Two halves of the same evening.
Uploads were one-way. A `type: file` field put an object in the bucket
and wrote its key into the record, and nothing ever read it back - a
desk showed `0aba5734-...-drawing.pdf` as a string with no link, so a
builder could receive drawings and never open one.
`GET /attachment/{bucket}/{record}/{field}` streams it. The key is not
the credential and is not taken from the caller: it is read out of the
record named, so a file is reachable only through a record somebody is
allowed to see, and a key that leaks off a desk is a link to nothing.
And there was no rate limiting anywhere. A burst of 30 per caller IP,
refilled one every two seconds, outside the session layer so anyone
over it is turned away before a handler is entered. Well above a
person filling in a form - a page load fetches several resources at
once - and well below being a nuisance. PORTAL_RATE_BURST and
PORTAL_RATE_SECONDS retune it; either 0 turns it off for an instance
behind something that already does this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
4.6 KiB
Markdown
127 lines
4.6 KiB
Markdown
# Operations
|
|
|
|
Portal does not deploy itself. It publishes versions; each site
|
|
decides when to take one.
|
|
|
|
## Cut a release
|
|
|
|
```sh
|
|
cargo release patch # or minor / major
|
|
```
|
|
|
|
That bumps `Cargo.toml`, tags `v<version>` and pushes. CI
|
|
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
|
|
attaches `portal-v<version>.tar.gz` (`portal`, `iris`, `hash.txt`,
|
|
`site/`) to the Gitea release. Plain pushes to `main` only run the
|
|
tests: nothing reaches production from this repo.
|
|
|
|
The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris)
|
|
to match, so a portal change to anything the lint reads fails the
|
|
release rather than the next content push.
|
|
|
|
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
|
|
heading for the new version. That section becomes the release notes,
|
|
and the table at the end of the file records which version each site
|
|
runs.
|
|
|
|
Pkg files are content-hashed (`hash-files = true`, instances run with
|
|
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
|
|
stale cached bundle with new wasm.
|
|
|
|
## Roll it out
|
|
|
|
In the content repo, one line of `.gitea/workflows/deploy.yml`:
|
|
|
|
```yaml
|
|
env:
|
|
PORTAL_RELEASE: v0.4.0
|
|
```
|
|
|
|
Its CI downloads the pinned artifact, ships
|
|
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
|
|
instance env from that repo's own Actions variables and secrets,
|
|
restarts `app@<instance>` and refreshes the Caddy route. Every rollout
|
|
is a commit, so reverting a bad version is `git revert` and a push.
|
|
|
|
Sites upgrade independently and may pin different versions.
|
|
|
|
Content changes never come through any of this: they hot-reload live
|
|
from the content repos over NATS.
|
|
|
|
## Add a site
|
|
|
|
A new content repo with a copy of an existing `deploy.yml` (change
|
|
`INSTANCE`, the port and the domains), Actions variables
|
|
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
|
|
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`, `PORTAL_GITEA_API_TOKEN`
|
|
— Gitea reserves the `GITEA_` prefix for secret names), a Kanidm
|
|
OAuth2 client, and DNS. `site.yaml` handles all branding; no portal
|
|
change is needed.
|
|
|
|
## Environment
|
|
|
|
Runtime configuration is environment variables; `src/main.rs` is the
|
|
authority and each content repo's `deploy.yml` holds the production
|
|
values.
|
|
|
|
| | |
|
|
|---|---|
|
|
| `NATS_URL` | events, projections, content reload, access questions |
|
|
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
|
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
|
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
|
|
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
|
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
|
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
|
| `GARAGE_*` | uploads |
|
|
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
|
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
|
|
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
|
|
|
|
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
|
separate unit on the same host reading the same NATS.
|
|
|
|
## Rate limiting
|
|
|
|
Every request passes a per-caller-IP limit before a session is loaded
|
|
or a handler is entered: a burst of 30, refilled one every two
|
|
seconds. A visitor filling in a form never reaches it (a page load
|
|
fetches several resources at once); a script does.
|
|
|
|
| | |
|
|
|---|---|
|
|
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
|
|
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
|
|
|
|
Either set to `0` turns it off, for an instance behind something that
|
|
already does this. The caller is read from `X-Forwarded-For` or
|
|
`X-Real-IP`, falling back to the socket — all of these instances are
|
|
behind Caddy.
|
|
|
|
This is a fence, not an accounting system. What keeps uploads honest
|
|
is the policy check on the route itself.
|
|
|
|
## Attachments
|
|
|
|
A `type: file` field stores an object key in the record. The file
|
|
comes back out at:
|
|
|
|
GET /attachment/{bucket}/{record}/{field}
|
|
|
|
which reads the key out of that record rather than taking one from the
|
|
caller, so a file can only be fetched through a record somebody is
|
|
allowed to see, and a leaked key is a link to nothing. Reading it
|
|
needs the same policy permission as reading the bucket on a desk.
|
|
|
|
Uploads need a bucket and a key in the Garage on the host:
|
|
|
|
```sh
|
|
garage bucket create <site>-attachments
|
|
garage key create <site>-portal-uploads
|
|
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
|
|
```
|
|
|
|
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
|
|
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
|
fails closed rather than appearing to work and storing nowhere.
|