diff --git a/Dockerfile b/Dockerfile index 3114666..75d4dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,14 +30,16 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ -# The mirror and the state directory (a replaced session token) are the only -# writable paths. Creating them in the image with the right owner matters: Docker -# initialises a new named volume from the image directory, including its -# ownership, so without this the volume lands root-owned and the unprivileged -# user gets EACCES on every write — with the failure recorded rather than -# crashing, which makes it easy to miss. The state directory holds a credential, -# so only its owner may enter it. -RUN mkdir -p /data/mirror /data/state && chown -R node:node /data && chmod 700 /data/state +# The mirror, the state directory (a replaced session token) and the notes are +# the only writable paths. Creating them in the image with the right owner +# matters: Docker initialises a new named volume from the image directory, +# including its ownership, so without this the volume lands root-owned and the +# unprivileged user gets EACCES on every write — with the failure recorded +# rather than crashing, which makes it easy to miss. The state directory holds a +# credential and the notes are personal, so only their owner may enter either. +RUN mkdir -p /data/mirror /data/state /data/notes \ + && chown -R node:node /data \ + && chmod 700 /data/state /data/notes # node:alpine ships an unprivileged `node` user. USER node diff --git a/README.md b/README.md index f1a05f3..9cd9661 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,9 @@ npm run probe # verifies the token and API against the live instan To try it on your own machine — Docker stack, Claude Code, and the CLI — follow [docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and connect claude.ai, follow [docs/PI.md](docs/PI.md); -[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. To add the notes +app to a server that is already running, follow +[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md). Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — provided you close the Schulportal window afterwards. See diff --git a/docs/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md new file mode 100644 index 0000000..1a08a42 --- /dev/null +++ b/docs/DEPLOY-NOTES.md @@ -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= +``` + +```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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 32331ff..7186f96 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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. diff --git a/docs/NOTES.md b/docs/NOTES.md index b9099e3..358844a 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -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 diff --git a/docs/PI.md b/docs/PI.md index 0040533..1d425b6 100644 --- a/docs/PI.md +++ b/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.