Create /data/notes in the image, and write the rollout runbook
The Dockerfile creates /data/mirror and /data/state with the right owner, and the comment above it says exactly why: Docker initialises a new named volume from the image directory, so a mount point the image does not have lands root-owned and the unprivileged user gets EACCES on every write. /data/notes was added to docker-compose.yml without being added here, so every save on a fresh deployment would have failed that way — verified both directions before fixing it. docs/DEPLOY-NOTES.md is the runbook for putting this on a server that is already running: publish, decide where the notes live *before* anything writes one, set WEB_PASSWORD, verify, migrate, index. Plus rollback, which is uneventful — no migration, and the old image simply ignores the new settings and leaves the notes volume alone. PI.md's backup table needed the bigger change. Everything else this server stores is a copy of something upstream and a crawl rebuilds it; the notes are not, and nothing can. They are now the one entry in that table marked irreplaceable, and the EACCES row says to fix the volume's ownership rather than delete it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
262
docs/DEPLOY-NOTES.md
Normal file
262
docs/DEPLOY-NOTES.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Rolling out the notes app
|
||||
|
||||
A runbook for putting the notes feature and the `/app` web app onto a
|
||||
deployment that is already running. [PI.md](PI.md) is the first-time setup;
|
||||
this is the upgrade, and it assumes the server is live and healthy.
|
||||
|
||||
Read [NOTES.md](NOTES.md) first if you have not: **step 2 is a decision you
|
||||
should not make twice**, because changing it later means moving files by hand.
|
||||
|
||||
## What arrives
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
|
||||
| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
|
||||
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
|
||||
|
||||
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
|
||||
directory is the only thing this server writes to, and it is yours.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **Ten minutes**, plus however long a full crawl takes (the first one with a
|
||||
class register adds a few dozen WebUntis requests; it runs in the background).
|
||||
- **No Caddy change.** `/app` is served by the same container on the same port,
|
||||
and the snippet already proxies everything. One line was added to
|
||||
`deploy/Caddyfile.snippet` as a comment about `X-Forwarded-Proto`; if your
|
||||
site block predates it, check that nothing strips that header — the session
|
||||
cookie's `Secure` flag depends on it.
|
||||
- **No database migration.** Notes and class-register entries are new node kinds
|
||||
in a column that is plain `TEXT`.
|
||||
- **No compose change on the Pi.** `deploy/docker-compose.pi.yml` overrides only
|
||||
the image, the database URL and the network, so the `notes` volume and
|
||||
`NOTES_DIR=/data/notes` come from `docker-compose.yml` unchanged.
|
||||
|
||||
## 1. Publish the image
|
||||
|
||||
On a development machine, from a clean checkout of `main`:
|
||||
|
||||
```bash
|
||||
npm run publish-image
|
||||
```
|
||||
|
||||
It builds arm64 and amd64 and pushes `latest` plus the commit id. If you pin
|
||||
`SCHULCLOUD_MCP_TAG`, note the short commit it prints — you need it in step 3.
|
||||
|
||||
## 2. Decide where the notes live
|
||||
|
||||
**Do this before anything writes a note.** Both options work; moving between
|
||||
them afterwards means moving files.
|
||||
|
||||
**A — the volume (default, nothing to do).** `docker-compose.yml` already
|
||||
declares a `notes` volume at `/data/notes`. The app and the CLI write to it,
|
||||
`add_note` writes to it, and that is the whole story. Choose this if you will
|
||||
write notes in the app and nowhere else.
|
||||
|
||||
**B — a directory you sync.** Choose this to *also* write notes from a phone or
|
||||
a laptop in an editor — Obsidian, iA Writer, a git repo. Edit
|
||||
`docker-compose.yml` on the Pi:
|
||||
|
||||
```yaml
|
||||
# under schulcloud-mcp:
|
||||
volumes:
|
||||
- mirror:/data/mirror
|
||||
- state:/data/state
|
||||
- /home/pi/Notizen:/data/notes # was: notes:/data/notes
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p /home/pi/Notizen
|
||||
```
|
||||
|
||||
The container runs unprivileged and `read_only`, so that directory must be
|
||||
writable by the container's user — if the logs show `EACCES` for `/data/notes`,
|
||||
see Troubleshooting. Then point Syncthing, Nextcloud or `git` at it. The server
|
||||
does not care which; new files are picked up by the next full crawl.
|
||||
|
||||
## 3. Configure
|
||||
|
||||
On the Pi, in `/opt/schulcloud-mcp`:
|
||||
|
||||
```bash
|
||||
cd /opt/schulcloud-mcp
|
||||
git pull
|
||||
```
|
||||
|
||||
Add the app password to `.env`. It is the only credential here a person types,
|
||||
so it is a passphrase rather than a token — **at least 12 characters, and three
|
||||
or four words is the right shape**:
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
|
||||
# --- the notes app ---
|
||||
WEB_PASSWORD=change-this-to-three-or-four-words
|
||||
EOF
|
||||
```
|
||||
|
||||
Optional, on the same pass:
|
||||
|
||||
| Setting | |
|
||||
|---|---|
|
||||
| `UNTIS_HISTORY_DAYS` | How far back to index the class register. Default 180; `0` turns it off. Only does anything with `UNTIS_*` configured. |
|
||||
| `NOTES_READONLY=1` | Refuse every write. `list_notes` and `get_note` still work, `add_note` and the app's save do not. Right when the notes are synced in and should have exactly one writer. |
|
||||
| `SCHULCLOUD_MCP_TAG` | If you pin images, set it to the commit from step 1. |
|
||||
|
||||
**Put `WEB_PASSWORD` in your password manager now.** The server never prints it,
|
||||
and changing it logs out every session.
|
||||
|
||||
## 4. Start it
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs --tail 20 schulcloud-mcp
|
||||
```
|
||||
|
||||
The startup line names what is on. With everything configured it ends
|
||||
`… keepalive every 30min, index every 6h`; the app and the notes are not named
|
||||
there, so verify them in the next step rather than reading the log for them.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
From anywhere:
|
||||
|
||||
```bash
|
||||
curl -s https://mcp.example.org/healthz
|
||||
# {"status":"ok","sessions":0,"index":"on"}
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/
|
||||
# 200 ← the app is served; 404 means WEB_PASSWORD is not set
|
||||
```
|
||||
|
||||
Then open `https://mcp.example.org/app/` in a browser and log in.
|
||||
|
||||
- **Notizen** should show today, and — if WebUntis is configured — today's
|
||||
lessons as headings, with times, teacher and room. "Kein Unterricht an diesem
|
||||
Tag" on a company-phase week or a weekend is correct, not a fault.
|
||||
- Type a line and wait two seconds. The status line should read
|
||||
**Gespeichert HH:MM**.
|
||||
- **Einstellungen** should show the Schulcloud token's remaining days and the
|
||||
index's state.
|
||||
|
||||
On a phone, add it to the home screen — it has a manifest and opens standalone.
|
||||
|
||||
Check the file landed where you meant it to:
|
||||
|
||||
```bash
|
||||
docker compose exec schulcloud-mcp ls -R /data/notes
|
||||
# 2026/2026-09-19.md
|
||||
```
|
||||
|
||||
## 6. Bring the old notes in
|
||||
|
||||
If you have notes in Apple Notes, migrate them now — see
|
||||
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
schulcloud note import notes.ndjson --dry-run # look first
|
||||
schulcloud note import notes.ndjson
|
||||
```
|
||||
|
||||
Import **once**. Re-running creates second copies, because the importer cannot
|
||||
tell an edited note from a new one with the same title.
|
||||
|
||||
## 7. Index them
|
||||
|
||||
Notes and the class register are only read by a **full** crawl. One runs on the
|
||||
timer (`CRAWL_INTERVAL_MS`, six hours by default), or force one now:
|
||||
|
||||
```bash
|
||||
schulcloud refresh --force
|
||||
```
|
||||
|
||||
Expect it to take minutes; the CLI polls and prints progress. When it finishes:
|
||||
|
||||
```bash
|
||||
schulcloud status
|
||||
```
|
||||
|
||||
Then ask Claude something only the new sources can answer — *"what did I write
|
||||
down in Deutsch last week?"* or *"what did we actually cover in LF07 this
|
||||
term?"* — and check the answer names your note or the class register as its
|
||||
source.
|
||||
|
||||
## Backups, which now matter more
|
||||
|
||||
**The notes are the only irreplaceable thing this server holds.** Everything
|
||||
else it stores is a copy of something upstream; a note you took in a lesson is
|
||||
not, and nothing can rebuild it.
|
||||
|
||||
| What | Needed? |
|
||||
|---|---|
|
||||
| `.env` | **Yes** — every secret, `WEB_PASSWORD` included. Encrypted only. |
|
||||
| **`schulcloud-mcp_notes`** (option A) | **Yes. Nothing can regenerate these.** |
|
||||
| **Your synced directory** (option B) | **Yes**, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. |
|
||||
| Postgres | Optional: a crawl rebuilds it. |
|
||||
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
|
||||
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
|
||||
|
||||
With the volume (option A):
|
||||
|
||||
```bash
|
||||
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
|
||||
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
|
||||
```
|
||||
|
||||
They are small — a school year of notes is a few megabytes — so back them up
|
||||
often and keep the old copies.
|
||||
|
||||
## Rolling back
|
||||
|
||||
The feature adds no migration and no incompatible state, so going back is the
|
||||
ordinary downgrade:
|
||||
|
||||
```bash
|
||||
# in .env
|
||||
SCHULCLOUD_MCP_TAG=<previous commit>
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
The old image ignores `WEB_PASSWORD` and `NOTES_DIR` and serves no `/app`. **The
|
||||
notes volume is untouched** — the files stay, and the newer image picks them up
|
||||
again unchanged. The only thing lost while rolled back is the ability to read or
|
||||
write them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| The server refuses to start, log names `WEB_PASSWORD` | Shorter than 12 characters | Use a longer passphrase |
|
||||
| Login says the password is wrong, and it is not | `.env` is read at container creation | `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| Logged out constantly, or the login "does nothing" | The session cookie is marked `Secure` and the connection is not HTTPS, or `X-Forwarded-Proto` is stripped | Reach it over HTTPS; check the Caddy site does not strip that header |
|
||||
| `Zu viele Fehlversuche` | The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job |
|
||||
| The editor says the server keeps no notes | `NOTES_DIR` unset on the server | It is set by `docker-compose.yml`; check `COMPOSE_FILE` in `.env` still lists it first |
|
||||
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
|
||||
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
|
||||
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
|
||||
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
|
||||
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
|
||||
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |
|
||||
|
||||
## Security notes for this rollout
|
||||
|
||||
- `WEB_PASSWORD` is the first credential here that a human types, so the first
|
||||
that can be guessed. It is hashed with scrypt at startup and never stored,
|
||||
compared or logged in the clear, and failed logins are rate-limited per
|
||||
address — but **length is what actually protects it**.
|
||||
- The session cookie opens `/api`, which can read your coursework and replace
|
||||
the Schulcloud token. It does not open `/mcp`. Treat a login on a shared
|
||||
device as you would treat the token.
|
||||
- Changing `WEB_PASSWORD` invalidates every session, because the signing key is
|
||||
derived from it. That is the revocation mechanism: change it, recreate the
|
||||
container, log in again.
|
||||
- The write surface is bounded to `NOTES_DIR` by `safeComponent` and
|
||||
`resolveWithin` — the same two functions that stop a hostile Schulcloud
|
||||
filename escaping the file mirror. Schulcloud and WebUntis remain read-only.
|
||||
@@ -218,6 +218,9 @@ for a deployment:
|
||||
|
||||
## The notes app
|
||||
|
||||
Rolling this onto a server that is already running has its own runbook:
|
||||
[DEPLOY-NOTES.md](DEPLOY-NOTES.md).
|
||||
|
||||
Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and
|
||||
a settings page. Unset, it is not served at all, and nothing else changes.
|
||||
|
||||
|
||||
@@ -209,6 +209,11 @@ The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
|
||||
consult them on their own, and are told to say when a note disagrees with the
|
||||
uploaded material rather than quietly preferring one.
|
||||
|
||||
## Deploying it
|
||||
|
||||
Adding this to a server that is already running — the password, where the notes
|
||||
live, backups and rollback — is [DEPLOY-NOTES.md](DEPLOY-NOTES.md).
|
||||
|
||||
## Keeping them somewhere you already sync
|
||||
|
||||
The notes are files, so any sync tool will do and the server does not need to
|
||||
|
||||
42
docs/PI.md
42
docs/PI.md
@@ -88,6 +88,17 @@ INDEX_PERSONAL_FILES=true
|
||||
EOF
|
||||
```
|
||||
|
||||
To write your own lesson notes — and to replace the Schulcloud token from a
|
||||
phone rather than a terminal — set a password for the app at `/app`. Unlike the
|
||||
tokens above it is typed by a person, so it is a passphrase: **at least 12
|
||||
characters, three or four words**. See [NOTES.md](NOTES.md).
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
WEB_PASSWORD=change-this-to-three-or-four-words
|
||||
EOF
|
||||
```
|
||||
|
||||
If your school publishes its timetable in **WebUntis**, add those four values
|
||||
too — the timetable, its cancellations and substitutions are not in Schulcloud
|
||||
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
|
||||
@@ -112,13 +123,15 @@ What those lines do:
|
||||
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
||||
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
||||
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
|
||||
| `WEB_PASSWORD` | The app at `/app`: the notes editor and a settings page for the Schulcloud token. Unset means no app is served at all. Hashed at startup and never logged; changing it logs out every session. Optional. |
|
||||
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
||||
|
||||
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
|
||||
now** — you need both again in step 9, and neither is ever printed by the server:
|
||||
**Copy `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` into your
|
||||
password manager now** — you need the first two again in step 9, and none of
|
||||
them is ever printed by the server:
|
||||
|
||||
```bash
|
||||
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN)=' .env
|
||||
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN|WEB_PASSWORD)=' .env
|
||||
```
|
||||
|
||||
## 4. The first Schulcloud token
|
||||
@@ -406,13 +419,27 @@ Going back works the same way: set the previous commit id, then `pull` and
|
||||
|
||||
## Backups
|
||||
|
||||
Everything this server stores is a copy of something upstream — with one
|
||||
exception. **Your own notes are not a copy of anything**, and nothing can
|
||||
rebuild them.
|
||||
|
||||
| What | Needed? |
|
||||
|---|---|
|
||||
| `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. |
|
||||
| **`schulcloud-mcp_notes`** | **Yes. The only irreplaceable thing here** — a note taken in a lesson cannot be retaken. Small: a school year is a few megabytes. |
|
||||
| Postgres | Optional: a crawl rebuilds it. `docker compose exec postgres pg_dump -U schulcloud schulcloud \| gzip > index.sql.gz` |
|
||||
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
|
||||
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
|
||||
|
||||
```bash
|
||||
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
|
||||
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
|
||||
```
|
||||
|
||||
If you bind-mounted a directory you already sync ([NOTES.md](NOTES.md)), back
|
||||
that up instead — and check the sync tool keeps versioned copies rather than
|
||||
assuming it does.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
@@ -431,7 +458,10 @@ Going back works the same way: set the previous commit id, then `pull` and
|
||||
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
|
||||
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
|
||||
| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` |
|
||||
| `EACCES` for `/data/state`, `/data/mirror` or `/data/notes` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`) and `docker compose up -d`. **Never delete `_notes`** — back it up, then fix the ownership: `docker run --rm -v schulcloud-mcp_notes:/n alpine chown -R 1000:1000 /n` |
|
||||
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| The app logs you out constantly | The session cookie is marked `Secure` but the connection is not HTTPS, or Caddy's `X-Forwarded-Proto` is being stripped | Reach it over HTTPS; leave that header alone |
|
||||
| Notes exist but `search` cannot find them | Only a **full** crawl reads them | `schulcloud refresh --force` |
|
||||
|
||||
## Security checklist
|
||||
|
||||
@@ -451,3 +481,7 @@ Going back works the same way: set the previous commit id, then `pull` and
|
||||
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
|
||||
connector with the new header. A leaked secret path is replaced the same way.
|
||||
`MCP_AUTH_TOKEN` stays valid either way.
|
||||
- `WEB_PASSWORD` is the one credential here that can be guessed, so its length
|
||||
is what protects the notes and the Schulcloud token. Changing it and
|
||||
recreating the container logs out every session — that is the revocation
|
||||
mechanism, and the thing to do if a phone is lost.
|
||||
|
||||
Reference in New Issue
Block a user