# The management page `./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what it is configured with, and the photo album's tagger. It replaces `tool/tagger.py`, which served the album half of this on the same port. ```sh ./web.sh # the configured pack ./web.sh --dir ~/pack --port 8140 ./web.sh --import ~/photos # dedup, strip and import, then serve ./web.sh --import ~/photos --dry-run # the report, changing nothing ./web.sh --untagged # only items with no tags yet ``` ## Why it is started by hand A systemd unit would be more convenient and is the wrong shape. This page can rewrite `.env` — the file that decides which groups the bot answers in and who may add it to one — and a unit would keep that writable on loopback all day for the sake of a bookmark. Started by hand, the window is the few minutes somebody is actually looking at it. It is also a **second process**, not more routes on the bot. The bot's own port already serves `POST /_selftest`, which posts for real into a real group; putting a configuration writer beside it would put both behind one mistake. ## Why loopback is not the whole defence Binding to `127.0.0.1` stops the network. It does not stop the browser already running on this machine, and two attacks reach a loopback port from an ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty lines, with nothing to configure and no session to keep: - **Cross-origin form posts.** Any site can POST a `
` at `127.0.0.1:8140` without reading the reply. A form can only send `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`, so every mutating request must declare `application/json` — which a form cannot. Anything else is **415**. - **DNS rebinding.** A hostname the attacker controls is re-pointed at `127.0.0.1` after the page loads, which makes the browser treat this app as same-origin. The rebound request still carries the attacker's hostname, so a `Host` that is not loopback is **421**. Neither is authentication, and neither pretends to be. They are the two cheapest things that make "only this machine" mean it. ## Editing .env Three refusals, in this order, and the order is the point. 1. **A key that may not be written is refused before anything is parsed**, so a read-only key cannot be smuggled past by making the rest of the file invalid. `403`, naming the key. 2. **The whole candidate file is validated by the function startup uses** — `load_config(candidate, use_dotenv=False)`. An edit this bot would refuse at boot is refused at the form instead, with the journal's own sentence. `400`. 3. **Only then is anything written.** A rejected edit leaves `.env` byte-identical. The write itself is line-by-line: only the `KEY=` line of a key that actually changed is replaced, so the paragraphs explaining each setting survive an edit made in a browser. A key the file never had is appended under a marker rather than dropped among comments that explain something else. Temp-then-replace with mode `0600` reapplied before the swap, because this file holds the account's number and is edited from a tab that can be closed mid-save. ### What is read-only, and why | Key | Why the page will not write it | |---|---| | `PHONE_NUMBER` | Identifies the account; the container holds the matching registration. | | `API_BASE_URL` | Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number. | | `DATABASE_PATH` | Repointing it silently orphans the whole statistical history. | | `PHOTO_PACK_DIR` | **Cannot be validated.** `_photo_pack_dirs()` reads `os.environ` directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album. | | `BIND_HOST` / `BIND_PORT` | Moving the bot's port from a page that proxies to it. | ### Group settings need a group listing `GROUP_IDS` and `SELFTEST_GROUPS` are editable only while `/api/groups` has actually returned something. `parse_group_ids` checks *syntax*; startup also requires each listed group to be one the bot really joined, judged against a listing that was really read. With the container down, a syntactically valid write can name a group the next startup rejects — a scoping mistake turned into a unit that will not start. The page says so and leaves the rest editable. Group ids are **picked, never typed** — `GROUP_IDS` and `SELFTEST_GROUPS` render as a `