diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa67c31 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +name: Release +on: + push: + tags: + - 'v*' +permissions: + contents: write +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..55ff6f1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test +on: + push: + branches: [main] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: proxy + POSTGRES_PASSWORD: proxy + POSTGRES_DB: etherpad_proxy + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U proxy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - name: Vet + run: go vet ./... + - name: Test (race) + env: + PG_TEST_DSN: postgres://proxy:proxy@localhost:5432/etherpad_proxy?sslmode=disable + run: go test -race ./... diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..6ac4c3f --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,32 @@ +version: 2 +project_name: etherpad-proxy +builds: + - id: etherpad-proxy + main: . + binary: etherpad-proxy + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 +archives: + - id: default + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - README.md + - LICENSE.md + - settings.json.template + - support/etherpad-proxy.service +checksum: + name_template: 'checksums.txt' +release: + github: + owner: ether + name: etherpad-proxy diff --git a/README.md b/README.md index f9c59a5..481d56d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,94 @@ This is now a production grade reverse proxy written in Golang. Visit http://localhost:9000 +## Installation without Docker + +The proxy is a single static Go binary. You can run it directly on a host +without Docker. + +### Option A: download a prebuilt binary + +1. Go to the [Releases page](https://github.com/ether/etherpad-proxy/releases) + and download the archive for your OS/architecture. +2. Extract it: `tar xzf etherpad-proxy_*.tar.gz` (or unzip on Windows). +3. Continue with **Configure** below. + +### Option B: build from source + +Requires Go 1.25 or newer. + +```bash +git clone https://github.com/ether/etherpad-proxy.git +cd etherpad-proxy +go build -o etherpad-proxy . +``` + +### Configure + +1. Copy the template and edit it: + ```bash + cp settings.json.template settings.json + ``` +2. Set `port` (proxy listen port), optionally `managementPort` + (default `8081`, serves `/pads`, `/metrics`, `/healthz`, `/readyz`), your + `backends`, and a database (see **Database** below). +3. The settings path defaults to `./settings.json`; override it with the + `SETTINGS_FILE` environment variable. + +### Run + +```bash +SETTINGS_FILE=/path/to/settings.json ./etherpad-proxy +``` + +The proxy listens on `port`; management/metrics/health endpoints listen on +`managementPort`. + +### Database + +- **SQLite (default, single instance):** set `dbSettings.filename`, e.g. + `"db/etherpad-proxy.db"`. No external services required. Create the directory + first: `mkdir -p db`. +- **Postgres (recommended for multiple proxy instances):** set + `dbSettings.postgresConnstr`. With a shared Postgres, several proxy instances + share routing state and assign new pads atomically, so they never route the + same pad to different backends. + + Provision a database and user, for example: + ```sql + CREATE USER proxy WITH PASSWORD 'changeme'; + CREATE DATABASE etherpad_proxy OWNER proxy; + ``` + Then set: + ```json + "dbSettings": { + "postgresConnstr": "postgres://proxy:changeme@db-host:5432/etherpad_proxy?sslmode=disable" + } + ``` + The proxy creates its tables automatically on first start. Set exactly one of + `filename` or `postgresConnstr`. + +### Run as a systemd service (Linux) + +A sample unit is provided at `support/etherpad-proxy.service`. + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin etherpad-proxy +sudo mkdir -p /opt/etherpad-proxy/db +sudo cp etherpad-proxy /opt/etherpad-proxy/ +sudo cp settings.json /opt/etherpad-proxy/ +sudo cp support/etherpad-proxy.service /etc/systemd/system/ +sudo chown -R etherpad-proxy:etherpad-proxy /opt/etherpad-proxy +sudo systemctl daemon-reload +sudo systemctl enable --now etherpad-proxy +``` + +Check status and logs: +```bash +systemctl status etherpad-proxy +journalctl -u etherpad-proxy -f +``` + ## Settings Settings come from ``settings.json``, see ``settings.json.template`` for an example to modify for your environment. diff --git a/checkAvailability_test.go b/checkAvailability_test.go new file mode 100644 index 0000000..6e6e305 --- /dev/null +++ b/checkAvailability_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strconv" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +// newStatsBackend starts a test server replying to /stats with the given +// activePads count, and returns its host and port. +func newStatsBackend(t *testing.T, activePads int) (string, int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/stats" { + fmt.Fprintf(w, `{"activePads": %d}`, activePads) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + return u.Hostname(), port +} + +func TestCheckAvailabilityCapacityAndDown(t *testing.T) { + okHost, okPort := newStatsBackend(t, 1) // under capacity -> up + available + fullHost, fullPort := newStatsBackend(t, 99) // over capacity -> up but not available + + settings := models.Settings{ + MaxPadsPerInstance: 5, + Backends: map[string]models.Backend{ + "ok": {Host: okHost, Port: okPort}, + "full": {Host: fullHost, Port: fullPort}, + "down": {Host: "127.0.0.1", Port: 1}, // nothing listening -> not up + }, + } + + got := checkAvailability(settings) + + if !slices.Contains(got.Up, "ok") || !slices.Contains(got.Up, "full") { + t.Fatalf("expected ok and full to be up, got %v", got.Up) + } + if slices.Contains(got.Up, "down") { + t.Fatalf("down backend should not be up, got %v", got.Up) + } + if !slices.Contains(got.Available, "ok") { + t.Fatalf("ok should be available, got %v", got.Available) + } + if slices.Contains(got.Available, "full") { + t.Fatalf("full backend should not be available, got %v", got.Available) + } +} diff --git a/databases/interfaces/iDB.go b/databases/interfaces/iDB.go index eaab14d..23165c1 100644 --- a/databases/interfaces/iDB.go +++ b/databases/interfaces/iDB.go @@ -10,4 +10,8 @@ type IDB interface { Set(id string, backend models.DBBackend) error GetAllPads() (map[string]string, error) GetClashByPadID(id string) ([]string, error) + // Assign stores candidate as the backend for padId only if no backend is + // already stored, and returns the backend now authoritative for padId (the + // pre-existing one if there was a race, otherwise candidate). + Assign(padId string, candidate string) (string, error) } diff --git a/databases/postgres/postgres_db.go b/databases/postgres/postgres_db.go index 3d4bec0..5fdb993 100644 --- a/databases/postgres/postgres_db.go +++ b/databases/postgres/postgres_db.go @@ -6,28 +6,30 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/ether/etherpad-proxy/databases/interfaces" "github.com/ether/etherpad-proxy/models" + _ "github.com/jackc/pgx/v5/stdlib" ) type Postgres struct { Conn *sql.DB + sb sq.StatementBuilderType } var _ interfaces.IDB = (*Postgres)(nil) func NewPostgresDB(connstr string) (*Postgres, error) { - conn, err := sql.Open("postgres", connstr) + conn, err := sql.Open("pgx", connstr) if err != nil { return nil, err } db := &Postgres{ Conn: conn, + sb: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), } if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS pad (id TEXT, backend TEXT, PRIMARY KEY (id))"); err != nil { return nil, err } - if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS clashes (id TEXT, data TEXT, PRIMARY KEY (id, data))"); err != nil { return nil, err } @@ -40,54 +42,70 @@ func (db *Postgres) Close() error { } func (db *Postgres) Get(id string) (*models.DBBackend, error) { - var data string - var sqlGet, args, err = sq.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() + sqlGet, args, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() if err != nil { return nil, err } - err = db.Conn.QueryRow(sqlGet, args...).Scan(&data) - if err != nil { + var data string + if err = db.Conn.QueryRow(sqlGet, args...).Scan(&data); err != nil { return nil, err } - - var actualData = models.DBBackend{ - Backend: data, - } - - return &actualData, nil + return &models.DBBackend{Backend: data}, nil } func (db *Postgres) CleanUpPads(padIds []string, padPrefix string) error { - sqlDelete, args, err := sq.Delete("pad").Where(sq.And{sq.NotEq{"id": padIds}, - sq.Like{"backend": padPrefix}}).ToSql() + sqlDelete, args, err := db.sb.Delete("pad"). + Where(sq.And{sq.NotEq{"id": padIds}, sq.Like{"backend": padPrefix}}).ToSql() if err != nil { return err } - _, err = db.Conn.Exec(sqlDelete, args...) return err } func (db *Postgres) RecordClash(id string, data string) error { - _, err := db.Conn.Exec("INSERT OR REPLACE INTO clashes (id, data) VALUES (?, ?)", id, data) + sqlStr, args, err := db.sb.Insert("clashes").Columns("id", "data").Values(id, data). + Suffix("ON CONFLICT (id, data) DO NOTHING").ToSql() if err != nil { return err } - return nil + _, err = db.Conn.Exec(sqlStr, args...) + return err } func (db *Postgres) Set(id string, dbModel models.DBBackend) error { - - _, err := db.Conn.Exec("INSERT OR REPLACE INTO pad (id, backend) VALUES (?, ?)", id, dbModel.Backend) + sqlStr, args, err := db.sb.Insert("pad").Columns("id", "backend").Values(id, dbModel.Backend). + Suffix("ON CONFLICT (id) DO UPDATE SET backend = EXCLUDED.backend").ToSql() if err != nil { return err } - return nil + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *Postgres) Assign(padId string, candidate string) (string, error) { + insSQL, insArgs, err := db.sb.Insert("pad").Columns("id", "backend").Values(padId, candidate). + Suffix("ON CONFLICT (id) DO NOTHING").ToSql() + if err != nil { + return "", err + } + if _, err = db.Conn.Exec(insSQL, insArgs...); err != nil { + return "", err + } + selSQL, selArgs, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return "", err + } + var backend string + if err = db.Conn.QueryRow(selSQL, selArgs...).Scan(&backend); err != nil { + return "", err + } + return backend, nil } func (db *Postgres) GetAllPads() (map[string]string, error) { - var padIDMap = make(map[string]string) - var sqlGet, args, err = sq.Select("id, backend").From("pad").ToSql() + padIDMap := make(map[string]string) + sqlGet, args, err := db.sb.Select("id", "backend").From("pad").ToSql() if err != nil { return nil, err } @@ -96,33 +114,27 @@ func (db *Postgres) GetAllPads() (map[string]string, error) { return nil, err } defer rows.Close() - for rows.Next() { - var padID string - var backend string + var padID, backend string if err := rows.Scan(&padID, &backend); err != nil { return nil, err } padIDMap[padID] = backend } - - return padIDMap, nil + return padIDMap, rows.Err() } func (db *Postgres) GetClashByPadID(padId string) ([]string, error) { - var sqlGet, args, err = sq.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() - + sqlGet, args, err := db.sb.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() if err != nil { return nil, err } - rows, err := db.Conn.Query(sqlGet, args...) if err != nil { return nil, err } - defer rows.Close() - var data = make([]string, 0) + data := make([]string, 0) for rows.Next() { var clash string if err := rows.Scan(&clash); err != nil { @@ -130,6 +142,5 @@ func (db *Postgres) GetClashByPadID(padId string) ([]string, error) { } data = append(data, clash) } - - return data, nil + return data, rows.Err() } diff --git a/databases/postgres/postgres_db_test.go b/databases/postgres/postgres_db_test.go new file mode 100644 index 0000000..8aacf13 --- /dev/null +++ b/databases/postgres/postgres_db_test.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +func newTestDB(t *testing.T) *Postgres { + t.Helper() + dsn := os.Getenv("PG_TEST_DSN") + if dsn == "" { + t.Skip("PG_TEST_DSN not set; skipping Postgres integration test") + } + db, err := NewPostgresDB(dsn) + if err != nil { + t.Fatalf("NewPostgresDB: %v", err) + } + // Start from a clean slate so reruns are deterministic. + if _, err := db.Conn.Exec("TRUNCATE pad, clashes"); err != nil { + t.Fatalf("truncate: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestPostgresAssignIsAtomic(t *testing.T) { + db := newTestDB(t) + first, err := db.Assign("pad1", "b1") + if err != nil { + t.Fatalf("Assign: %v", err) + } + if first != "b1" { + t.Fatalf("expected b1, got %s", first) + } + second, err := db.Assign("pad1", "b2") + if err != nil { + t.Fatalf("Assign 2: %v", err) + } + if second != "b1" { + t.Fatalf("expected assign to keep b1, got %s", second) + } +} + +func TestPostgresSetGetUpsert(t *testing.T) { + db := newTestDB(t) + if err := db.Set("pad1", models.DBBackend{Backend: "b1"}); err != nil { + t.Fatalf("Set: %v", err) + } + if err := db.Set("pad1", models.DBBackend{Backend: "b2"}); err != nil { + t.Fatalf("Set upsert: %v", err) + } + got, err := db.Get("pad1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Backend != "b2" { + t.Fatalf("expected b2, got %s", got.Backend) + } + if _, err := db.Get("missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected ErrNoRows, got %v", err) + } +} + +func TestPostgresClashes(t *testing.T) { + db := newTestDB(t) + _ = db.RecordClash("pad1", "b1") + _ = db.RecordClash("pad1", "b1") + _ = db.RecordClash("pad1", "b2") + clashes, err := db.GetClashByPadID("pad1") + if err != nil { + t.Fatalf("GetClashByPadID: %v", err) + } + if len(clashes) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(clashes)) + } +} diff --git a/databases/sqlite/sqlite_db.go b/databases/sqlite/sqlite_db.go index e0f694f..1c8c119 100644 --- a/databases/sqlite/sqlite_db.go +++ b/databases/sqlite/sqlite_db.go @@ -3,17 +3,19 @@ package sqlite import ( "database/sql" + sq "github.com/Masterminds/squirrel" "github.com/ether/etherpad-proxy/databases/interfaces" "github.com/ether/etherpad-proxy/models" _ "modernc.org/sqlite" ) -import sq "github.com/Masterminds/squirrel" - type DB struct { Conn *sql.DB + sb sq.StatementBuilderType } +var _ interfaces.IDB = (*DB)(nil) + func NewSQLiteDB(filename string) (*DB, error) { conn, err := sql.Open("sqlite", filename) if err != nil { @@ -22,6 +24,7 @@ func NewSQLiteDB(filename string) (*DB, error) { db := &DB{ Conn: conn, + sb: sq.StatementBuilder.PlaceholderFormat(sq.Question), } if _, err = db.Conn.Exec("PRAGMA foreign_keys = ON"); err != nil { @@ -30,7 +33,6 @@ func NewSQLiteDB(filename string) (*DB, error) { if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS pad (id TEXT, backend TEXT, PRIMARY KEY (id))"); err != nil { return nil, err } - if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS clashes (id TEXT, data TEXT, PRIMARY KEY (id, data))"); err != nil { return nil, err } @@ -43,54 +45,70 @@ func (db *DB) Close() error { } func (db *DB) Get(id string) (*models.DBBackend, error) { - var data string - var sqlGet, args, err = sq.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() + sqlGet, args, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() if err != nil { return nil, err } - err = db.Conn.QueryRow(sqlGet, args...).Scan(&data) - if err != nil { + var data string + if err = db.Conn.QueryRow(sqlGet, args...).Scan(&data); err != nil { return nil, err } - - var actualData = models.DBBackend{ - Backend: data, - } - - return &actualData, nil + return &models.DBBackend{Backend: data}, nil } func (db *DB) CleanUpPads(padIds []string, padPrefix string) error { - sqlDelete, args, err := sq.Delete("pad").Where(sq.And{sq.NotEq{"id": padIds}, - sq.Like{"backend": padPrefix}}).ToSql() + sqlDelete, args, err := db.sb.Delete("pad"). + Where(sq.And{sq.NotEq{"id": padIds}, sq.Like{"backend": padPrefix}}).ToSql() if err != nil { return err } - _, err = db.Conn.Exec(sqlDelete, args...) return err } func (db *DB) RecordClash(id string, data string) error { - _, err := db.Conn.Exec("INSERT OR REPLACE INTO clashes (id, data) VALUES (?, ?)", id, data) + sqlStr, args, err := db.sb.Insert("clashes").Columns("id", "data").Values(id, data). + Suffix("ON CONFLICT (id, data) DO NOTHING").ToSql() if err != nil { return err } - return nil + _, err = db.Conn.Exec(sqlStr, args...) + return err } func (db *DB) Set(id string, dbModel models.DBBackend) error { - - _, err := db.Conn.Exec("INSERT OR REPLACE INTO pad (id, backend) VALUES (?, ?)", id, dbModel.Backend) + sqlStr, args, err := db.sb.Insert("pad").Columns("id", "backend").Values(id, dbModel.Backend). + Suffix("ON CONFLICT (id) DO UPDATE SET backend = EXCLUDED.backend").ToSql() if err != nil { return err } - return nil + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *DB) Assign(padId string, candidate string) (string, error) { + insSQL, insArgs, err := db.sb.Insert("pad").Columns("id", "backend").Values(padId, candidate). + Suffix("ON CONFLICT (id) DO NOTHING").ToSql() + if err != nil { + return "", err + } + if _, err = db.Conn.Exec(insSQL, insArgs...); err != nil { + return "", err + } + selSQL, selArgs, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return "", err + } + var backend string + if err = db.Conn.QueryRow(selSQL, selArgs...).Scan(&backend); err != nil { + return "", err + } + return backend, nil } func (db *DB) GetAllPads() (map[string]string, error) { - var padIDMap = make(map[string]string) - var sqlGet, args, err = sq.Select("id, backend").From("pad").ToSql() + padIDMap := make(map[string]string) + sqlGet, args, err := db.sb.Select("id", "backend").From("pad").ToSql() if err != nil { return nil, err } @@ -99,33 +117,27 @@ func (db *DB) GetAllPads() (map[string]string, error) { return nil, err } defer rows.Close() - for rows.Next() { - var padID string - var backend string + var padID, backend string if err := rows.Scan(&padID, &backend); err != nil { return nil, err } padIDMap[padID] = backend } - - return padIDMap, nil + return padIDMap, rows.Err() } func (db *DB) GetClashByPadID(padId string) ([]string, error) { - var sqlGet, args, err = sq.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() - + sqlGet, args, err := db.sb.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() if err != nil { return nil, err } - rows, err := db.Conn.Query(sqlGet, args...) if err != nil { return nil, err } - defer rows.Close() - var data = make([]string, 0) + data := make([]string, 0) for rows.Next() { var clash string if err := rows.Scan(&clash); err != nil { @@ -133,8 +145,5 @@ func (db *DB) GetClashByPadID(padId string) ([]string, error) { } data = append(data, clash) } - - return data, nil + return data, rows.Err() } - -var _ interfaces.IDB = (*DB)(nil) diff --git a/databases/sqlite/sqlite_db_test.go b/databases/sqlite/sqlite_db_test.go new file mode 100644 index 0000000..93a3f0b --- /dev/null +++ b/databases/sqlite/sqlite_db_test.go @@ -0,0 +1,108 @@ +package sqlite + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +func newTestDB(t *testing.T) *DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + db, err := NewSQLiteDB(path) + if err != nil { + t.Fatalf("NewSQLiteDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestSetAndGet(t *testing.T) { + db := newTestDB(t) + if err := db.Set("pad1", models.DBBackend{Backend: "b1"}); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := db.Get("pad1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Backend != "b1" { + t.Fatalf("expected b1, got %s", got.Backend) + } +} + +func TestSetUpsert(t *testing.T) { + db := newTestDB(t) + _ = db.Set("pad1", models.DBBackend{Backend: "b1"}) + if err := db.Set("pad1", models.DBBackend{Backend: "b2"}); err != nil { + t.Fatalf("Set upsert: %v", err) + } + got, _ := db.Get("pad1") + if got.Backend != "b2" { + t.Fatalf("expected b2 after upsert, got %s", got.Backend) + } +} + +func TestAssignIsAtomic(t *testing.T) { + db := newTestDB(t) + first, err := db.Assign("pad1", "b1") + if err != nil { + t.Fatalf("Assign: %v", err) + } + if first != "b1" { + t.Fatalf("expected b1, got %s", first) + } + second, err := db.Assign("pad1", "b2") + if err != nil { + t.Fatalf("Assign 2: %v", err) + } + if second != "b1" { + t.Fatalf("expected assign to keep b1, got %s", second) + } +} + +func TestGetMissingReturnsNoRows(t *testing.T) { + db := newTestDB(t) + if _, err := db.Get("missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestRecordClashAndGet(t *testing.T) { + db := newTestDB(t) + if err := db.RecordClash("pad1", "b1"); err != nil { + t.Fatalf("RecordClash: %v", err) + } + if err := db.RecordClash("pad1", "b1"); err != nil { + t.Fatalf("RecordClash dup: %v", err) + } + if err := db.RecordClash("pad1", "b2"); err != nil { + t.Fatalf("RecordClash 2: %v", err) + } + clashes, err := db.GetClashByPadID("pad1") + if err != nil { + t.Fatalf("GetClashByPadID: %v", err) + } + if len(clashes) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(clashes)) + } +} + +func TestCleanUpPads(t *testing.T) { + db := newTestDB(t) + _ = db.Set("keep", models.DBBackend{Backend: "b1"}) + _ = db.Set("stale", models.DBBackend{Backend: "b1"}) + if err := db.CleanUpPads([]string{"keep"}, "b1"); err != nil { + t.Fatalf("CleanUpPads: %v", err) + } + all, _ := db.GetAllPads() + if _, ok := all["stale"]; ok { + t.Fatalf("stale pad should have been deleted") + } + if _, ok := all["keep"]; !ok { + t.Fatalf("keep pad should remain") + } +} diff --git a/docs/superpowers/plans/2026-05-28-production-ready-proxy.md b/docs/superpowers/plans/2026-05-28-production-ready-proxy.md new file mode 100644 index 0000000..2ff9661 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-production-ready-proxy.md @@ -0,0 +1,2105 @@ +# Production-Ready Etherpad Proxy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make etherpad-proxy reliable for multi-instance production use — working Postgres-backed shared state with race-free pad assignment, no data races, observability/operability, tests + CI, and non-Docker installation docs with prebuilt binaries. + +**Architecture:** A fleet of stateless proxy instances shares `padId → backend` routing through a shared database (SQLite for single-instance, Postgres for multi-instance). Pad assignment is atomic (`INSERT … ON CONFLICT DO NOTHING` + read-back) so concurrent proxies never split-brain a new pad. Per-instance state (which backends are up/available) stays in-memory behind an `RWMutex`. The process exposes health, readiness, and Prometheus metrics on a management port and shuts down gracefully. + +**Tech Stack:** Go 1.24+, `database/sql` + Masterminds/squirrel, `jackc/pgx/v5` (Postgres driver via stdlib), `modernc.org/sqlite`, `prometheus/client_golang`, zap, GoReleaser + GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-05-28-production-ready-proxy-design.md` + +--- + +## File Structure + +**Create:** +- `metrics/metrics.go` — Prometheus collectors, updated by handlers and loops. +- `databases/sqlite/sqlite_db_test.go` — DB contract tests against temp-file SQLite. +- `databases/postgres/postgres_db_test.go` — same contract, gated on `PG_TEST_DSN`. +- `models/backendstate_test.go` — concurrency-safe state tests. +- `models/settings_test.go` — config validation tests. +- `proxyHandler_test.go` — routing decision tests with a fake `IDB`. +- `checkAvailability_test.go` — availability check against `httptest` backends. +- `support/etherpad-proxy.service` — sample systemd unit. +- `.goreleaser.yaml` — cross-platform release build config. +- `.github/workflows/test.yml` — vet + race tests + Postgres service. +- `.github/workflows/release.yml` — tag-triggered GoReleaser release. + +**Modify:** +- `databases/interfaces/iDB.go` — add `Assign`. +- `databases/sqlite/sqlite_db.go` — dialect-correct upserts, `Assign`, placeholder format. +- `databases/postgres/postgres_db.go` — pgx driver, `$N` placeholders, upserts, `Assign`. +- `models/AvailableBackends.go` — replace bare struct with `RWMutex`-guarded `BackendState`. +- `models/Settings.go` — add `ManagementPort`, add `Validate()`. +- `main.go` — call `Validate()`. +- `proxyHandler.go` — extract `chooseBackend`, guard static resources, use `BackendState`, metrics. +- `runtime.go` — `BackendState` wiring, graceful shutdown, health/readiness/metrics endpoints, structured logging. +- `go.mod` / `go.sum` — add pgx + prometheus, drop lib/pq. +- `README.md` — "Installation without Docker" + Postgres + systemd + binaries. + +--- + +## Task 1: Database layer — pgx driver, dialect-correct SQL, atomic `Assign` + +**Files:** +- Modify: `databases/interfaces/iDB.go` +- Modify: `databases/sqlite/sqlite_db.go` +- Modify: `databases/postgres/postgres_db.go` +- Create: `databases/sqlite/sqlite_db_test.go` +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Add pgx dependency, drop lib/pq** + +Run: +```bash +go get github.com/jackc/pgx/v5@latest +go mod tidy +``` +Expected: `github.com/jackc/pgx/v5` appears in `go.mod` require block; `github.com/lib/pq` is removed after the postgres file stops importing it (Step 5). Re-run `go mod tidy` after Step 5. + +- [ ] **Step 2: Add `Assign` to the IDB interface** + +Replace the contents of `databases/interfaces/iDB.go`: +```go +package interfaces + +import "github.com/ether/etherpad-proxy/models" + +type IDB interface { + Close() error + Get(id string) (*models.DBBackend, error) + CleanUpPads(padIds []string, padPrefix string) error + RecordClash(id string, data string) error + Set(id string, backend models.DBBackend) error + GetAllPads() (map[string]string, error) + GetClashByPadID(id string) ([]string, error) + // Assign stores candidate as the backend for padId only if no backend is + // already stored, and returns the backend now authoritative for padId (the + // pre-existing one if there was a race, otherwise candidate). + Assign(padId string, candidate string) (string, error) +} +``` + +- [ ] **Step 3: Write the failing SQLite test** + +Create `databases/sqlite/sqlite_db_test.go`: +```go +package sqlite + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +func newTestDB(t *testing.T) *DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + db, err := NewSQLiteDB(path) + if err != nil { + t.Fatalf("NewSQLiteDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestSetAndGet(t *testing.T) { + db := newTestDB(t) + if err := db.Set("pad1", models.DBBackend{Backend: "b1"}); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := db.Get("pad1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Backend != "b1" { + t.Fatalf("expected b1, got %s", got.Backend) + } +} + +func TestSetUpsert(t *testing.T) { + db := newTestDB(t) + _ = db.Set("pad1", models.DBBackend{Backend: "b1"}) + if err := db.Set("pad1", models.DBBackend{Backend: "b2"}); err != nil { + t.Fatalf("Set upsert: %v", err) + } + got, _ := db.Get("pad1") + if got.Backend != "b2" { + t.Fatalf("expected b2 after upsert, got %s", got.Backend) + } +} + +func TestAssignIsAtomic(t *testing.T) { + db := newTestDB(t) + first, err := db.Assign("pad1", "b1") + if err != nil { + t.Fatalf("Assign: %v", err) + } + if first != "b1" { + t.Fatalf("expected b1, got %s", first) + } + second, err := db.Assign("pad1", "b2") + if err != nil { + t.Fatalf("Assign 2: %v", err) + } + if second != "b1" { + t.Fatalf("expected assign to keep b1, got %s", second) + } +} + +func TestGetMissingReturnsNoRows(t *testing.T) { + db := newTestDB(t) + if _, err := db.Get("missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestRecordClashAndGet(t *testing.T) { + db := newTestDB(t) + if err := db.RecordClash("pad1", "b1"); err != nil { + t.Fatalf("RecordClash: %v", err) + } + if err := db.RecordClash("pad1", "b1"); err != nil { + t.Fatalf("RecordClash dup: %v", err) + } + if err := db.RecordClash("pad1", "b2"); err != nil { + t.Fatalf("RecordClash 2: %v", err) + } + clashes, err := db.GetClashByPadID("pad1") + if err != nil { + t.Fatalf("GetClashByPadID: %v", err) + } + if len(clashes) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(clashes)) + } +} + +func TestCleanUpPads(t *testing.T) { + db := newTestDB(t) + _ = db.Set("keep", models.DBBackend{Backend: "b1"}) + _ = db.Set("stale", models.DBBackend{Backend: "b1"}) + if err := db.CleanUpPads([]string{"keep"}, "b1"); err != nil { + t.Fatalf("CleanUpPads: %v", err) + } + all, _ := db.GetAllPads() + if _, ok := all["stale"]; ok { + t.Fatalf("stale pad should have been deleted") + } + if _, ok := all["keep"]; !ok { + t.Fatalf("keep pad should remain") + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `go test ./databases/sqlite/ -run TestAssignIsAtomic -v` +Expected: compile failure — `db.Assign undefined` (method not implemented yet). + +- [ ] **Step 5: Rewrite the SQLite implementation** + +Replace the contents of `databases/sqlite/sqlite_db.go`: +```go +package sqlite + +import ( + "database/sql" + + sq "github.com/Masterminds/squirrel" + "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/models" + _ "modernc.org/sqlite" +) + +type DB struct { + Conn *sql.DB + sb sq.StatementBuilderType +} + +var _ interfaces.IDB = (*DB)(nil) + +func NewSQLiteDB(filename string) (*DB, error) { + conn, err := sql.Open("sqlite", filename) + if err != nil { + return nil, err + } + + db := &DB{ + Conn: conn, + sb: sq.StatementBuilder.PlaceholderFormat(sq.Question), + } + + if _, err = db.Conn.Exec("PRAGMA foreign_keys = ON"); err != nil { + return nil, err + } + if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS pad (id TEXT, backend TEXT, PRIMARY KEY (id))"); err != nil { + return nil, err + } + if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS clashes (id TEXT, data TEXT, PRIMARY KEY (id, data))"); err != nil { + return nil, err + } + + return db, nil +} + +func (db *DB) Close() error { + return db.Conn.Close() +} + +func (db *DB) Get(id string) (*models.DBBackend, error) { + sqlGet, args, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() + if err != nil { + return nil, err + } + var data string + if err = db.Conn.QueryRow(sqlGet, args...).Scan(&data); err != nil { + return nil, err + } + return &models.DBBackend{Backend: data}, nil +} + +func (db *DB) CleanUpPads(padIds []string, padPrefix string) error { + sqlDelete, args, err := db.sb.Delete("pad"). + Where(sq.And{sq.NotEq{"id": padIds}, sq.Like{"backend": padPrefix}}).ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlDelete, args...) + return err +} + +func (db *DB) RecordClash(id string, data string) error { + sqlStr, args, err := db.sb.Insert("clashes").Columns("id", "data").Values(id, data). + Suffix("ON CONFLICT (id, data) DO NOTHING").ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *DB) Set(id string, dbModel models.DBBackend) error { + sqlStr, args, err := db.sb.Insert("pad").Columns("id", "backend").Values(id, dbModel.Backend). + Suffix("ON CONFLICT (id) DO UPDATE SET backend = EXCLUDED.backend").ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *DB) Assign(padId string, candidate string) (string, error) { + insSQL, insArgs, err := db.sb.Insert("pad").Columns("id", "backend").Values(padId, candidate). + Suffix("ON CONFLICT (id) DO NOTHING").ToSql() + if err != nil { + return "", err + } + if _, err = db.Conn.Exec(insSQL, insArgs...); err != nil { + return "", err + } + selSQL, selArgs, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return "", err + } + var backend string + if err = db.Conn.QueryRow(selSQL, selArgs...).Scan(&backend); err != nil { + return "", err + } + return backend, nil +} + +func (db *DB) GetAllPads() (map[string]string, error) { + padIDMap := make(map[string]string) + sqlGet, args, err := db.sb.Select("id", "backend").From("pad").ToSql() + if err != nil { + return nil, err + } + rows, err := db.Conn.Query(sqlGet, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var padID, backend string + if err := rows.Scan(&padID, &backend); err != nil { + return nil, err + } + padIDMap[padID] = backend + } + return padIDMap, rows.Err() +} + +func (db *DB) GetClashByPadID(padId string) ([]string, error) { + sqlGet, args, err := db.sb.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return nil, err + } + rows, err := db.Conn.Query(sqlGet, args...) + if err != nil { + return nil, err + } + defer rows.Close() + data := make([]string, 0) + for rows.Next() { + var clash string + if err := rows.Scan(&clash); err != nil { + return nil, err + } + data = append(data, clash) + } + return data, rows.Err() +} +``` + +- [ ] **Step 6: Rewrite the Postgres implementation (pgx + `$N` + upserts)** + +Replace the contents of `databases/postgres/postgres_db.go`: +```go +package postgres + +import ( + "database/sql" + + sq "github.com/Masterminds/squirrel" + "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/models" + _ "github.com/jackc/pgx/v5/stdlib" +) + +type Postgres struct { + Conn *sql.DB + sb sq.StatementBuilderType +} + +var _ interfaces.IDB = (*Postgres)(nil) + +func NewPostgresDB(connstr string) (*Postgres, error) { + conn, err := sql.Open("pgx", connstr) + if err != nil { + return nil, err + } + + db := &Postgres{ + Conn: conn, + sb: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + } + + if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS pad (id TEXT, backend TEXT, PRIMARY KEY (id))"); err != nil { + return nil, err + } + if _, err = db.Conn.Exec("CREATE TABLE IF NOT EXISTS clashes (id TEXT, data TEXT, PRIMARY KEY (id, data))"); err != nil { + return nil, err + } + + return db, nil +} + +func (db *Postgres) Close() error { + return db.Conn.Close() +} + +func (db *Postgres) Get(id string) (*models.DBBackend, error) { + sqlGet, args, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": id}).ToSql() + if err != nil { + return nil, err + } + var data string + if err = db.Conn.QueryRow(sqlGet, args...).Scan(&data); err != nil { + return nil, err + } + return &models.DBBackend{Backend: data}, nil +} + +func (db *Postgres) CleanUpPads(padIds []string, padPrefix string) error { + sqlDelete, args, err := db.sb.Delete("pad"). + Where(sq.And{sq.NotEq{"id": padIds}, sq.Like{"backend": padPrefix}}).ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlDelete, args...) + return err +} + +func (db *Postgres) RecordClash(id string, data string) error { + sqlStr, args, err := db.sb.Insert("clashes").Columns("id", "data").Values(id, data). + Suffix("ON CONFLICT (id, data) DO NOTHING").ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *Postgres) Set(id string, dbModel models.DBBackend) error { + sqlStr, args, err := db.sb.Insert("pad").Columns("id", "backend").Values(id, dbModel.Backend). + Suffix("ON CONFLICT (id) DO UPDATE SET backend = EXCLUDED.backend").ToSql() + if err != nil { + return err + } + _, err = db.Conn.Exec(sqlStr, args...) + return err +} + +func (db *Postgres) Assign(padId string, candidate string) (string, error) { + insSQL, insArgs, err := db.sb.Insert("pad").Columns("id", "backend").Values(padId, candidate). + Suffix("ON CONFLICT (id) DO NOTHING").ToSql() + if err != nil { + return "", err + } + if _, err = db.Conn.Exec(insSQL, insArgs...); err != nil { + return "", err + } + selSQL, selArgs, err := db.sb.Select("backend").From("pad").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return "", err + } + var backend string + if err = db.Conn.QueryRow(selSQL, selArgs...).Scan(&backend); err != nil { + return "", err + } + return backend, nil +} + +func (db *Postgres) GetAllPads() (map[string]string, error) { + padIDMap := make(map[string]string) + sqlGet, args, err := db.sb.Select("id", "backend").From("pad").ToSql() + if err != nil { + return nil, err + } + rows, err := db.Conn.Query(sqlGet, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var padID, backend string + if err := rows.Scan(&padID, &backend); err != nil { + return nil, err + } + padIDMap[padID] = backend + } + return padIDMap, rows.Err() +} + +func (db *Postgres) GetClashByPadID(padId string) ([]string, error) { + sqlGet, args, err := db.sb.Select("data").From("clashes").Where(sq.Eq{"id": padId}).ToSql() + if err != nil { + return nil, err + } + rows, err := db.Conn.Query(sqlGet, args...) + if err != nil { + return nil, err + } + defer rows.Close() + data := make([]string, 0) + for rows.Next() { + var clash string + if err := rows.Scan(&clash); err != nil { + return nil, err + } + data = append(data, clash) + } + return data, rows.Err() +} +``` + +- [ ] **Step 7: Tidy modules and run the tests** + +Run: +```bash +go mod tidy +go test ./databases/... -v +``` +Expected: `lib/pq` gone from `go.mod`; all SQLite tests PASS. (Postgres tests are added in Task 2 and skip without a DSN.) + +- [ ] **Step 8: Commit** + +```bash +git add databases/ go.mod go.sum +git commit -m "fix: make Postgres functional and pad assignment atomic (#54) + +- swap lib/pq for jackc/pgx/v5 (driver was never registered) +- use dialect-correct placeholders and ON CONFLICT upserts +- add atomic Assign to prevent multi-proxy split-brain +- add SQLite DB layer tests + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Postgres integration test (gated on `PG_TEST_DSN`) + +**Files:** +- Create: `databases/postgres/postgres_db_test.go` + +- [ ] **Step 1: Write the gated integration test** + +Create `databases/postgres/postgres_db_test.go`: +```go +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +func newTestDB(t *testing.T) *Postgres { + t.Helper() + dsn := os.Getenv("PG_TEST_DSN") + if dsn == "" { + t.Skip("PG_TEST_DSN not set; skipping Postgres integration test") + } + db, err := NewPostgresDB(dsn) + if err != nil { + t.Fatalf("NewPostgresDB: %v", err) + } + // Start from a clean slate so reruns are deterministic. + if _, err := db.Conn.Exec("TRUNCATE pad, clashes"); err != nil { + t.Fatalf("truncate: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestPostgresAssignIsAtomic(t *testing.T) { + db := newTestDB(t) + first, err := db.Assign("pad1", "b1") + if err != nil { + t.Fatalf("Assign: %v", err) + } + if first != "b1" { + t.Fatalf("expected b1, got %s", first) + } + second, err := db.Assign("pad1", "b2") + if err != nil { + t.Fatalf("Assign 2: %v", err) + } + if second != "b1" { + t.Fatalf("expected assign to keep b1, got %s", second) + } +} + +func TestPostgresSetGetUpsert(t *testing.T) { + db := newTestDB(t) + if err := db.Set("pad1", models.DBBackend{Backend: "b1"}); err != nil { + t.Fatalf("Set: %v", err) + } + if err := db.Set("pad1", models.DBBackend{Backend: "b2"}); err != nil { + t.Fatalf("Set upsert: %v", err) + } + got, err := db.Get("pad1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Backend != "b2" { + t.Fatalf("expected b2, got %s", got.Backend) + } + if _, err := db.Get("missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected ErrNoRows, got %v", err) + } +} + +func TestPostgresClashes(t *testing.T) { + db := newTestDB(t) + _ = db.RecordClash("pad1", "b1") + _ = db.RecordClash("pad1", "b1") + _ = db.RecordClash("pad1", "b2") + clashes, err := db.GetClashByPadID("pad1") + if err != nil { + t.Fatalf("GetClashByPadID: %v", err) + } + if len(clashes) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(clashes)) + } +} +``` + +- [ ] **Step 2: Verify it skips without a DSN** + +Run: `go test ./databases/postgres/ -v` +Expected: tests report `--- SKIP` (PG_TEST_DSN not set). + +- [ ] **Step 3: Commit** + +```bash +git add databases/postgres/postgres_db_test.go +git commit -m "test: add gated Postgres integration tests + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Concurrency-safe `BackendState` + +**Files:** +- Modify: `models/AvailableBackends.go` +- Create: `models/backendstate_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `models/backendstate_test.go`: +```go +package models + +import ( + "sync" + "testing" +) + +func TestBackendStateSnapshotIsolated(t *testing.T) { + var s BackendState + s.SetState([]string{"a", "b"}, []string{"a"}) + avail := s.SnapshotAvailable() + avail[0] = "mutated" + if got := s.SnapshotAvailable(); got[0] != "a" { + t.Fatalf("snapshot should be a copy, got %v", got) + } +} + +func TestBackendStateIsUp(t *testing.T) { + var s BackendState + s.SetState(nil, []string{"a"}) + if !s.IsUp("a") { + t.Fatal("a should be up") + } + if s.IsUp("b") { + t.Fatal("b should not be up") + } +} + +func TestBackendStateConcurrent(t *testing.T) { + var s BackendState + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { defer wg.Done(); s.SetState([]string{"a"}, []string{"a"}) }() + go func() { defer wg.Done(); _ = s.SnapshotUp() }() + } + wg.Wait() +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./models/ -run TestBackendState -v` +Expected: compile failure — `undefined: BackendState`. + +- [ ] **Step 3: Replace `AvailableBackends.go` with `BackendState`** + +Replace the contents of `models/AvailableBackends.go`: +```go +package models + +import ( + "slices" + "sync" +) + +// BackendState holds the per-instance view of which backends are up and which +// still have capacity (available). It is safe for concurrent use. +type BackendState struct { + mu sync.RWMutex + up []string + available []string +} + +// SetState atomically replaces both lists with copies of the provided slices. +func (b *BackendState) SetState(available, up []string) { + b.mu.Lock() + defer b.mu.Unlock() + b.available = append([]string(nil), available...) + b.up = append([]string(nil), up...) +} + +// SnapshotAvailable returns a copy of the available list. +func (b *BackendState) SnapshotAvailable() []string { + b.mu.RLock() + defer b.mu.RUnlock() + return append([]string(nil), b.available...) +} + +// SnapshotUp returns a copy of the up list. +func (b *BackendState) SnapshotUp() []string { + b.mu.RLock() + defer b.mu.RUnlock() + return append([]string(nil), b.up...) +} + +// IsUp reports whether backend is currently up. +func (b *BackendState) IsUp(backend string) bool { + b.mu.RLock() + defer b.mu.RUnlock() + return slices.Contains(b.up, backend) +} +``` + +- [ ] **Step 4: Run the test (race detector) to verify it passes** + +Run: `go test ./models/ -run TestBackendState -race -v` +Expected: all PASS, no race warnings. (Package `main` won't compile yet — that's fixed in Tasks 7–8.) + +- [ ] **Step 5: Commit** + +```bash +git add models/AvailableBackends.go models/backendstate_test.go +git commit -m "refactor: add RWMutex-guarded BackendState + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Prometheus metrics package + +**Files:** +- Create: `metrics/metrics.go` +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Add the Prometheus dependency** + +Run: `go get github.com/prometheus/client_golang@latest` +Expected: `github.com/prometheus/client_golang` added to `go.mod`. + +- [ ] **Step 2: Create the metrics package** + +Create `metrics/metrics.go`: +```go +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // RequestsTotal counts proxy requests by outcome: + // proxied, no_backend, clash, resource_redirect. + RequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "etherpad_proxy_requests_total", + Help: "Total proxy requests by outcome.", + }, []string{"outcome"}) + + // BackendsUp is the number of backends currently reachable. + BackendsUp = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "etherpad_proxy_backends_up", + Help: "Number of backends currently up.", + }) + + // BackendsAvailable is the number of up backends still under capacity. + BackendsAvailable = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "etherpad_proxy_backends_available", + Help: "Number of backends currently available (up and under capacity).", + }) + + // PadAssignmentsTotal counts new pad-to-backend assignments. + PadAssignmentsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_pad_assignments_total", + Help: "Total number of new pad assignments.", + }) + + // ClashesTotal counts recorded pad clashes. + ClashesTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_clashes_total", + Help: "Total number of recorded pad clashes.", + }) + + // DBErrorsTotal counts database operation errors. + DBErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_db_errors_total", + Help: "Total number of database errors.", + }) +) +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./metrics/` +Expected: no output (success). + +- [ ] **Step 4: Commit** + +```bash +git add metrics/ go.mod go.sum +git commit -m "feat: add Prometheus metrics package + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Settings — management port + startup validation + +**Files:** +- Modify: `models/Settings.go` +- Modify: `main.go` +- Create: `models/settings_test.go` + +- [ ] **Step 1: Write the failing validation test** + +Create `models/settings_test.go`: +```go +package models + +import "testing" + +func strPtr(s string) *string { return &s } + +func validBackend() Backend { + return Backend{Host: "localhost", Port: 9001} +} + +func baseSettings() Settings { + return Settings{ + Port: 9000, + Backends: map[string]Backend{"b1": validBackend()}, + DBSettings: DBSettings{Filename: "db/x.db"}, + } +} + +func TestValidateOK(t *testing.T) { + if err := baseSettings().Validate(); err != nil { + t.Fatalf("expected valid, got %v", err) + } +} + +func TestValidateBadPort(t *testing.T) { + s := baseSettings() + s.Port = 0 + if err := s.Validate(); err == nil { + t.Fatal("expected error for port 0") + } +} + +func TestValidateNoBackends(t *testing.T) { + s := baseSettings() + s.Backends = nil + if err := s.Validate(); err == nil { + t.Fatal("expected error for no backends") + } +} + +func TestValidateBothDBSet(t *testing.T) { + s := baseSettings() + s.DBSettings = DBSettings{Filename: "db/x.db", Connstr: "postgres://x"} + if err := s.Validate(); err == nil { + t.Fatal("expected error when both filename and connstr set") + } +} + +func TestValidateNeitherDBSet(t *testing.T) { + s := baseSettings() + s.DBSettings = DBSettings{} + if err := s.Validate(); err == nil { + t.Fatal("expected error when neither filename nor connstr set") + } +} + +func TestValidatePartialBasicAuth(t *testing.T) { + s := baseSettings() + b := validBackend() + b.Username = strPtr("admin") // password missing + s.Backends = map[string]Backend{"b1": b} + if err := s.Validate(); err == nil { + t.Fatal("expected error for username without password") + } +} + +func TestValidatePartialOAuth(t *testing.T) { + s := baseSettings() + b := validBackend() + b.ClientId = strPtr("id") // secret + tokenURL missing + s.Backends = map[string]Backend{"b1": b} + if err := s.Validate(); err == nil { + t.Fatal("expected error for partial oauth config") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./models/ -run TestValidate -v` +Expected: compile failure — `s.Validate undefined`. + +- [ ] **Step 3: Add `ManagementPort` and `Validate` to Settings** + +Replace the contents of `models/Settings.go`: +```go +package models + +import ( + "errors" + "fmt" +) + +type Settings struct { + Port int `json:"port"` + ManagementPort int `json:"managementPort"` + Backends map[string]Backend `json:"backends"` + MaxPadsPerInstance int `json:"maxPadsPerInstance"` + CheckInterval int64 `json:"checkInterval"` + DBSettings DBSettings `json:"dbSettings"` +} + +type DBSettings struct { + Filename string `json:"filename"` + Connstr string `json:"postgresConnstr"` +} + +type Backend struct { + Host string `json:"host"` + Port int `json:"port"` + ClientId *string `json:"clientId"` + ClientSecret *string `json:"clientSecret"` + Scopes []string `json:"scopes"` + TokenURL *string `json:"tokenUrl"` + Username *string `json:"username"` + Password *string `json:"password"` +} + +func validPort(p int) bool { return p > 0 && p <= 65535 } + +// Validate checks that the settings are internally consistent and usable. +func (s Settings) Validate() error { + if !validPort(s.Port) { + return fmt.Errorf("port must be between 1 and 65535, got %d", s.Port) + } + if s.ManagementPort != 0 && !validPort(s.ManagementPort) { + return fmt.Errorf("managementPort must be between 1 and 65535, got %d", s.ManagementPort) + } + if len(s.Backends) == 0 { + return errors.New("at least one backend must be configured") + } + for name, b := range s.Backends { + if b.Host == "" { + return fmt.Errorf("backend %q is missing host", name) + } + if !validPort(b.Port) { + return fmt.Errorf("backend %q has invalid port %d", name, b.Port) + } + if (b.Username != nil) != (b.Password != nil) { + return fmt.Errorf("backend %q must set both username and password", name) + } + hasOAuth := b.ClientId != nil && b.ClientSecret != nil && b.TokenURL != nil + if (b.ClientId != nil || b.ClientSecret != nil || b.TokenURL != nil) && !hasOAuth { + return fmt.Errorf("backend %q must set clientId, clientSecret and tokenUrl together", name) + } + } + hasFile := s.DBSettings.Filename != "" + hasConn := s.DBSettings.Connstr != "" + if hasFile == hasConn { + return errors.New("exactly one of dbSettings.filename or dbSettings.postgresConnstr must be set") + } + return nil +} +``` + +- [ ] **Step 4: Call `Validate` in `main`** + +In `main.go`, after the `json.Unmarshal` block that fills `settingsData` (the `if err = json.Unmarshal(...)` block ending at the closing brace), and before `StartServer(settingsData, sugar)`, insert: +```go + if err = settingsData.Validate(); err != nil { + sugar.Fatalf("Invalid settings: %v", err) + } + +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./models/ -v` +Expected: all `TestValidate*` PASS. + +- [ ] **Step 6: Commit** + +```bash +git add models/Settings.go models/settings_test.go main.go +git commit -m "feat: add managementPort setting and startup config validation + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Refactor `proxyHandler.go` — testable routing, guarded statics, metrics + +**Files:** +- Modify: `proxyHandler.go` +- Create: `proxyHandler_test.go` + +- [ ] **Step 1: Rewrite `proxyHandler.go`** + +Replace the entire contents of `proxyHandler.go`: +```go +package main + +import ( + "context" + "database/sql" + "errors" + "math/rand/v2" + "net/http" + "net/http/httputil" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/metrics" + "github.com/ether/etherpad-proxy/models" + "github.com/ether/etherpad-proxy/ui" + "go.uber.org/zap" +) + +type StaticResource struct { + Backend string + FullPath string +} + +// staticResources is a concurrency-safe map of scraped static resource names. +type staticResources struct { + mu sync.RWMutex + m map[string]StaticResource +} + +func newStaticResources() *staticResources { + return &staticResources{m: make(map[string]StaticResource)} +} + +func (s *staticResources) set(name string, r StaticResource) { + s.mu.Lock() + defer s.mu.Unlock() + s.m[name] = r +} + +func (s *staticResources) get(name string) (StaticResource, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, ok := s.m[name] + return r, ok +} + +func (s *staticResources) anyPath() (string, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.m { + return r.FullPath, true + } + return "", false +} + +type ProxyHandler struct { + p map[string]httputil.ReverseProxy + logger *zap.SugaredLogger + db interfaces.IDB + state *models.BackendState + static *staticResources +} + +type ResourceNotFound struct { + newPath string +} + +func (m *ResourceNotFound) Error() string { return "Resource not found" } + +type ClashInPadId struct { + padId string +} + +func (m *ClashInPadId) Error() string { return "Pad clash" } + +func ScrapeJSFiles(settings models.Settings, static *staticResources, logger *zap.SugaredLogger) { + go func() { + for { + for key, backend := range settings.Backends { + response, err := http.Get("http://" + backend.Host + ":" + strconv.Itoa(backend.Port) + "/p/test") + if err != nil { + logger.Warnf("Error while scraping JS files: %v", err) + continue + } + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + logger.Warnf("Error parsing scraped document: %v", err) + _ = response.Body.Close() + continue + } + doc.Find("script").Each(func(_ int, s *goquery.Selection) { + src, ok := s.Attr("src") + if ok && strings.Contains(src, "padbootstrap") { + parts := strings.Split(src, "/") + name := parts[len(parts)-1] + static.set(name, StaticResource{ + Backend: key, + FullPath: "http://" + backend.Host + ":" + strconv.Itoa(backend.Port) + "/" + name, + }) + } + }) + if err = response.Body.Close(); err != nil { + logger.Warnf("Error while closing response body: %v", err) + } + } + time.Sleep(10 * time.Minute) + } + }() +} + +// chooseBackend returns the backend key a request should be routed to, or an +// error (ResourceNotFound carries a redirect path; ClashInPadId signals an +// unresolved pad clash). +func (ph *ProxyHandler) chooseBackend(padId *string, r *http.Request) (string, error) { + available := ph.state.SnapshotAvailable() + up := ph.state.SnapshotUp() + + if padId == nil { + if len(available) == 0 { + return "", errors.New("no backends available") + } + if strings.Contains(r.URL.Path, "padbootstrap") { + parts := strings.Split(r.URL.Path, "/") + name := parts[len(parts)-1] + if res, ok := ph.static.get(name); ok && slices.Contains(up, res.Backend) { + return res.Backend, nil + } + if path, ok := ph.static.anyPath(); ok { + return "", &ResourceNotFound{newPath: path} + } + return "", &ResourceNotFound{} + } + return available[rand.IntN(len(available))], nil + } + + if len(available) == 0 { + return "", errors.New("no backends available") + } + + stored, err := ph.db.Get(*padId) + if errors.Is(err, sql.ErrNoRows) { + clashes, cerr := ph.db.GetClashByPadID(*padId) + if cerr != nil && !errors.Is(cerr, sql.ErrNoRows) { + metrics.DBErrorsTotal.Inc() + return "", cerr + } + if len(clashes) == 0 { + candidate := available[rand.IntN(len(available))] + backend, aerr := ph.db.Assign(*padId, candidate) + if aerr != nil { + metrics.DBErrorsTotal.Inc() + return "", aerr + } + metrics.PadAssignmentsTotal.Inc() + return backend, nil + } + ph.logger.Warnf("Pad %s is in a clash with backends: %v", *padId, clashes) + return "", &ClashInPadId{padId: *padId} + } else if err != nil { + metrics.DBErrorsTotal.Inc() + return "", err + } + + if slices.Contains(up, stored.Backend) { + return stored.Backend, nil + } + if len(up) == 0 { + return "", errors.New("no backends available") + } + newBackend := up[rand.IntN(len(up))] + if serr := ph.db.Set(*padId, models.DBBackend{Backend: newBackend}); serr != nil { + metrics.DBErrorsTotal.Inc() + ph.logger.Info("Error while setting padId in DB: ", serr) + } + return newBackend, nil +} + +func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ph.logger.Debugf("%s %s", r.Method, r.URL) + var padId *string + + if strings.Contains(r.URL.Path, "/p/") { + afterP := strings.Split(r.URL.Path, "/p/")[1] + beforeQuery := strings.Split(afterP, "?")[0] + first := strings.Split(beforeQuery, "/")[0] + padId = &first + ph.logger.Infof("Initial request to /p/%s", first) + } + + if padId == nil { + if q := r.URL.Query().Get("padId"); q != "" { + padId = &q + } + } + + backendKey, err := ph.chooseBackend(padId, r) + if err != nil { + var resourceNotFound *ResourceNotFound + if errors.As(err, &resourceNotFound) && resourceNotFound.newPath != "" { + metrics.RequestsTotal.WithLabelValues("resource_redirect").Inc() + http.Redirect(w, r, resourceNotFound.newPath, http.StatusTemporaryRedirect) + return + } + var clash *ClashInPadId + if errors.As(err, &clash) { + metrics.RequestsTotal.WithLabelValues("clash").Inc() + } else { + metrics.RequestsTotal.WithLabelValues("no_backend").Inc() + } + ph.logger.Error("Error while creating route: ", err) + w.WriteHeader(http.StatusInternalServerError) + template := ui.Error() + if rerr := template.Render(context.Background(), w); rerr != nil { + ph.logger.Error("Error while rendering template: ", rerr) + } + return + } + + proxy := ph.p[backendKey] + metrics.RequestsTotal.WithLabelValues("proxied").Inc() + proxy.ServeHTTP(w, r) +} +``` + +- [ ] **Step 2: Write the routing tests** + +Create `proxyHandler_test.go`: +```go +package main + +import ( + "database/sql" + "errors" + "net/http" + "net/http/httputil" + "testing" + + "github.com/ether/etherpad-proxy/models" + "go.uber.org/zap" +) + +type fakeDB struct { + pads map[string]string + clashes map[string][]string + assignCalls int +} + +func newFakeDB() *fakeDB { + return &fakeDB{pads: map[string]string{}, clashes: map[string][]string{}} +} + +func (f *fakeDB) Close() error { return nil } +func (f *fakeDB) Get(id string) (*models.DBBackend, error) { + if b, ok := f.pads[id]; ok { + return &models.DBBackend{Backend: b}, nil + } + return nil, sql.ErrNoRows +} +func (f *fakeDB) CleanUpPads(_ []string, _ string) error { return nil } +func (f *fakeDB) RecordClash(id string, data string) error { + f.clashes[id] = append(f.clashes[id], data) + return nil +} +func (f *fakeDB) Set(id string, backend models.DBBackend) error { + f.pads[id] = backend.Backend + return nil +} +func (f *fakeDB) GetAllPads() (map[string]string, error) { return f.pads, nil } +func (f *fakeDB) GetClashByPadID(id string) ([]string, error) { + return f.clashes[id], nil +} +func (f *fakeDB) Assign(id string, candidate string) (string, error) { + f.assignCalls++ + if b, ok := f.pads[id]; ok { + return b, nil + } + f.pads[id] = candidate + return candidate, nil +} + +func newTestHandler(db *fakeDB, available, up []string) *ProxyHandler { + state := &models.BackendState{} + state.SetState(available, up) + return &ProxyHandler{ + p: map[string]httputil.ReverseProxy{}, + logger: zap.NewNop().Sugar(), + db: db, + state: state, + static: newStaticResources(), + } +} + +func mustReq(path string) *http.Request { + r, _ := http.NewRequest("GET", path, nil) + return r +} + +func TestChooseBackendNoBackends(t *testing.T) { + h := newTestHandler(newFakeDB(), nil, nil) + pad := "pad1" + if _, err := h.chooseBackend(&pad, mustReq("/p/pad1")); err == nil { + t.Fatal("expected error when no backends available") + } +} + +func TestChooseBackendAssignsNewPad(t *testing.T) { + db := newFakeDB() + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b1" { + t.Fatalf("expected b1, got %s", got) + } + if db.assignCalls != 1 { + t.Fatalf("expected Assign called once, got %d", db.assignCalls) + } +} + +func TestChooseBackendUsesStoredWhenUp(t *testing.T) { + db := newFakeDB() + db.pads["pad1"] = "b2" + h := newTestHandler(db, []string{"b1"}, []string{"b1", "b2"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b2" { + t.Fatalf("expected stored backend b2, got %s", got) + } +} + +func TestChooseBackendReassignsWhenStoredDown(t *testing.T) { + db := newFakeDB() + db.pads["pad1"] = "down" + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b1" { + t.Fatalf("expected reassign to b1, got %s", got) + } + if db.pads["pad1"] != "b1" { + t.Fatalf("expected DB updated to b1, got %s", db.pads["pad1"]) + } +} + +func TestChooseBackendClash(t *testing.T) { + db := newFakeDB() + db.clashes["pad1"] = []string{"b1", "b2"} + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + _, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + var clash *ClashInPadId + if !errors.As(err, &clash) { + t.Fatalf("expected ClashInPadId, got %v", err) + } +} +``` + +- [ ] **Step 3: Verify package main still does not compile (runtime.go not yet updated)** + +Run: `go build ./...` +Expected: failure referencing `runtime.go` (old `AvailableBackends` global / old `ScrapeJSFiles` signature). This is expected — Task 7 fixes `runtime.go`. Do NOT commit yet. + +- [ ] **Step 4: (Deferred) tests run after Task 7** + +Note: `proxyHandler_test.go` is committed together with the `runtime.go` refactor in Task 7 so the package compiles. Leave changes staged-but-uncommitted, or proceed directly to Task 7 and commit both together. + +--- + +## Task 7: Refactor `runtime.go` — wiring, graceful shutdown, health, metrics endpoint + +**Files:** +- Modify: `runtime.go` + +- [ ] **Step 1: Rewrite `runtime.go`** + +Replace the entire contents of `runtime.go`: +```go +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/ether/etherpad-proxy/databases" + "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/metrics" + "github.com/ether/etherpad-proxy/models" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.uber.org/zap" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +const defaultManagementPort = 8081 + +func checkAvailabilityLoop(settings models.Settings, state *models.BackendState, _ *zap.SugaredLogger) { + timerP := time.Duration(settings.CheckInterval) * time.Millisecond + go func() { + for { + response := checkAvailability(settings) + state.SetState(response.Available, response.Up) + metrics.BackendsAvailable.Set(float64(len(response.Available))) + metrics.BackendsUp.Set(float64(len(response.Up))) + time.Sleep(timerP) + } + }() +} + +func cleanUpEtherpadsLoop(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB, state *models.BackendState) { + timerP := time.Duration(settings.CheckInterval) * time.Second + timerBefore := 5 * time.Second + go func() { + for { + time.Sleep(timerBefore) + cleanUpEtherpads(settings, logger, db, state) + time.Sleep(timerP) + } + }() +} + +func cleanUpEtherpads(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB, state *models.BackendState) { + upBackends := state.SnapshotUp() + mapOfPadsToBackends := make(map[string]string) + for _, backend := range upBackends { + foundBackend := settings.Backends[backend] + var authorizationHeader string + if foundBackend.Username != nil && foundBackend.Password != nil { + authorizationHeader = "Basic " + base64.StdEncoding.EncodeToString([]byte(*foundBackend.Username+":"+*foundBackend.Password)) + } else if foundBackend.ClientId != nil && foundBackend.ClientSecret != nil { + conf := &clientcredentials.Config{ + ClientID: *foundBackend.ClientId, + ClientSecret: *foundBackend.ClientSecret, + Scopes: foundBackend.Scopes, + TokenURL: *foundBackend.TokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + } + token, err := conf.Token(context.Background()) + if err != nil { + logger.Warnf("Error getting token: %v", err) + continue + } + authorizationHeader = "Bearer " + token.AccessToken + } else { + logger.Info("No authentication method found for backend: ", backend) + continue + } + + client := &http.Client{} + req, _ := http.NewRequest("GET", "http://"+foundBackend.Host+":"+strconv.Itoa(foundBackend.Port)+"/api/1.3.0/listAllPads", nil) + req.Header.Set("Authorization", authorizationHeader) + req.Header.Set("Content-Type", "application/json") + res, err := client.Do(req) + if err != nil { + logger.Warnf("Error retrieving etherpads: %v from %s", err, backend) + continue + } + bytes, err := io.ReadAll(res.Body) + if err != nil { + logger.Warnf("Error reading response body: %v", err) + _ = res.Body.Close() + continue + } + var response models.ListAllPadsModel + if err = json.Unmarshal(bytes, &response); err != nil { + logger.Warnf("Error unmarshalling response: %v", err) + _ = res.Body.Close() + continue + } + if response.Code != 0 { + logger.Warnf("Error retrieving etherpads: %v", response.Message) + _ = res.Body.Close() + continue + } + for _, pad := range response.Data.PadIds { + if entry, ok := mapOfPadsToBackends[pad]; ok { + logger.Warnf("Pad %s already exists on backend %s", pad, entry) + if err = db.RecordClash(pad, backend); err != nil { + metrics.DBErrorsTotal.Inc() + logger.Warnf("Error recording clash: %v", err) + } else { + metrics.ClashesTotal.Inc() + } + continue + } + mapOfPadsToBackends[pad] = backend + } + if err = res.Body.Close(); err != nil { + logger.Warnf("Error closing response body: %v", err) + } + } + + backendToPads := make(map[string][]string) + for pad, backend := range mapOfPadsToBackends { + backendToPads[backend] = append(backendToPads[backend], pad) + } + for backend, pads := range backendToPads { + if err := db.CleanUpPads(pads, backend); err != nil { + metrics.DBErrorsTotal.Inc() + logger.Warnf("Error cleaning up etherpads: %v", err) + } + } + for pad, backend := range mapOfPadsToBackends { + if err := db.Set(pad, models.DBBackend{Backend: backend}); err != nil { + metrics.DBErrorsTotal.Inc() + logger.Warnf("Error setting etherpad: %v", err) + } + } +} + +func StartServer(settings models.Settings, logger *zap.SugaredLogger) { + db, err := databases.CreateNewDatabase(settings) + if err != nil { + logger.Fatalf("Error opening database: %v", err) + } + + state := &models.BackendState{} + static := newStaticResources() + + checkAvailabilityLoop(settings, state, logger) + cleanUpEtherpadsLoop(settings, logger, db, state) + ScrapeJSFiles(settings, static, logger) + + proxies := make(map[string]httputil.ReverseProxy) + for key, backend := range settings.Backends { + proxyURL, perr := url.Parse("http://" + backend.Host + ":" + strconv.Itoa(backend.Port)) + if perr != nil { + logger.Fatalf("Error parsing backend URL for %s: %v", key, perr) + } + proxies[key] = *httputil.NewSingleHostReverseProxy(proxyURL) + } + + handler := &ProxyHandler{ + p: proxies, + logger: logger, + db: db, + state: state, + static: static, + } + + proxyMux := http.NewServeMux() + proxyMux.HandleFunc("/", handler.ServeHTTP) + proxySrv := &http.Server{Addr: ":" + strconv.Itoa(settings.Port), Handler: proxyMux} + + managementPort := settings.ManagementPort + if managementPort == 0 { + managementPort = defaultManagementPort + } + adminMux := http.NewServeMux() + adminMux.Handle("/pads", &AdminPanel{DB: db, logger: logger}) + adminMux.Handle("/metrics", promhttp.Handler()) + adminMux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + adminMux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + if len(state.SnapshotUp()) > 0 { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + }) + mgmtSrv := &http.Server{Addr: ":" + strconv.Itoa(managementPort), Handler: adminMux} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + logger.Info("Starting management server on port ", managementPort) + if serr := mgmtSrv.ListenAndServe(); serr != nil && !errors.Is(serr, http.ErrServerClosed) { + logger.Fatalf("Error starting management server: %v", serr) + } + }() + go func() { + logger.Info("Starting server on port ", settings.Port) + if serr := proxySrv.ListenAndServe(); serr != nil && !errors.Is(serr, http.ErrServerClosed) { + logger.Fatalf("Error starting server: %v", serr) + } + }() + + <-ctx.Done() + logger.Info("Shutdown signal received, draining...") + stop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if serr := proxySrv.Shutdown(shutdownCtx); serr != nil { + logger.Warnf("Proxy server shutdown error: %v", serr) + } + if serr := mgmtSrv.Shutdown(shutdownCtx); serr != nil { + logger.Warnf("Management server shutdown error: %v", serr) + } + if cerr := db.Close(); cerr != nil { + logger.Warnf("Database close error: %v", cerr) + } + logger.Info("Shutdown complete") +} +``` + +- [ ] **Step 2: Build the whole module** + +Run: `go build ./...` +Expected: success (no output). + +- [ ] **Step 3: Run the full test suite with the race detector** + +Run: `go test ./... -race` +Expected: all PASS (Postgres tests SKIP without `PG_TEST_DSN`), no race warnings. + +- [ ] **Step 4: Vet** + +Run: `go vet ./...` +Expected: no output. + +- [ ] **Step 5: Commit (includes Task 6 changes)** + +```bash +git add proxyHandler.go proxyHandler_test.go runtime.go +git commit -m "feat: race-free routing, graceful shutdown, health/metrics endpoints + +- guard static resource map and use BackendState snapshots (no data races) +- extract testable chooseBackend; use atomic Assign for new pads +- http.Server with signal-driven graceful shutdown +- /healthz, /readyz and /metrics on the management port +- structured zap logging; record metrics on routing and DB outcomes + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: `checkAvailability` test + +**Files:** +- Create: `checkAvailability_test.go` + +- [ ] **Step 1: Write the test using `httptest` backends** + +Create `checkAvailability_test.go`: +```go +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strconv" + "testing" + + "github.com/ether/etherpad-proxy/models" +) + +// newStatsBackend starts a test server replying to /stats with the given +// activePads count, and returns its host and port. +func newStatsBackend(t *testing.T, activePads int) (string, int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/stats" { + fmt.Fprintf(w, `{"activePads": %d}`, activePads) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + return u.Hostname(), port +} + +func TestCheckAvailabilityCapacityAndDown(t *testing.T) { + okHost, okPort := newStatsBackend(t, 1) // under capacity -> up + available + fullHost, fullPort := newStatsBackend(t, 99) // over capacity -> up but not available + + settings := models.Settings{ + MaxPadsPerInstance: 5, + Backends: map[string]models.Backend{ + "ok": {Host: okHost, Port: okPort}, + "full": {Host: fullHost, Port: fullPort}, + "down": {Host: "127.0.0.1", Port: 1}, // nothing listening -> not up + }, + } + + got := checkAvailability(settings) + + if !slices.Contains(got.Up, "ok") || !slices.Contains(got.Up, "full") { + t.Fatalf("expected ok and full to be up, got %v", got.Up) + } + if slices.Contains(got.Up, "down") { + t.Fatalf("down backend should not be up, got %v", got.Up) + } + if !slices.Contains(got.Available, "ok") { + t.Fatalf("ok should be available, got %v", got.Available) + } + if slices.Contains(got.Available, "full") { + t.Fatalf("full backend should not be available, got %v", got.Available) + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./ -run TestCheckAvailability -v` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add checkAvailability_test.go +git commit -m "test: cover backend availability checks + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: CI test workflow + +**Files:** +- Create: `.github/workflows/test.yml` + +- [ ] **Step 1: Create the workflow** + +Create `.github/workflows/test.yml`: +```yaml +name: Test +on: + push: + branches: [main] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: proxy + POSTGRES_PASSWORD: proxy + POSTGRES_DB: etherpad_proxy + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U proxy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Vet + run: go vet ./... + - name: Test (race) + env: + PG_TEST_DSN: postgres://proxy:proxy@localhost:5432/etherpad_proxy?sslmode=disable + run: go test -race ./... +``` + +- [ ] **Step 2: Validate YAML locally** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml')); print('OK')"` +Expected: `OK` (YAML parses). If Python/PyYAML is unavailable, visually confirm indentation matches the block above. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "ci: run go vet and race tests with a Postgres service + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 10: Installation-without-Docker docs + systemd unit (#52) + +**Files:** +- Create: `support/etherpad-proxy.service` +- Modify: `README.md` + +- [ ] **Step 1: Create the systemd unit** + +Create `support/etherpad-proxy.service`: +```ini +[Unit] +Description=Etherpad sharding reverse proxy +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=etherpad-proxy +Group=etherpad-proxy +WorkingDirectory=/opt/etherpad-proxy +Environment=SETTINGS_FILE=/opt/etherpad-proxy/settings.json +ExecStart=/opt/etherpad-proxy/etherpad-proxy +Restart=on-failure +RestartSec=5 +# Hardening +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 2: Add the docs section to README** + +In `README.md`, insert the following block immediately after the `## Getting started for production` section (i.e. before `## Settings`): + +````markdown +## Installation without Docker + +The proxy is a single static Go binary. You can run it directly on a host +without Docker. + +### Option A: download a prebuilt binary + +1. Go to the [Releases page](https://github.com/ether/etherpad-proxy/releases) + and download the archive for your OS/architecture. +2. Extract it: `tar xzf etherpad-proxy_*.tar.gz` (or unzip on Windows). +3. Continue with **Configure** below. + +### Option B: build from source + +Requires Go 1.24 or newer. + +```bash +git clone https://github.com/ether/etherpad-proxy.git +cd etherpad-proxy +go build -o etherpad-proxy . +``` + +### Configure + +1. Copy the template and edit it: + ```bash + cp settings.json.template settings.json + ``` +2. Set `port` (proxy listen port), optionally `managementPort` + (default `8081`, serves `/pads`, `/metrics`, `/healthz`, `/readyz`), your + `backends`, and a database (see **Database** below). +3. The settings path defaults to `./settings.json`; override it with the + `SETTINGS_FILE` environment variable. + +### Run + +```bash +SETTINGS_FILE=/path/to/settings.json ./etherpad-proxy +``` + +The proxy listens on `port`; management/metrics/health endpoints listen on +`managementPort`. + +### Database + +- **SQLite (default, single instance):** set `dbSettings.filename`, e.g. + `"db/etherpad-proxy.db"`. No external services required. Create the directory + first: `mkdir -p db`. +- **Postgres (recommended for multiple proxy instances):** set + `dbSettings.postgresConnstr`. With a shared Postgres, several proxy instances + share routing state and assign new pads atomically, so they never route the + same pad to different backends. + + Provision a database and user, for example: + ```sql + CREATE USER proxy WITH PASSWORD 'changeme'; + CREATE DATABASE etherpad_proxy OWNER proxy; + ``` + Then set: + ```json + "dbSettings": { + "postgresConnstr": "postgres://proxy:changeme@db-host:5432/etherpad_proxy?sslmode=disable" + } + ``` + The proxy creates its tables automatically on first start. Set exactly one of + `filename` or `postgresConnstr`. + +### Run as a systemd service (Linux) + +A sample unit is provided at `support/etherpad-proxy.service`. + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin etherpad-proxy +sudo mkdir -p /opt/etherpad-proxy/db +sudo cp etherpad-proxy /opt/etherpad-proxy/ +sudo cp settings.json /opt/etherpad-proxy/ +sudo cp support/etherpad-proxy.service /etc/systemd/system/ +sudo chown -R etherpad-proxy:etherpad-proxy /opt/etherpad-proxy +sudo systemctl daemon-reload +sudo systemctl enable --now etherpad-proxy +``` + +Check status and logs: +```bash +systemctl status etherpad-proxy +journalctl -u etherpad-proxy -f +``` +```` + +- [ ] **Step 3: Verify the README renders sensibly** + +Run: `git diff --stat README.md` +Expected: README.md modified. Visually confirm the new section sits between +"Getting started for production" and "Settings". + +- [ ] **Step 4: Commit** + +```bash +git add README.md support/etherpad-proxy.service +git commit -m "docs: add installation without Docker, Postgres setup, systemd unit (#52) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 11: GoReleaser config + release workflow + +**Files:** +- Create: `.goreleaser.yaml` +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create the GoReleaser config** + +Create `.goreleaser.yaml`: +```yaml +version: 2 +project_name: etherpad-proxy +builds: + - id: etherpad-proxy + main: . + binary: etherpad-proxy + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 +archives: + - id: default + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - README.md + - LICENSE.md + - settings.json.template + - support/etherpad-proxy.service +checksum: + name_template: 'checksums.txt' +release: + github: + owner: ether + name: etherpad-proxy +``` + +- [ ] **Step 2: Validate the config** + +Run: `go run github.com/goreleaser/goreleaser/v2@latest check` +Expected: `command finished successfully` / no config errors. (If network-restricted, skip; the release workflow validates on CI.) + +- [ ] **Step 3: Create the release workflow** + +Create `.github/workflows/release.yml`: +```yaml +name: Release +on: + push: + tags: + - 'v*' +permissions: + contents: write +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +- [ ] **Step 4: Commit** + +```bash +git add .goreleaser.yaml .github/workflows/release.yml +git commit -m "ci: add GoReleaser config and tag-triggered release workflow + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification + +- [ ] **Step 1: Full build, vet, race tests** + +Run: +```bash +go build ./... +go vet ./... +go test -race ./... +``` +Expected: build clean; vet silent; all tests PASS (Postgres SKIP without DSN), no race warnings. + +- [ ] **Step 2: Smoke-test the running binary** + +Run (PowerShell): +```powershell +go build -o etherpad-proxy.exe . +# With a settings.json pointing at a SQLite file and at least one backend: +$env:SETTINGS_FILE="settings.json"; ./etherpad-proxy.exe +``` +In another shell, confirm management endpoints: +```bash +curl -i http://localhost:8081/healthz # 200 +curl -i http://localhost:8081/readyz # 503 until a backend is up, then 200 +curl -s http://localhost:8081/metrics | grep etherpad_proxy # metric lines present +``` +Then send SIGINT (Ctrl+C) and confirm the logs show "Shutdown complete". + +- [ ] **Step 3: Confirm `lib/pq` is gone** + +Run: `grep -r "lib/pq" go.mod go.sum` +Expected: no matches in `go.mod` (may remain transitively in `go.sum` only if another dep needs it; the direct require must be gone). diff --git a/docs/superpowers/specs/2026-05-28-production-ready-proxy-design.md b/docs/superpowers/specs/2026-05-28-production-ready-proxy-design.md new file mode 100644 index 0000000..2a63c06 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-production-ready-proxy-design.md @@ -0,0 +1,218 @@ +# Production-Ready Etherpad Proxy — Design + +Date: 2026-05-28 +Issues: [#54 — does the etherpad-proxy itself scale?](https://github.com/ether/etherpad-proxy/issues/54), [#52 — Installation without docker](https://github.com/ether/etherpad-proxy/issues/52) + +## Goal + +Make etherpad-proxy a reverse proxy you can rely on in production: a fleet of +proxy instances can share routing state correctly through a shared database, +the process is observable and operable, the code is free of data races, it is +covered by tests, and it can be installed and run without Docker. + +## Background: current state + +The proxy shards pads across a pool of Etherpad backends. It maps `padId → +backend` in a database so the same pad always lands on the same backend. A +background loop health-checks backends (`/stats`) to know which are *up* and +which still have capacity (*available*), and another loop reconciles the DB +against each backend's real pad list (`listAllPads`), recording *clashes* when +the same pad exists on more than one backend. + +PR #57 added a Postgres backend to the DB layer with the intent of letting +multiple proxy instances share state (issue #54). However, that support is +**non-functional** and the routing logic has correctness and concurrency +defects that block reliable multi-instance operation. + +## Problems this design fixes + +### Scaling correctness (#54) + +1. **Postgres driver is never registered.** `databases/postgres` calls + `sql.Open("postgres", …)` but nothing blank-imports a driver, so it fails at + runtime with `sql: unknown driver "postgres"`. +2. **SQLite-only SQL is sent to Postgres.** `RecordClash` and `Set` use + `INSERT OR REPLACE … VALUES (?, ?)`; the squirrel queries use `?` + placeholders. Postgres requires `INSERT … ON CONFLICT …` and `$N` + placeholders. Writes would fail even with a driver loaded. +3. **Pad assignment races across proxies (split-brain).** When a pad has no + stored backend, each proxy independently picks a *random* backend and writes + it (last-write-wins). Two proxies handling the same new pad route it to + **different** backends — producing the exact clash the system is meant to + avoid. + +### Concurrency (data races) + +4. `AvailableBackends.Available` / `.Up` are read **without holding the mutex** + in several places in `proxyHandler.go` (e.g. the `len(...)` guard and the + `slices.Index` lookup), while a background goroutine reassigns them under the + lock. This is a data race. +5. `StaticResourceMap` (package-level `map`) is written by the `ScrapeJSFiles` + goroutine and read in `createRoute` with no synchronization — a data race. +6. `cleanUpEtherpads` holds `AvailableBackends.Mutex` across slow outbound HTTP + calls, serializing the health loop against request routing for the duration + of network I/O. + +### Operability + +7. No graceful shutdown — the process can't drain in-flight requests or close + the DB cleanly. +8. No health/readiness endpoint for load balancers and orchestrators. +9. No startup config validation — misconfiguration surfaces as obscure runtime + failures. +10. Ad-hoc `log.Println` / debug prints instead of the structured zap logger. +11. Management port is a hardcoded constant (`8081`). +12. No metrics. + +### Quality + +13. Zero tests; CI only builds a Docker image. + +### Installation (#52) + +14. No documented way to install/run without Docker; no prebuilt binaries. + +## Design + +### 1. Database layer — drivers, dialects, atomic assignment + +- **Driver:** replace `github.com/lib/pq` with `github.com/jackc/pgx/v5` used + through its `database/sql` adapter. Blank-import + `_ "github.com/jackc/pgx/v5/stdlib"` in the postgres package; open with driver + name `"pgx"`. Remove `lib/pq` from `go.mod`. The existing `database/sql` + + squirrel code is otherwise unchanged. +- **Dialect-correct SQL:** each implementation builds squirrel statements with + the right placeholder format — `sq.Dollar` for Postgres, `sq.Question` for + SQLite — and uses upserts instead of `INSERT OR REPLACE`: + - pad: `INSERT INTO pad (id, backend) VALUES (…) ON CONFLICT (id) DO UPDATE SET backend = EXCLUDED.backend` + - clashes: `INSERT INTO clashes (id, data) VALUES (…) ON CONFLICT (id, data) DO NOTHING` + SQLite (modernc) supports `ON CONFLICT` / `EXCLUDED`, so both backends use the + same statement shape, differing only in placeholder format. +- **Atomic assignment (approach A).** Add to `IDB`: + + ```go + // Assign stores `candidate` as the backend for `padId` only if no backend is + // already stored, and returns the backend that is now authoritative (the + // existing one if there was a race, otherwise `candidate`). + Assign(padId string, candidate string) (string, error) + ``` + + Implemented as `INSERT … ON CONFLICT (id) DO NOTHING` followed by a + `SELECT backend FROM pad WHERE id = …`. The first proxy to win the insert + decides the backend; concurrent proxies read the winner. This removes the + split-brain window and works identically on SQLite and Postgres. + +- `createRoute` uses `Assign` wherever it currently does "pick random backend + + `Set`" for an unassigned pad. The "stored backend is no longer up → reassign" + path keeps using `Set` (an intentional overwrite). + +The `IDB` interface gains `Assign`; both implementations and the fake used in +tests implement it. `databases.CreateNewDatabase` selection logic is unchanged. + +### 2. Concurrency-safe shared state + +- Introduce a `BackendState` type (in `models`) wrapping `up`/`available` + string slices behind a `sync.RWMutex`, exposing: + - `SetState(available, up []string)` + - `SnapshotAvailable() []string` / `SnapshotUp() []string` (return copies) + - small helpers like `AvailableCount()` / `IsUp(backend string) bool` + Replace the bare `AvailableBackends` global and all manual `Mutex.Lock()` + call sites. Routing takes one consistent snapshot per request. +- Wrap `StaticResourceMap` in a `sync.RWMutex`-guarded type (or `sync.Map`) with + `Get`/`Set`/`Snapshot`. Scraper writes, router reads — both synchronized. +- `cleanUpEtherpads` snapshots the up-list under the lock, releases it, then + performs HTTP I/O. +- The whole suite runs under `go test -race` in CI. + +### 3. Operability + +- **Graceful shutdown:** build explicit `http.Server` values for the proxy and + management listeners. `main` derives a context via + `signal.NotifyContext(ctx, SIGINT, SIGTERM)`; on signal, call + `Shutdown(timeoutCtx)` on both servers and `db.Close()`. +- **Health endpoints** on the management server (the proxy port is a `/` + catch-all and can't host them): + - `GET /healthz` → 200 always (process is alive). + - `GET /readyz` → 200 if ≥1 backend is up, else 503. +- **Config validation** in a `Settings.Validate() error`, called in `main` + before `StartServer`: port in range; ≥1 backend; each backend has host and + valid port; exactly one of `filename` / `postgresConnstr` set; per-backend + auth coherent (basic = username+password, oauth = clientId+clientSecret+ + tokenURL, or none). Fail fast with a clear message. +- **Structured logging:** route request-path and scraper logging through the + existing zap `SugaredLogger`; remove `log.Println` and the `doc.Find` debug + prints. +- **Configurable management port:** add `managementPort` to `Settings` + (default `8081` when zero). + +### 4. Metrics (Prometheus) + +- Add `github.com/prometheus/client_golang`. Register a `promhttp` handler at + `GET /metrics` on the management server. +- Metrics: + - `etherpad_proxy_requests_total{outcome}` — counter (outcome ∈ + proxied / no_backend / clash / resource_redirect / error). + - `etherpad_proxy_backends_up` / `etherpad_proxy_backends_available` — gauges, + set by the availability loop. + - `etherpad_proxy_pad_assignments_total` — counter, incremented on a new + assignment. + - `etherpad_proxy_clashes_total` — counter, incremented when a clash is + recorded. + - `etherpad_proxy_db_errors_total` — counter, incremented on DB errors. +- Metrics live in a small `metrics` package (or `metrics.go`) so handlers and + loops can update them without import cycles. + +### 5. Tests + CI + +- **Unit tests:** + - DB layer against in-memory SQLite (`file::memory:?cache=shared`): + `Get`/`Set`/`Assign` (including the conflict path)/`CleanUpPads`/clashes. + - `createRoute` routing decisions against a fake `IDB` and a seeded + `BackendState`: unassigned pad, assigned-and-up, assigned-but-down + (reassign), clash, no-backends-available, static-resource paths. + - `checkAvailability` against `httptest` backends returning crafted `/stats`. + - `Settings.Validate` table tests. +- **Postgres integration test** gated on `PG_TEST_DSN`; `t.Skip` when unset so + local runs don't require Postgres. Exercises the same DB contract as SQLite. +- **CI:** new `.github/workflows/test.yml` running `go vet ./...` and + `go test -race ./...` on a matrix; provides a `postgres` service container and + sets `PG_TEST_DSN` for the integration test. Existing `build.yml` (Docker + image) is unchanged. + +### 6. Installation without Docker (#52) + releases + +- **README "Installation without Docker" section** covering: + - Prerequisites: Go 1.24+ (for building) or a downloaded release binary. + - Build from source: `go build -o etherpad-proxy .` + - Configuration: copy `settings.json.template` → `settings.json`; the + `SETTINGS_FILE` env var overrides the path. + - Running: `./etherpad-proxy`; proxy on `port`, management/metrics/health on + `managementPort`. + - Database choice: SQLite default (zero setup) vs Postgres for multi-instance + deployments, including provisioning steps and the `postgresConnstr` format. +- **systemd:** add `support/etherpad-proxy.service` (a sample unit running a + dedicated user, `WorkingDirectory`, `SETTINGS_FILE`, restart policy) with + install instructions in the README. +- **Prebuilt binaries:** add `.goreleaser.yaml` building + linux/darwin/windows × amd64/arm64 archives, and + `.github/workflows/release.yml` triggered on `v*` tags that runs GoReleaser + and publishes to GitHub Releases (`contents: write`). README documents + downloading and running a release binary. + +## Out of scope (YAGNI) + +- Rate limiting, TLS termination (handled by an upstream LB / ingress), and + authentication on the admin panel. +- Distributed coordination beyond the shared DB (no leader election, no gossip) + — the shared DB + atomic assignment is sufficient for correctness. + +## Risks / notes + +- `ON CONFLICT` requires the `pad.id` and `clashes.(id,data)` primary keys that + already exist in both schemas — verified in the current `CREATE TABLE` + statements. +- Switching the Postgres DSN scheme: pgx's stdlib accepts the same + `postgres://…` URLs as lib/pq, so existing `postgresConnstr` values keep + working; documented regardless. +- The availability loop currently swaps whole slices; `BackendState` preserves + that semantics (atomic replace), only adding safe reads. diff --git a/go.mod b/go.mod index 89138aa..7cf8a96 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,44 @@ module github.com/ether/etherpad-proxy -go 1.24.0 +go 1.25.0 require ( github.com/Masterminds/squirrel v1.5.4 github.com/PuerkitoBio/goquery v1.10.2 github.com/a-h/templ v0.3.857 + github.com/jackc/pgx/v5 v5.9.2 + github.com/prometheus/client_golang v1.23.2 go.uber.org/zap v1.27.0 - golang.org/x/oauth2 v0.29.0 + golang.org/x/oauth2 v0.30.0 modernc.org/sqlite v1.37.0 ) require ( github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + 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 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect modernc.org/libc v1.62.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.9.1 // indirect diff --git a/go.sum b/go.sum index c1a51c3..ece51b7 100644 --- a/go.sum +++ b/go.sum @@ -6,33 +6,64 @@ github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg= github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -40,6 +71,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -53,8 +86,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -64,10 +97,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -75,8 +108,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -89,8 +122,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -109,15 +142,23 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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= modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= diff --git a/main.go b/main.go index 650a9e5..307f5d8 100644 --- a/main.go +++ b/main.go @@ -28,5 +28,9 @@ func main() { sugar.Fatalf("Error reading file: %v", err) } + if err = settingsData.Validate(); err != nil { + sugar.Fatalf("Invalid settings: %v", err) + } + StartServer(settingsData, sugar) } diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..722c552 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,45 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // RequestsTotal counts proxy requests by outcome: + // proxied, no_backend, clash, resource_redirect. + RequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "etherpad_proxy_requests_total", + Help: "Total proxy requests by outcome.", + }, []string{"outcome"}) + + // BackendsUp is the number of backends currently reachable. + BackendsUp = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "etherpad_proxy_backends_up", + Help: "Number of backends currently up.", + }) + + // BackendsAvailable is the number of up backends still under capacity. + BackendsAvailable = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "etherpad_proxy_backends_available", + Help: "Number of backends currently available (up and under capacity).", + }) + + // PadAssignmentsTotal counts new pad-to-backend assignments. + PadAssignmentsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_pad_assignments_total", + Help: "Total number of new pad assignments.", + }) + + // ClashesTotal counts recorded pad clashes. + ClashesTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_clashes_total", + Help: "Total number of recorded pad clashes.", + }) + + // DBErrorsTotal counts database operation errors. + DBErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "etherpad_proxy_db_errors_total", + Help: "Total number of database errors.", + }) +) diff --git a/models/AvailableBackends.go b/models/AvailableBackends.go index 1450f03..44c4330 100644 --- a/models/AvailableBackends.go +++ b/models/AvailableBackends.go @@ -1,9 +1,43 @@ package models -import "sync" +import ( + "slices" + "sync" +) -type AvailableBackends struct { - Up []string - Available []string - Mutex sync.Mutex +// BackendState holds the per-instance view of which backends are up and which +// still have capacity (available). It is safe for concurrent use. +type BackendState struct { + mu sync.RWMutex + up []string + available []string +} + +// SetState atomically replaces both lists with copies of the provided slices. +func (b *BackendState) SetState(available, up []string) { + b.mu.Lock() + defer b.mu.Unlock() + b.available = append([]string(nil), available...) + b.up = append([]string(nil), up...) +} + +// SnapshotAvailable returns a copy of the available list. +func (b *BackendState) SnapshotAvailable() []string { + b.mu.RLock() + defer b.mu.RUnlock() + return append([]string(nil), b.available...) +} + +// SnapshotUp returns a copy of the up list. +func (b *BackendState) SnapshotUp() []string { + b.mu.RLock() + defer b.mu.RUnlock() + return append([]string(nil), b.up...) +} + +// IsUp reports whether backend is currently up. +func (b *BackendState) IsUp(backend string) bool { + b.mu.RLock() + defer b.mu.RUnlock() + return slices.Contains(b.up, backend) } diff --git a/models/Settings.go b/models/Settings.go index e41305a..2428aa4 100644 --- a/models/Settings.go +++ b/models/Settings.go @@ -1,7 +1,13 @@ package models +import ( + "errors" + "fmt" +) + type Settings struct { Port int `json:"port"` + ManagementPort int `json:"managementPort"` Backends map[string]Backend `json:"backends"` MaxPadsPerInstance int `json:"maxPadsPerInstance"` CheckInterval int64 `json:"checkInterval"` @@ -23,3 +29,39 @@ type Backend struct { Username *string `json:"username"` Password *string `json:"password"` } + +func validPort(p int) bool { return p > 0 && p <= 65535 } + +// Validate checks that the settings are internally consistent and usable. +func (s Settings) Validate() error { + if !validPort(s.Port) { + return fmt.Errorf("port must be between 1 and 65535, got %d", s.Port) + } + if s.ManagementPort != 0 && !validPort(s.ManagementPort) { + return fmt.Errorf("managementPort must be between 1 and 65535, got %d", s.ManagementPort) + } + if len(s.Backends) == 0 { + return errors.New("at least one backend must be configured") + } + for name, b := range s.Backends { + if b.Host == "" { + return fmt.Errorf("backend %q is missing host", name) + } + if !validPort(b.Port) { + return fmt.Errorf("backend %q has invalid port %d", name, b.Port) + } + if (b.Username != nil) != (b.Password != nil) { + return fmt.Errorf("backend %q must set both username and password", name) + } + hasOAuth := b.ClientId != nil && b.ClientSecret != nil && b.TokenURL != nil + if (b.ClientId != nil || b.ClientSecret != nil || b.TokenURL != nil) && !hasOAuth { + return fmt.Errorf("backend %q must set clientId, clientSecret and tokenUrl together", name) + } + } + hasFile := s.DBSettings.Filename != "" + hasConn := s.DBSettings.Connstr != "" + if hasFile == hasConn { + return errors.New("exactly one of dbSettings.filename or dbSettings.postgresConnstr must be set") + } + return nil +} diff --git a/models/backendstate_test.go b/models/backendstate_test.go new file mode 100644 index 0000000..b5359c7 --- /dev/null +++ b/models/backendstate_test.go @@ -0,0 +1,38 @@ +package models + +import ( + "sync" + "testing" +) + +func TestBackendStateSnapshotIsolated(t *testing.T) { + var s BackendState + s.SetState([]string{"a", "b"}, []string{"a"}) + avail := s.SnapshotAvailable() + avail[0] = "mutated" + if got := s.SnapshotAvailable(); got[0] != "a" { + t.Fatalf("snapshot should be a copy, got %v", got) + } +} + +func TestBackendStateIsUp(t *testing.T) { + var s BackendState + s.SetState(nil, []string{"a"}) + if !s.IsUp("a") { + t.Fatal("a should be up") + } + if s.IsUp("b") { + t.Fatal("b should not be up") + } +} + +func TestBackendStateConcurrent(t *testing.T) { + var s BackendState + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { defer wg.Done(); s.SetState([]string{"a"}, []string{"a"}) }() + go func() { defer wg.Done(); _ = s.SnapshotUp() }() + } + wg.Wait() +} diff --git a/models/settings_test.go b/models/settings_test.go new file mode 100644 index 0000000..e3a1d3d --- /dev/null +++ b/models/settings_test.go @@ -0,0 +1,75 @@ +package models + +import "testing" + +func strPtr(s string) *string { return &s } + +func validBackend() Backend { + return Backend{Host: "localhost", Port: 9001} +} + +func baseSettings() Settings { + return Settings{ + Port: 9000, + Backends: map[string]Backend{"b1": validBackend()}, + DBSettings: DBSettings{Filename: "db/x.db"}, + } +} + +func TestValidateOK(t *testing.T) { + if err := baseSettings().Validate(); err != nil { + t.Fatalf("expected valid, got %v", err) + } +} + +func TestValidateBadPort(t *testing.T) { + s := baseSettings() + s.Port = 0 + if err := s.Validate(); err == nil { + t.Fatal("expected error for port 0") + } +} + +func TestValidateNoBackends(t *testing.T) { + s := baseSettings() + s.Backends = nil + if err := s.Validate(); err == nil { + t.Fatal("expected error for no backends") + } +} + +func TestValidateBothDBSet(t *testing.T) { + s := baseSettings() + s.DBSettings = DBSettings{Filename: "db/x.db", Connstr: "postgres://x"} + if err := s.Validate(); err == nil { + t.Fatal("expected error when both filename and connstr set") + } +} + +func TestValidateNeitherDBSet(t *testing.T) { + s := baseSettings() + s.DBSettings = DBSettings{} + if err := s.Validate(); err == nil { + t.Fatal("expected error when neither filename nor connstr set") + } +} + +func TestValidatePartialBasicAuth(t *testing.T) { + s := baseSettings() + b := validBackend() + b.Username = strPtr("admin") // password missing + s.Backends = map[string]Backend{"b1": b} + if err := s.Validate(); err == nil { + t.Fatal("expected error for username without password") + } +} + +func TestValidatePartialOAuth(t *testing.T) { + s := baseSettings() + b := validBackend() + b.ClientId = strPtr("id") // secret + tokenURL missing + s.Backends = map[string]Backend{"b1": b} + if err := s.Validate(); err == nil { + t.Fatal("expected error for partial oauth config") + } +} diff --git a/proxyHandler.go b/proxyHandler.go index 6478cea..f194af4 100644 --- a/proxyHandler.go +++ b/proxyHandler.go @@ -4,214 +4,224 @@ import ( "context" "database/sql" "errors" - "log" + "math/rand/v2" "net/http" "net/http/httputil" "slices" "strconv" "strings" + "sync" "time" "github.com/PuerkitoBio/goquery" "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/metrics" "github.com/ether/etherpad-proxy/models" "github.com/ether/etherpad-proxy/ui" "go.uber.org/zap" ) -import "math/rand/v2" -import _ "github.com/ether/etherpad-proxy/ui" - -type ProxyHandler struct { - p map[string]httputil.ReverseProxy - logger *zap.SugaredLogger - db interfaces.IDB -} type StaticResource struct { Backend string FullPath string } -var StaticResourceMap = map[string]StaticResource{} +// staticResources is a concurrency-safe map of scraped static resource names. +type staticResources struct { + mu sync.RWMutex + m map[string]StaticResource +} + +func newStaticResources() *staticResources { + return &staticResources{m: make(map[string]StaticResource)} +} + +func (s *staticResources) set(name string, r StaticResource) { + s.mu.Lock() + defer s.mu.Unlock() + s.m[name] = r +} + +func (s *staticResources) get(name string) (StaticResource, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, ok := s.m[name] + return r, ok +} + +func (s *staticResources) anyPath() (string, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.m { + return r.FullPath, true + } + return "", false +} + +type ProxyHandler struct { + p map[string]httputil.ReverseProxy + logger *zap.SugaredLogger + db interfaces.IDB + state *models.BackendState + static *staticResources +} type ResourceNotFound struct { newPath string } -func (m *ResourceNotFound) Error() string { - return "Resource not found" -} +func (m *ResourceNotFound) Error() string { return "Resource not found" } type ClashInPadId struct { padId string } -func (m *ClashInPadId) Error() string { - return "Resource not found" -} +func (m *ClashInPadId) Error() string { return "Pad clash" } -func ScrapeJSFiles(backends models.Settings) { +func ScrapeJSFiles(settings models.Settings, static *staticResources, logger *zap.SugaredLogger) { go func() { for { - for key, backend := range backends.Backends { + for key, backend := range settings.Backends { response, err := http.Get("http://" + backend.Host + ":" + strconv.Itoa(backend.Port) + "/p/test") if err != nil { - log.Println("Error while scraping JS files: ", err) + logger.Warnf("Error while scraping JS files: %v", err) continue } - doc, err := goquery.NewDocumentFromReader(response.Body) - doc.Find("script").Each(func(i int, s *goquery.Selection) { - log.Println(s.Attr("src")) - var src, ok = s.Attr("src") - if ok { - if strings.Index(src, "padbootstrap") != -1 { - var splittedPath = strings.Split(src, "/") - StaticResourceMap[splittedPath[len(splittedPath)-1]] = StaticResource{ - Backend: key, - FullPath: "http://" + backend.Host + ":" + strconv.Itoa(backend.Port) + "/" + splittedPath[len(splittedPath)-1], - } - } + if err != nil { + logger.Warnf("Error parsing scraped document: %v", err) + _ = response.Body.Close() + continue + } + doc.Find("script").Each(func(_ int, s *goquery.Selection) { + src, ok := s.Attr("src") + if ok && strings.Contains(src, "padbootstrap") { + parts := strings.Split(src, "/") + name := parts[len(parts)-1] + static.set(name, StaticResource{ + Backend: key, + FullPath: "http://" + backend.Host + ":" + strconv.Itoa(backend.Port) + "/" + name, + }) } }) if err = response.Body.Close(); err != nil { - log.Println("Error while closing response body: ", err) - continue + logger.Warnf("Error while closing response body: %v", err) } } time.Sleep(10 * time.Minute) } - }() } -func (ph *ProxyHandler) createRoute(padId *string, r *http.Request) (*httputil.ReverseProxy, error) { +// chooseBackend returns the backend key a request should be routed to, or an +// error (ResourceNotFound carries a redirect path; ClashInPadId signals an +// unresolved pad clash). +func (ph *ProxyHandler) chooseBackend(padId *string, r *http.Request) (string, error) { + available := ph.state.SnapshotAvailable() + up := ph.state.SnapshotUp() + if padId == nil { - var newBackend *string - // It's a static resource - AvailableBackends.Mutex.Lock() - if len(AvailableBackends.Available) == 0 { - return nil, errors.New("no backends available") + if len(available) == 0 { + return "", errors.New("no backends available") } if strings.Contains(r.URL.Path, "padbootstrap") { - // This is a static resource - // We need to find the backend that serves this resource - var splittedPath = strings.Split(r.URL.Path, "/") - var resourceName = splittedPath[len(splittedPath)-1] - if key, okay := StaticResourceMap[resourceName]; okay { - for i := 0; i < len(AvailableBackends.Up); i++ { - if AvailableBackends.Up[i] == key.Backend { - newBackend = &key.Backend - break - } - } - } else { - var firstNewPadRef string - for _, resource := range StaticResourceMap { - firstNewPadRef = resource.FullPath - } - return nil, &ResourceNotFound{ - firstNewPadRef, - } + parts := strings.Split(r.URL.Path, "/") + name := parts[len(parts)-1] + if res, ok := ph.static.get(name); ok && slices.Contains(up, res.Backend) { + return res.Backend, nil } - } else { - newBackend = &AvailableBackends.Available[rand.IntN(len(AvailableBackends.Available))] + if path, ok := ph.static.anyPath(); ok { + return "", &ResourceNotFound{newPath: path} + } + return "", &ResourceNotFound{} } - AvailableBackends.Mutex.Unlock() - var chosenBackend = ph.p[*newBackend] - return &chosenBackend, nil + return available[rand.IntN(len(available))], nil } - var padRead, err = ph.db.Get(*padId) - if len(AvailableBackends.Available) == 0 { - return nil, errors.New("no backends available") + if len(available) == 0 { + return "", errors.New("no backends available") } - if errors.Is(err, sql.ErrNoRows) { - // if no backend is stored for this pad, create a new connection - result, err := ph.db.GetClashByPadID(*padId) - - if err != nil && errors.Is(err, sql.ErrNoRows) || len(result) == 0 { - AvailableBackends.Mutex.Lock() - var newBackend = AvailableBackends.Available[rand.IntN(len(AvailableBackends.Available))] - AvailableBackends.Mutex.Unlock() - - if err = ph.db.Set(*padId, models.DBBackend{ - Backend: newBackend, - }); err != nil { - ph.logger.Info("Error while setting padId in DB: ", err) - } - var chosenBackend = ph.p[newBackend] - return &chosenBackend, nil - } else if err != nil { - return nil, err + stored, err := ph.db.Get(*padId) + if errors.Is(err, sql.ErrNoRows) { + clashes, cerr := ph.db.GetClashByPadID(*padId) + if cerr != nil && !errors.Is(cerr, sql.ErrNoRows) { + metrics.DBErrorsTotal.Inc() + return "", cerr } - - // There is an active clash for this pad - ph.logger.Warnf("Pad %s is in a clash with backends: %v", *padId, result) - return nil, &ClashInPadId{ - padId: *padId, + if len(clashes) == 0 { + candidate := available[rand.IntN(len(available))] + backend, aerr := ph.db.Assign(*padId, candidate) + if aerr != nil { + metrics.DBErrorsTotal.Inc() + return "", aerr + } + metrics.PadAssignmentsTotal.Inc() + return backend, nil } + ph.logger.Warnf("Pad %s is in a clash with backends: %v", *padId, clashes) + return "", &ClashInPadId{padId: *padId} + } else if err != nil { + metrics.DBErrorsTotal.Inc() + return "", err } - if len(AvailableBackends.Available) == 0 { - ph.logger.Info("request made during startup") + if slices.Contains(up, stored.Backend) { + return stored.Backend, nil } - - if slices.Index(AvailableBackends.Up, padRead.Backend) != -1 { - var chosenBackend = ph.p[padRead.Backend] - return &chosenBackend, nil - } else { - AvailableBackends.Mutex.Lock() - newBackend := AvailableBackends.Up[rand.IntN(len(AvailableBackends.Up))] - AvailableBackends.Mutex.Unlock() - if err = ph.db.Set(*padId, models.DBBackend{ - Backend: newBackend, - }); err != nil { - ph.logger.Info("Error while setting padId in DB: ", err) - } - var chosenBackend = ph.p[newBackend] - return &chosenBackend, nil + if len(up) == 0 { + return "", errors.New("no backends available") + } + newBackend := up[rand.IntN(len(up))] + if serr := ph.db.Set(*padId, models.DBBackend{Backend: newBackend}); serr != nil { + metrics.DBErrorsTotal.Inc() + ph.logger.Info("Error while setting padId in DB: ", serr) } + return newBackend, nil } func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.Println(r.URL) + ph.logger.Debugf("%s %s", r.Method, r.URL) var padId *string - if strings.Index(r.URL.Path, "/p/") != -1 { - var separatedString = strings.Split(r.URL.Path, "/p/")[1] - var separatedStringByQuestion = strings.Split(separatedString, "?")[0] - padId = &strings.Split(separatedStringByQuestion, "/")[0] - ph.logger.Info("Initial request to /p/" + *padId) + if strings.Contains(r.URL.Path, "/p/") { + afterP := strings.Split(r.URL.Path, "/p/")[1] + beforeQuery := strings.Split(afterP, "?")[0] + first := strings.Split(beforeQuery, "/")[0] + padId = &first + ph.logger.Infof("Initial request to /p/%s", first) } if padId == nil { - padIdToWrite := r.URL.Query().Get("padId") - if padIdToWrite != "" { - padId = &padIdToWrite + if q := r.URL.Query().Get("padId"); q != "" { + padId = &q } } - var padProxy, err = ph.createRoute(padId, r) - + backendKey, err := ph.chooseBackend(padId, r) if err != nil { - if errors.Is(err, &ResourceNotFound{}) { - var resourceNotFound *ResourceNotFound - errors.As(err, &resourceNotFound) - var newPath = resourceNotFound.newPath - http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) + var resourceNotFound *ResourceNotFound + if errors.As(err, &resourceNotFound) && resourceNotFound.newPath != "" { + metrics.RequestsTotal.WithLabelValues("resource_redirect").Inc() + http.Redirect(w, r, resourceNotFound.newPath, http.StatusTemporaryRedirect) return } - + var clash *ClashInPadId + if errors.As(err, &clash) { + metrics.RequestsTotal.WithLabelValues("clash").Inc() + } else { + metrics.RequestsTotal.WithLabelValues("no_backend").Inc() + } ph.logger.Error("Error while creating route: ", err) w.WriteHeader(http.StatusInternalServerError) template := ui.Error() - if err := template.Render(context.Background(), w); err != nil { - ph.logger.Error("Error while rendering template: ", err) + if rerr := template.Render(context.Background(), w); rerr != nil { + ph.logger.Error("Error while rendering template: ", rerr) } return } - padProxy.ServeHTTP(w, r) + proxy := ph.p[backendKey] + metrics.RequestsTotal.WithLabelValues("proxied").Inc() + proxy.ServeHTTP(w, r) } diff --git a/proxyHandler_test.go b/proxyHandler_test.go new file mode 100644 index 0000000..98315bf --- /dev/null +++ b/proxyHandler_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "database/sql" + "errors" + "net/http" + "net/http/httputil" + "testing" + + "github.com/ether/etherpad-proxy/models" + "go.uber.org/zap" +) + +type fakeDB struct { + pads map[string]string + clashes map[string][]string + assignCalls int +} + +func newFakeDB() *fakeDB { + return &fakeDB{pads: map[string]string{}, clashes: map[string][]string{}} +} + +func (f *fakeDB) Close() error { return nil } +func (f *fakeDB) Get(id string) (*models.DBBackend, error) { + if b, ok := f.pads[id]; ok { + return &models.DBBackend{Backend: b}, nil + } + return nil, sql.ErrNoRows +} +func (f *fakeDB) CleanUpPads(_ []string, _ string) error { return nil } +func (f *fakeDB) RecordClash(id string, data string) error { + f.clashes[id] = append(f.clashes[id], data) + return nil +} +func (f *fakeDB) Set(id string, backend models.DBBackend) error { + f.pads[id] = backend.Backend + return nil +} +func (f *fakeDB) GetAllPads() (map[string]string, error) { return f.pads, nil } +func (f *fakeDB) GetClashByPadID(id string) ([]string, error) { + return f.clashes[id], nil +} +func (f *fakeDB) Assign(id string, candidate string) (string, error) { + f.assignCalls++ + if b, ok := f.pads[id]; ok { + return b, nil + } + f.pads[id] = candidate + return candidate, nil +} + +func newTestHandler(db *fakeDB, available, up []string) *ProxyHandler { + state := &models.BackendState{} + state.SetState(available, up) + return &ProxyHandler{ + p: map[string]httputil.ReverseProxy{}, + logger: zap.NewNop().Sugar(), + db: db, + state: state, + static: newStaticResources(), + } +} + +func mustReq(path string) *http.Request { + r, _ := http.NewRequest("GET", path, nil) + return r +} + +func TestChooseBackendNoBackends(t *testing.T) { + h := newTestHandler(newFakeDB(), nil, nil) + pad := "pad1" + if _, err := h.chooseBackend(&pad, mustReq("/p/pad1")); err == nil { + t.Fatal("expected error when no backends available") + } +} + +func TestChooseBackendAssignsNewPad(t *testing.T) { + db := newFakeDB() + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b1" { + t.Fatalf("expected b1, got %s", got) + } + if db.assignCalls != 1 { + t.Fatalf("expected Assign called once, got %d", db.assignCalls) + } +} + +func TestChooseBackendUsesStoredWhenUp(t *testing.T) { + db := newFakeDB() + db.pads["pad1"] = "b2" + h := newTestHandler(db, []string{"b1"}, []string{"b1", "b2"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b2" { + t.Fatalf("expected stored backend b2, got %s", got) + } +} + +func TestChooseBackendReassignsWhenStoredDown(t *testing.T) { + db := newFakeDB() + db.pads["pad1"] = "down" + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + got, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + if err != nil { + t.Fatalf("chooseBackend: %v", err) + } + if got != "b1" { + t.Fatalf("expected reassign to b1, got %s", got) + } + if db.pads["pad1"] != "b1" { + t.Fatalf("expected DB updated to b1, got %s", db.pads["pad1"]) + } +} + +func TestChooseBackendClash(t *testing.T) { + db := newFakeDB() + db.clashes["pad1"] = []string{"b1", "b2"} + h := newTestHandler(db, []string{"b1"}, []string{"b1"}) + pad := "pad1" + _, err := h.chooseBackend(&pad, mustReq("/p/pad1")) + var clash *ClashInPadId + if !errors.As(err, &clash) { + t.Fatalf("expected ClashInPadId, got %v", err) + } +} diff --git a/runtime.go b/runtime.go index a90ca1e..7914917 100644 --- a/runtime.go +++ b/runtime.go @@ -4,60 +4,58 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "io" "net/http" "net/http/httputil" "net/url" + "os" + "os/signal" "strconv" - "sync" + "syscall" "time" "github.com/ether/etherpad-proxy/databases" "github.com/ether/etherpad-proxy/databases/interfaces" + "github.com/ether/etherpad-proxy/metrics" "github.com/ether/etherpad-proxy/models" + "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" - _ "golang.org/x/oauth2/clientcredentials" ) -var AvailableBackends = models.AvailableBackends{ - Available: []string{}, - Up: []string{}, - Mutex: sync.Mutex{}, -} +const defaultManagementPort = 8081 -func checkAvailabilityLoop(settings models.Settings, _ *zap.SugaredLogger) { - var timerP = time.Duration(settings.CheckInterval) * time.Millisecond +func checkAvailabilityLoop(settings models.Settings, state *models.BackendState, _ *zap.SugaredLogger) { + timerP := time.Duration(settings.CheckInterval) * time.Millisecond go func() { for { - var response = checkAvailability(settings) - AvailableBackends.Mutex.Lock() - AvailableBackends.Available = response.Available - AvailableBackends.Up = response.Up - AvailableBackends.Mutex.Unlock() + response := checkAvailability(settings) + state.SetState(response.Available, response.Up) + metrics.BackendsAvailable.Set(float64(len(response.Available))) + metrics.BackendsUp.Set(float64(len(response.Up))) time.Sleep(timerP) } }() } -func cleanUpEtherpadsLoop(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB) { - var timerP = time.Duration(settings.CheckInterval) * time.Second - var timerBefore = time.Duration(5) * time.Second +func cleanUpEtherpadsLoop(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB, state *models.BackendState) { + timerP := time.Duration(settings.CheckInterval) * time.Second + timerBefore := 5 * time.Second go func() { for { time.Sleep(timerBefore) - cleanUpEtherpads(settings, logger, db) + cleanUpEtherpads(settings, logger, db, state) time.Sleep(timerP) } }() } -func cleanUpEtherpads(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB) { - AvailableBackends.Mutex.Lock() - defer AvailableBackends.Mutex.Unlock() - var mapOfPadsToBackends = make(map[string]string) - for _, backend := range AvailableBackends.Up { +func cleanUpEtherpads(settings models.Settings, logger *zap.SugaredLogger, db interfaces.IDB, state *models.BackendState) { + upBackends := state.SnapshotUp() + mapOfPadsToBackends := make(map[string]string) + for _, backend := range upBackends { foundBackend := settings.Backends[backend] var authorizationHeader string if foundBackend.Username != nil && foundBackend.Password != nil { @@ -91,112 +89,140 @@ func cleanUpEtherpads(settings models.Settings, logger *zap.SugaredLogger, db in continue } bytes, err := io.ReadAll(res.Body) - if err != nil { logger.Warnf("Error reading response body: %v", err) + _ = res.Body.Close() continue } var response models.ListAllPadsModel if err = json.Unmarshal(bytes, &response); err != nil { logger.Warnf("Error unmarshalling response: %v", err) + _ = res.Body.Close() continue } - if response.Code != 0 { logger.Warnf("Error retrieving etherpads: %v", response.Message) + _ = res.Body.Close() continue } - for _, pad := range response.Data.PadIds { if entry, ok := mapOfPadsToBackends[pad]; ok { - logger.Warnf("Pad %s already exists in the map", entry) + logger.Warnf("Pad %s already exists on backend %s", pad, entry) if err = db.RecordClash(pad, backend); err != nil { + metrics.DBErrorsTotal.Inc() logger.Warnf("Error recording clash: %v", err) + } else { + metrics.ClashesTotal.Inc() } continue } mapOfPadsToBackends[pad] = backend } - if err = res.Body.Close(); err != nil { logger.Warnf("Error closing response body: %v", err) - continue } } - var backendToPads = make(map[string][]string) + backendToPads := make(map[string][]string) for pad, backend := range mapOfPadsToBackends { - if val, ok := backendToPads[backend]; ok { - backendToPads[backend] = append(val, pad) - } else { - backendToPads[backend] = []string{pad} - } + backendToPads[backend] = append(backendToPads[backend], pad) } - for backend, pads := range backendToPads { if err := db.CleanUpPads(pads, backend); err != nil { + metrics.DBErrorsTotal.Inc() logger.Warnf("Error cleaning up etherpads: %v", err) } } - for pad, backend := range mapOfPadsToBackends { if err := db.Set(pad, models.DBBackend{Backend: backend}); err != nil { + metrics.DBErrorsTotal.Inc() logger.Warnf("Error setting etherpad: %v", err) } } - } func StartServer(settings models.Settings, logger *zap.SugaredLogger) { - var backendIds []string - for key := range settings.Backends { - backendIds = append(backendIds, key) - } db, err := databases.CreateNewDatabase(settings) if err != nil { logger.Fatalf("Error opening database: %v", err) } - proxies := make(map[string]httputil.ReverseProxy) - checkAvailabilityLoop(settings, logger) - cleanUpEtherpadsLoop(settings, logger, db) - ScrapeJSFiles(settings) + state := &models.BackendState{} + static := newStaticResources() + checkAvailabilityLoop(settings, state, logger) + cleanUpEtherpadsLoop(settings, logger, db, state) + ScrapeJSFiles(settings, static, logger) + + proxies := make(map[string]httputil.ReverseProxy) for key, backend := range settings.Backends { - proxyUrl, err := url.Parse("http://" + backend.Host + ":" + strconv.Itoa(backend.Port)) - if err != nil { - panic(err.Error()) + proxyURL, perr := url.Parse("http://" + backend.Host + ":" + strconv.Itoa(backend.Port)) + if perr != nil { + logger.Fatalf("Error parsing backend URL for %s: %v", key, perr) } - proxy := httputil.NewSingleHostReverseProxy(proxyUrl) - - proxies[key] = *proxy + proxies[key] = *httputil.NewSingleHostReverseProxy(proxyURL) } - handler := ProxyHandler{ + handler := &ProxyHandler{ p: proxies, logger: logger, db: db, + state: state, + static: static, } - http.HandleFunc("/", handler.ServeHTTP) + proxyMux := http.NewServeMux() + proxyMux.HandleFunc("/", handler.ServeHTTP) + proxySrv := &http.Server{Addr: ":" + strconv.Itoa(settings.Port), Handler: proxyMux} - go func() { - const managementPort = 8081 - adminMux := http.NewServeMux() - adminPanel := AdminPanel{ - DB: db, - logger: logger, + managementPort := settings.ManagementPort + if managementPort == 0 { + managementPort = defaultManagementPort + } + adminMux := http.NewServeMux() + adminMux.Handle("/pads", &AdminPanel{DB: db, logger: logger}) + adminMux.Handle("/metrics", promhttp.Handler()) + adminMux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + adminMux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + if len(state.SnapshotUp()) > 0 { + w.WriteHeader(http.StatusOK) + return } - adminMux.Handle("/pads", &adminPanel) + w.WriteHeader(http.StatusServiceUnavailable) + }) + mgmtSrv := &http.Server{Addr: ":" + strconv.Itoa(managementPort), Handler: adminMux} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { logger.Info("Starting management server on port ", managementPort) - if err = http.ListenAndServe(":"+strconv.Itoa(managementPort), adminMux); err != nil { - logger.Fatalf("Error starting management server: %v", err) + if serr := mgmtSrv.ListenAndServe(); serr != nil && !errors.Is(serr, http.ErrServerClosed) { + logger.Fatalf("Error starting management server: %v", serr) + } + }() + go func() { + logger.Info("Starting server on port ", settings.Port) + if serr := proxySrv.ListenAndServe(); serr != nil && !errors.Is(serr, http.ErrServerClosed) { + logger.Fatalf("Error starting server: %v", serr) } }() - logger.Info("Starting server on port ", settings.Port) - if err = http.ListenAndServe(":"+strconv.Itoa(settings.Port), nil); err != nil { - logger.Fatalf("Error starting server: %v", err) + <-ctx.Done() + logger.Info("Shutdown signal received, draining...") + stop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if serr := proxySrv.Shutdown(shutdownCtx); serr != nil { + logger.Warnf("Proxy server shutdown error: %v", serr) + } + if serr := mgmtSrv.Shutdown(shutdownCtx); serr != nil { + logger.Warnf("Management server shutdown error: %v", serr) + } + if cerr := db.Close(); cerr != nil { + logger.Warnf("Database close error: %v", cerr) } + logger.Info("Shutdown complete") } diff --git a/support/etherpad-proxy.service b/support/etherpad-proxy.service new file mode 100644 index 0000000..4bb7e79 --- /dev/null +++ b/support/etherpad-proxy.service @@ -0,0 +1,21 @@ +[Unit] +Description=Etherpad sharding reverse proxy +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=etherpad-proxy +Group=etherpad-proxy +WorkingDirectory=/opt/etherpad-proxy +Environment=SETTINGS_FILE=/opt/etherpad-proxy/settings.json +ExecStart=/opt/etherpad-proxy/etherpad-proxy +Restart=on-failure +RestartSec=5 +# Hardening +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target