Remove the unauthenticated webhook and the SQLite migration script
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m54s

Both existed to carry an upgrade across, and both upgrades are done.

/api/alertmanager/webhook took no credential at all: anything able to
reach the port could open an incident for anybody. v0.12.0 kept it,
deprecated, so the teams release did not stop delivery while the
Alertmanager config was edited, and logged a line per payload asking to
be moved. The cluster's Alertmanager now posts on an integration key --
verified in the log, every two minutes, with no deprecation line since
the rollout -- so the door can be shut rather than left ajar until
somebody remembers. A sender still posting there gets the JSON 404 every
unknown /api path gets.

The tests move with it, which they should have done anyway: the harness
mints an integration key for the default team and posts on that, so they
exercise the path production uses rather than one only they still used.

scripts/sqlite-to-postgres.go goes the same way. It was written to be
temporary, it was the last thing needing modernc.org/sqlite, and this
install migrated on 2026-09-20. `go mod tidy` drops the driver and its
six transitive dependencies with it; the module graph is now chi, pgx,
pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out
of the v0.12.0 tag, which the README now says.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-20 18:11:30 +02:00
parent 4c85e7646c
commit 7c87ae2af8
8 changed files with 72 additions and 319 deletions
+43 -27
View File
@@ -182,19 +182,39 @@ In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and
## Alertmanager configuration ## Alertmanager configuration
Add terdut-server as a webhook receiver in your `alertmanager.yml`: Alerts arrive on a team's **integration key**, which says both that the sender
may post and which team the alerts belong to. Mint one as an owner of the team:
```bash
curl -X POST https://terdut.example.com/api/teams/1/integrations \
-H "Authorization: Bearer $TERDUT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"prod alertmanager"}'
```
The response carries the key and the full URL **once**; only a SHA-256 hash is
stored. Put it in your `alertmanager.yml`:
```yaml ```yaml
receivers: receivers:
- name: terdut - name: terdut
webhook_configs: webhook_configs:
- url: http://terdut-server:8080/api/alertmanager/webhook - url: http://terdut-server:8080/api/integrations/<key>/alertmanager
send_resolved: true send_resolved: true
route: route:
receiver: terdut receiver: terdut
``` ```
The whole URL is a credential, so treat it like one. Alertmanager 0.26 and
later can read it from a file with `url_file:` instead, which keeps it out of
your configuration repository:
```yaml
- url_file: /etc/alertmanager/secrets/terdut-webhook-url/url
send_resolved: true
```
The webhook endpoint requires no authentication. The webhook endpoint requires no authentication.
If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give
@@ -447,7 +467,7 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### Authentication ### Authentication
All endpoints except `/api/bootstrap`, `/api/alertmanager/webhook`, All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key: `/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
``` ```
@@ -517,10 +537,10 @@ and the full URL once and stores only a SHA-256 hash.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key | | `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key |
| `POST` | `/api/alertmanager/webhook` | **Deprecated, unauthenticated.** The pre-teams receiver, kept for one release so an upgrade does not stop delivering while the Alertmanager config is edited. Routes everything to the oldest team |
The deprecated path is why anything that can reach the port can still open an This is the only way in. The pre-teams `POST /api/alertmanager/webhook` took no
incident. Move senders to a key and it goes away. credential at all — anything able to reach the port could open an incident —
and was removed in v0.13.0 once senders had moved onto keys.
### Teams ### Teams
@@ -765,10 +785,11 @@ name on them.
What changes, and will need attention: What changes, and will need attention:
- **Alert ingestion moved.** `POST /api/alertmanager/webhook` still works but is - **Alert ingestion moved.** Mint a key with
deprecated and unauthenticated, and routes everything to the oldest team. Mint `POST /api/teams/{teamID}/integrations` and point Alertmanager at the URL it
a key with `POST /api/teams/{teamID}/integrations` and point Alertmanager at returns. In v0.12.0 the old `POST /api/alertmanager/webhook` still worked,
the URL it returns. The old path goes away in a later release. deprecated, routing everything to the oldest team; **v0.13.0 removes it**, so
upgrade straight from v0.11.x to v0.13.0 only after the senders are moved.
- **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and - **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and
editing the rota is now an owner's job. `GET /api/schedule/current` stayed editing the rota is now an owner's job. `GET /api/schedule/current` stayed
where it was but now returns an **array** — one entry per team with somebody where it was but now returns an **array** — one entry per team with somebody
@@ -810,30 +831,25 @@ non-administrator now gets `403` where a `200` used to come back.
## Upgrading from SQLite ## Upgrading from SQLite
Versions up to v0.10.2 stored everything in a SQLite file. From the Postgres release onwards Versions up to v0.10.2 stored everything in a SQLite file. From v0.11.1 the server needs
the server needs `TERDUT_DB_DSN` and keeps nothing on disk. `TERDUT_DB_DSN` and keeps nothing on disk.
The cutover is ordered — the server must not be running while the copy happens: The copy was done by `scripts/sqlite-to-postgres.go`, which **was deleted in v0.13.0** along
with the SQLite driver it was the last user of. It is still in the history — check out the
`v0.12.0` tag to get it:
```bash ```bash
# 1. Stop the old server, keeping its database file. git show v0.12.0:scripts/sqlite-to-postgres.go > sqlite-to-postgres.go
# 2. Create an empty Postgres database, then let the new binary build the schema:
TERDUT_DB_DSN='postgres://terdut:secret@localhost:5432/terdut?sslmode=disable' ./terdut &
# ...watch for "listening on", then stop it again.
# 3. Copy the data across:
go run -tags migrate ./scripts/sqlite-to-postgres.go \
-sqlite /data/terdut.db \
-dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
# 4. Start the new server for good.
``` ```
The cutover is ordered, and the server must not be running while the copy happens: stop the
old version, let the new binary build the schema against an empty Postgres, run the script
with `-sqlite` and `-dsn`, then start the new version for good. On Kubernetes step three runs
as a Job with the same image against the PVC before it is removed.
The copy preserves every id, so incidents keep their numbers and the timeline, alert The copy preserves every id, so incidents keep their numbers and the timeline, alert
membership, outbox and ack tokens all still point where they did. It refuses a target that membership, outbox and ack tokens all still point where they did. It refuses a target that
already has rows, so a second run cannot double-insert. On Kubernetes, step 3 runs as a Job already has rows, so a second run cannot double-insert.
with the same image against the PVC before it is removed.
The script is deliberately temporary: it is the only thing left that needs the SQLite driver,
and both should be deleted once the installs that need them have migrated.
## Upgrading to incidents ## Upgrading to incidents
-10
View File
@@ -7,22 +7,12 @@ require (
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.50.1
) )
require ( require (
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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
-49
View File
@@ -1,16 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -21,14 +13,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -36,46 +22,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-21
View File
@@ -94,27 +94,6 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
} }
} }
// handleLegacyWebhook is the pre-teams unauthenticated endpoint, kept for one
// release so an upgrade does not silently stop delivering while somebody edits
// the Alertmanager config. It routes to the oldest team, which on an upgraded
// install is the Default team everything was moved into.
//
// It is deprecated and unauthenticated — anything that can reach the port can
// open an incident. Move senders to an integration key and this goes away.
func handleLegacyWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, err := defaultTeamID(r.Context(), db)
if err != nil {
log.Printf("legacy webhook: no team to route to: %v", err)
w.WriteHeader(http.StatusOK)
return
}
log.Printf("legacy webhook: unauthenticated payload routed to team %d; "+
"move this sender to an integration key", teamID)
receiveWebhook(w, r, db, notify, teamID)
}
}
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) { func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
var payload amPayload var payload amPayload
if err := decodeJSON(r, &payload); err != nil { if err := decodeJSON(r, &payload); err != nil {
+16 -1
View File
@@ -22,6 +22,10 @@ import (
type ts struct { type ts struct {
*httptest.Server *httptest.Server
key string key string
// ingestKey is an integration key for the default team: the only way in
// since the unauthenticated webhook was removed, so the tests exercise the
// same path production does.
ingestKey string
db *sql.DB db *sql.DB
notify api.NotifyConfig notify api.NotifyConfig
deadman api.DeadmanConfig deadman api.DeadmanConfig
@@ -66,6 +70,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman} s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
var integration struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/integrations",
map[string]string{"name": "test"}), &integration)
if integration.Key == "" {
t.Fatal("no integration key was returned")
}
s.ingestKey = integration.Key
// Dead man's switches belong to a team now, so a test that wants them // Dead man's switches belong to a team now, so a test that wants them
// configures the default team the way an owner would. // configures the default team the way an owner would.
if deadman.Timeout > 0 { if deadman.Timeout > 0 {
@@ -234,7 +248,8 @@ func postWebhook(t *testing.T, s *ts, alerts []map[string]any, groupKey ...strin
} }
} }
data, _ := json.Marshal(payload) data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data)) resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("post webhook: %v", err) t.Fatalf("post webhook: %v", err)
} }
+5 -6
View File
@@ -31,14 +31,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
// Alert ingestion. The key in the path says both that the sender may post // Alert ingestion. The key in the path says both that the sender may post
// and which team the alerts belong to, which is why it needs no session. // and which team the alerts belong to, which is why it needs no session.
//
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
// took no credential at all, was removed in v0.13.0 once the cluster's
// Alertmanager had moved onto a key; a sender still posting there gets the
// JSON 404 every unknown /api path gets.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify)) r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// DEPRECATED, and unauthenticated: anything that can reach the port can
// open an incident here. Kept for one release so an upgrade does not stop
// delivering while the Alertmanager config is edited; it routes everything
// to the oldest team. Remove it once senders carry a key.
r.Post("/api/alertmanager/webhook", handleLegacyWebhook(db, notify))
// Signing in to the web UI. Login trades a password for a session cookie, // Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key. // which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL)) r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
+4 -3
View File
@@ -463,9 +463,10 @@ func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
return id, true return id, true
} }
// defaultTeamID is the team the deprecated unauthenticated webhook routes to: // defaultTeamID is the oldest team, which on an upgraded install is the
// the oldest one, which on an upgraded install is the "Default" team every // "Default" team every pre-teams row was moved into and on a fresh one is the
// pre-teams row was moved into. // team migration 003 creates. Bootstrap puts the first user in it, so somebody
// signing in to a new server lands somewhere rather than in no team at all.
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) { func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
var id int64 var id int64
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id) err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
-198
View File
@@ -1,198 +0,0 @@
//go:build migrate
// Command sqlite-to-postgres copies a terdut SQLite database into a freshly
// migrated Postgres one. It exists for exactly one upgrade — the one that moved
// this server off SQLite — and should be deleted once the installs that need it
// have run it. The modernc.org/sqlite dependency goes with it.
//
// Build-tagged so the dependency stays out of the server binary and out of a
// plain `go build ./...`:
//
// go run -tags migrate ./scripts/sqlite-to-postgres.go \
// -sqlite /path/to/terdut.db \
// -dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
//
// The Postgres side must already have the schema: start the new server once
// against an empty database, let it migrate, stop it, then run this. The copy
// refuses to touch a database that already has rows, so a second run cannot
// double-insert.
//
// Ids are preserved, which is what keeps every foreign key — incident_alerts,
// incident_events, notifications, the ack tokens — pointing at the same rows it
// pointed at before. The identity sequences are moved past the copied ids at the
// end, so the first row the server writes afterwards does not collide.
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// tables are copied parents first: every foreign key points at a table earlier
// in this list.
var tables = []struct {
name string
columns []string
// jsonb marks columns that were TEXT in SQLite and are jsonb in Postgres,
// so the insert can cast them.
jsonb []string
// sequence is the identity sequence to advance afterwards, empty when the
// table has no generated id.
sequence string
}{
{name: "users", columns: []string{"id", "username", "email", "created_at", "ntfy_topic", "password_hash"}, sequence: "users_id_seq"},
{name: "api_keys", columns: []string{"id", "user_id", "key_hash", "name", "created_at", "last_used_at"}, sequence: "api_keys_id_seq"},
{name: "sessions", columns: []string{"id", "token_hash", "user_id", "created_at", "last_seen_at", "expires_at", "user_agent"}, sequence: "sessions_id_seq"},
{name: "alerts", columns: []string{"id", "fingerprint", "name", "status", "labels", "annotations", "starts_at", "ends_at", "generator_url", "received_at", "archived_at", "resolution_source"}, jsonb: []string{"labels", "annotations"}, sequence: "alerts_id_seq"},
{name: "schedule_entries", columns: []string{"id", "user_id", "date", "created_at"}, sequence: "schedule_entries_id_seq"},
{name: "incidents", columns: []string{"id", "group_key", "title", "group_labels", "status", "severity", "triggered_at", "acknowledged_by", "acknowledged_at", "assigned_to", "snoozed_until", "resolved_at", "resolution_source", "archived_at"}, jsonb: []string{"group_labels"}, sequence: "incidents_id_seq"},
{name: "incident_alerts", columns: []string{"incident_id", "alert_id", "added_at"}},
{name: "incident_events", columns: []string{"id", "incident_id", "type", "user_id", "alert_id", "detail", "created_at"}, sequence: "incident_events_id_seq"},
{name: "notifications", columns: []string{"id", "incident_id", "user_id", "topic", "kind", "created_at", "send_after", "attempts", "sent_at", "last_error"}, sequence: "notifications_id_seq"},
{name: "incident_ack_tokens", columns: []string{"token_hash", "incident_id", "user_id", "created_at", "expires_at"}},
}
func main() {
sqlitePath := flag.String("sqlite", "", "path to the existing terdut SQLite database")
dsn := flag.String("dsn", "", "Postgres DSN of the migrated, empty database")
flag.Parse()
if *sqlitePath == "" || *dsn == "" {
log.Fatal("both -sqlite and -dsn are required")
}
src, err := sql.Open("sqlite", *sqlitePath)
if err != nil {
log.Fatalf("open sqlite: %v", err)
}
defer src.Close()
dst, err := sql.Open("pgx", *dsn)
if err != nil {
log.Fatalf("open postgres: %v", err)
}
defer dst.Close()
if err := dst.Ping(); err != nil {
log.Fatalf("ping postgres: %v", err)
}
if err := assertEmpty(dst); err != nil {
log.Fatalf("%v", err)
}
// One transaction for the whole copy: a run that dies half way leaves the
// target as it found it, rather than a partial database someone has to
// recognise as partial.
tx, err := dst.Begin()
if err != nil {
log.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck
for _, t := range tables {
n, err := copyTable(src, tx, t.name, t.columns, t.jsonb)
if err != nil {
log.Fatalf("copy %s: %v", t.name, err)
}
log.Printf("%-20s %d row(s)", t.name, n)
}
for _, t := range tables {
if t.sequence == "" {
continue
}
if err := advanceSequence(tx, t.sequence, t.name); err != nil {
log.Fatalf("advance %s: %v", t.sequence, err)
}
}
if err := tx.Commit(); err != nil {
log.Fatalf("commit: %v", err)
}
log.Print("done")
}
// assertEmpty refuses a target that already holds data, so running this twice
// cannot duplicate anything.
func assertEmpty(dst *sql.DB) error {
for _, t := range tables {
var n int64
if err := dst.QueryRow("SELECT COUNT(*) FROM " + t.name).Scan(&n); err != nil {
return fmt.Errorf("count %s (has the new server migrated this database?): %w", t.name, err)
}
if n > 0 {
return fmt.Errorf("%s already has %d row(s): the target must be empty", t.name, n)
}
}
return nil
}
func copyTable(src *sql.DB, tx *sql.Tx, table string, columns, jsonb []string) (int64, error) {
rows, err := src.Query("SELECT " + strings.Join(columns, ", ") + " FROM " + table)
if err != nil {
return 0, err
}
defer rows.Close()
insert := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" +
strings.Join(valuePlaceholders(columns, jsonb), ", ") + ")"
stmt, err := tx.Prepare(insert)
if err != nil {
return 0, err
}
defer stmt.Close()
var copied int64
for rows.Next() {
// Scanning into any lets the SQLite driver decide each column's Go type
// and hands it straight back to pgx, which is all this needs: the column
// types match on both sides, apart from the JSON casts above.
values := make([]any, len(columns))
targets := make([]any, len(columns))
for i := range values {
targets[i] = &values[i]
}
if err := rows.Scan(targets...); err != nil {
return copied, err
}
if _, err := stmt.Exec(values...); err != nil {
return copied, fmt.Errorf("insert row %d: %w", copied+1, err)
}
copied++
}
return copied, rows.Err()
}
// valuePlaceholders numbers the placeholders, casting the columns that became
// jsonb: pgx sends a Go string as text, and Postgres will not assign text to a
// jsonb column without being told.
func valuePlaceholders(columns, jsonb []string) []string {
isJSON := make(map[string]bool, len(jsonb))
for _, c := range jsonb {
isJSON[c] = true
}
out := make([]string, len(columns))
for i, c := range columns {
out[i] = fmt.Sprintf("$%d", i+1)
if isJSON[c] {
out[i] += "::jsonb"
}
}
return out
}
// advanceSequence puts an identity sequence past the largest copied id. Without
// it the first insert after the migration would reuse id 1.
func advanceSequence(tx *sql.Tx, sequence, table string) error {
_, err := tx.Exec(fmt.Sprintf(
`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)`,
sequence, table))
return err
}