diff --git a/Dockerfile b/Dockerfile index 92c2129a..c2e582ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone COPY ./install_prerequisite.sh /opt/openrepl/ COPY ./bin/gdb /usr/bin/ +# the debug helpers (ptrace probe, openrepl-gdb, the rappel wrapper), installed by the script +COPY ./scripts/ptrace-probe.c ./scripts/openrepl-gdb ./scripts/openrepl-rappel /opt/openrepl/scripts/ RUN ./install_prerequisite.sh --cleanup-tools --run-tests FROM builder as build-image diff --git a/README.md b/README.md index 86570fcd..fdfda24e 100755 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Then do a quick manual check at `localhost:8080`: ### Notes - **No per-REPL sandboxing locally.** Colima's VM uses cgroup v2, so the log shows `Unable to create Container` and REPLs run without their own namespaces or memory limits. Test sandboxing on a cgroup v1 host. +- **Debug runs under QEMU locally.** Rosetta cannot trace programs, so plain gdb fails with `Couldn't get registers`. Debug detects it and runs your program under QEMU, which gdb connects to (see [LLD 04](docs/lld/04-ide-run-and-files.md)). The program starts paused: set breakpoints, then type `c`. The assembly REPL (rappel) needs real ptrace and does not start here. - **Don't commit `bin/gotty`.** The dev container rebuilds this tracked file. Restore it before committing with `git checkout -- bin/gotty`, and don't commit `node_modules` or `dist` folders. - **Genie and Practice question generation need an OpenAI key.** Set `OPENREPL_OPENAI_API_KEY` (see [Settings and secrets](#settings-and-secrets)), then restart the server. - **Stopping and restarting the VM:** `colima stop openrepl` and `colima start openrepl`. `colima delete openrepl` removes the VM and its images. diff --git a/docs/distributed-mode.md b/docs/distributed-mode.md index 5a87dcf9..dddf5083 100644 --- a/docs/distributed-mode.md +++ b/docs/distributed-mode.md @@ -189,6 +189,10 @@ worker_token = "" // or GOTTY_WORKER_TOKEN A worker reconnects by itself, with a delay that grows from 1 to 30 seconds, when the gateway restarts or the network drops. +### Workers that cannot trace programs (Raspberry Pi) + +An arm64 worker runs the amd64 image under `qemu-user`, which has no `ptrace`. Debug (gdb) still works there: the image's `openrepl-gdb` notices the emulator and lets it serve gdb (`QEMU_GDB`), so the program starts paused and you type `c` to start it. Programs that use the 32-bit `int 0x80` system call do not run under qemu-user at all. The assembly REPL (`rappel`) needs real `ptrace` and prints a short message instead of starting; the dashboard shows "no ptrace" for such a worker, and you can switch `rappel` off for it there (next section). See LLD 04 §2. + ### The worker's own port A worker opens no port by default. Give it `--port` (or `--address`) and it also serves that address, like a standalone server, so people on the same network can use the worker directly at `http://:/`: @@ -282,6 +286,15 @@ Things to do: Limits: files over 50 MB are not synchronized; sockets, pipes and device files are skipped; names that start with `.wsync-` are reserved; ownership and set-user-id bits are not copied; Linux only. +## Languages per worker + +`--worker-languages` says what a worker has when it starts. In the dashboard (*Workers*, then a node) the Languages card lets you change that without restarting anything, for each language: **Default** (what the worker declared), **Off** (refuse new terminals of it on this node), or **On** (take it although the worker did not declare it, for a language you installed on the worker afterwards). It works the same for the gateway's own node. Visitors on that node no longer see a refused language in the language picker, and open terminals keep running. The card also says whether the node's host can trace programs, which the assembly REPL needs. + +| Route | What it does | +|---|---| +| `GET /admin/workers//languages` | The languages with what the node declares and what you decided. `` is a worker id, or `local`. | +| `POST /admin/workers//languages` | Body `{"language": "rappel", "rule": "default"}` (or `"off"`, `"on"`). Saved with the site settings. | + ## Operating a fleet The easiest way is the dashboard at `/admin` (admin sign-in): *Workers* lists the nodes with their load, state and how many sessions each has been given, opens a node's details (address, version, languages, sync state, clock difference) and drains, undrains or reconnects it. *Sessions* lists who is on which node, and can end a session or move it to another node. *Add a worker* shows the command line for a new one, with the gateway's tunnel host key fingerprint. The same things are available as routes on the gateway. They need the same admin sign-in; to change something from `curl`, pass the browser's session cookie and the header `X-Requested-With: openrepl-admin` (the dashboard sends it; without it a change is refused): @@ -353,7 +366,8 @@ A few things to know when reading it: | Browser: `execution node unavailable` (503) | The worker that holds this session is offline. It works again when the worker reconnects. | | Browser: `workspace node unavailable` (503) | A signed-in user's worker is offline or draining. | | Browser: `no execution node available` (503) | No online node has a weight: no worker is connected and the gateway runs with `--local-weight 0`. | -| Browser: `this language is not available on your execution node` | The session's worker was started with `--worker-languages` and lacks that REPL. | +| Browser: `this language is not available on your execution node` | The session's worker was started with `--worker-languages` and lacks that REPL. Install it and switch the language **On** for that node (Languages card), or restart the worker with the list. | +| Browser: `This language is switched off on your execution node` | An admin switched it off for that node in the Languages card. Set it back to Default. | | Terminal: `exceeding max number of connections` | That node's memory budget is used up. | All messages go to `/gottyTraces/gotty.log` on the machine concerned. diff --git a/docs/lld/04-ide-run-and-files.md b/docs/lld/04-ide-run-and-files.md index bd0bbcd0..f33f641e 100644 --- a/docs/lld/04-ide-run-and-files.md +++ b/docs/lld/04-ide-run-and-files.md @@ -60,12 +60,27 @@ A Run request executes `/bin/bash -c "$SCRIPT" "$ARG0" "$FLAGS"` (the `Prefix` i | `$0` | Path of the saved editor file (`IdeFileName`), or the base64 editor content when no file is selected. Scripts decode it with `echo $0 \| base64 --decode`. | | `$1` | The *Compiler/Repl Args* text box, passed as one string. | | `$IdeLang` | The UI option value (`c`, `cpp`, `go`, `python`, …), so one backend (for example `cling`) can pick `gcc` or `g++`. | -| `$CompilerOption` | `debug` when **debug** was pressed. Scripts then add `-g` and launch `gdb`, `rust-gdb` and similar. | +| `$CompilerOption` | `debug` when **debug** was pressed. Scripts then add `-g` and launch `openrepl-gdb` (below) instead of a bare `gdb`. | | `$IdeFileName`, `$HOME` | The same file path, and the workspace directory. | | client `EnvFlags` | Extra variables from the *Env Vars/Paths* box. | Scripts usually write `test.` into `$HOME` when there is no file, compile it, run it, and `printf "\n"` at the end. +### Debug on a host without ptrace (`openrepl-gdb`) + +gdb traces the program with `ptrace`, and not every host has it: a Raspberry Pi worker runs the amd64 image under `qemu-user` (`ptrace` answers "Function not implemented"), and x86 code under Rosetta can start a traced child but not read its registers (`Couldn't get registers: Input/output error`). Scripts therefore start `openrepl-gdb [--gdb rust-gdb] PROGRAM [gdb arguments]` (`scripts/openrepl-gdb`, installed to `/usr/local/bin`), which asks `openrepl-ptrace-probe` (`scripts/ptrace-probe.c`, built once by the install script) and takes one route: + +| Route | When | What it does | +|---|---|---| +| `ptrace` | The probe passes (it forks a child, traces it and reads its registers). | `exec gdb` as before. If only the address randomization cannot be switched off (the probe's exit 2), gdb gets `set disable-randomization off`, so it does not warn. | +| `emulator` | The probe fails and `QEMU_VERSION=1 /bin/true` prints a `qemu-` version: the container itself runs under qemu-user (the Pi workers). | Starts the program with `QEMU_GDB=` in its environment, which makes the emulator that runs it wait for gdb on that port. No second emulator, no extra slowdown. | +| `qemu` | The probe fails, there is no emulator around, and the host is x86-64 (Rosetta, a sandbox). | Runs the program as `QEMU_GDB= QEMU_LD_PREFIX=/ qemu-x86_64 PROGRAM` (`qemu-user` from apt). | +| none | Neither. | Prints "Debugging is not available on this server" and "Run still works", exit 1. | + +On the last three routes gdb is started with `set sysroot /` and `target remote 127.0.0.1:`, on a free port chosen per session. It only connects: the user sets breakpoints and continues. qemu describes its registers to gdb in XML, so these routes need a gdb built with XML support (`gdb --configuration` shows `--with-expat`). The helper takes the first `gdb` on the `PATH` that has it (for `rust-gdb`, it sets `RUST_GDB`). The gdb 8.1.1 the repo bundles as `bin/gdb` has none and, when it is first on the `PATH`, fails with `Remote 'g' packet reply is too long` and lets the program run away; Ubuntu's gdb (installed by the install script) is used instead, and if there is none the helper says so before it starts the program. The program starts paused at its first instruction, because a remote target cannot "run": `run` and `r` are redefined to `continue`, and the helper prints a two line banner saying so. The program keeps the terminal for its input, and Ctrl-C reaches gdb only (the shell ignores it for the background job), which interrupts the program. When gdb ends, or the terminal closes (a small watcher notices that `/dev/tty` is gone, because a gdb that waits for a running remote program ignores the hangup), the helper stops the program. + +`OPENREPL_GDB_ROUTE=ptrace|emulator|qemu` forces a route; the install script's test uses `qemu`. Known limits of the QEMU routes: x86-64 only, slower than native, and a program that uses the 32-bit `int 0x80` system call gate does not work under qemu-user (the assembly sample uses `syscall`, which does). The assembly REPL (rappel) cannot work without `ptrace` at all: `/usr/local/bin/rappel` is a wrapper (`scripts/openrepl-rappel`) that runs the probe first and, if `ptrace` is missing, tells the user to use the editor's Run or Debug; otherwise it starts the real rappel (`OPENREPL_RAPPEL_BIN`, default `/opt/gotty/rappel/bin/rappel`). + ### Language routing on the client `handleTerminalOptions` (`gotty.ts`): diff --git a/docs/lld/08-build-and-deploy.md b/docs/lld/08-build-and-deploy.md index 9c6aa017..a8a34654 100644 --- a/docs/lld/08-build-and-deploy.md +++ b/docs/lld/08-build-and-deploy.md @@ -53,11 +53,13 @@ For a local build and test loop on macOS (Colima with Rosetta, plus a dev contai It has three stages, all based on `ubuntu:22.04`: -1. **`builder`:** copies `install_prerequisite.sh` and `bin/gdb`, then runs `./install_prerequisite.sh --cleanup-tools --run-tests`. This installs every REPL runtime: +1. **`builder`:** copies `install_prerequisite.sh`, `bin/gdb` and the debug helpers (`scripts/ptrace-probe.c`, `scripts/openrepl-gdb`, `scripts/openrepl-rappel`), then runs `./install_prerequisite.sh --cleanup-tools --run-tests`. This installs every REPL runtime: - from apt: gcc/g++, default-jdk, python2.7/3, ipython/ipython3, golang, yaegi, npm/nvm/node, ruby, perl, tcl, sqlite3, jq, rustc/cargo, rust-gdb, nasm, rlwrap, net-tools, libcap2-bin; - prebuilt: cling (`repls/cling-Ubuntu-22.04-x86_64-*.tar.bz2`) and evcxr; - from source: gointerpreter, jq-repl, perli, rappel; - - from npm: `typescript@4.9.5` and `ts-node`. + - from npm: `typescript@4.9.5` and `ts-node`; + - for Debug on hosts without `ptrace` (LLD 04 §2): `qemu-user` from apt, `openrepl-ptrace-probe` (built from `scripts/ptrace-probe.c` once, here, so Debug never compiles anything), `openrepl-gdb`, and `/usr/local/bin/rappel` as a wrapper in front of the real rappel. The bundled `bin/gdb` is copied but Ubuntu's gdb (pulled in by `rust-gdb`) is the one that ends up in use. + - `--run-tests` runs each command through `bash -c`, so the `tclsh` check is a real pipe. It also debugs a small program through the QEMU route (this one must pass), through `ptrace` and the rappel REPL (reported, not fatal, and skipped when the build host has no `ptrace`). 2. **`build-image`:** adds make, git and npm (pinned to 8.5.1), copies the repo, and runs `make all`. 3. **Final:** `builder` plus `/usr/local/bin/gotty` and `/opt/scripts/run_app.sh`. It creates `/gottyTraces` and `/opt/gotty`, sets `ENV TERM=xterm GODEBUG=cgocheck=1 GOPATH=/opt/gotty/`, `EXPOSE 80`, `ENTRYPOINT run_app.sh`, `CMD ["-p","80"]`. diff --git a/docs/lld/09-adding-a-repl.md b/docs/lld/09-adding-a-repl.md index e71dee94..e399df1b 100644 --- a/docs/lld/09-adding-a-repl.md +++ b/docs/lld/09-adding-a-repl.md @@ -49,7 +49,7 @@ printf "\n"; ``` -See LLD 04 §2 for the Compiler script contract (`$0`, `$1`, `$IdeLang`, `$CompilerOption`). Escape `<`, `>` and `&` in XML. `` can wrap the REPL (for example with `rlwrap`) in interactive mode only. +See LLD 04 §2 for the Compiler script contract (`$0`, `$1`, `$IdeLang`, `$CompilerOption`). A script that starts gdb for `debug` should call `openrepl-gdb PROGRAM` (or `openrepl-gdb --gdb rust-gdb PROGRAM`) rather than `gdb`, so Debug also works on hosts without ptrace. Escape `<`, `>` and `&` in XML. `` can wrap the REPL (for example with `rlwrap`) in interactive mode only. ## 4. Expose it in the UI (`src/resources/index.html`) diff --git a/docs/lld/11-distributed-execution.md b/docs/lld/11-distributed-execution.md index ebae7059..138eb84b 100644 --- a/docs/lld/11-distributed-execution.md +++ b/docs/lld/11-distributed-execution.md @@ -189,6 +189,7 @@ The gateway applies the site rules (maintenance mode, languages that are switche - *Use:* `applyWorkerConfig` (`server/worker_config.go`) makes the config the worker's `SiteSettings`, in memory only; the worker reads no `settings.json` or database for them. `/settings.js` of the worker then shows the gateway's banner, and `wrapWorkerControls` closes a terminal of a visitor of the worker's own port with `site notice: ...` for maintenance and for a switched-off language. A request the gateway forwarded (trusted) is not checked again: the gateway knows who is an admin and the worker does not. On the worker's own port nobody is exempt, admins included; an admin who needs a terminal during maintenance uses the gateway. - *Before the first config* (or if the gateway never sends one, an older gateway) the worker has the default settings: no rules. - *Dashboard:* the worker's drawer shows "Site rules: up to date" or the revision it follows (`WorkerInfo.ConfigRev`, `ConfigCurrent`). +- *Per worker:* the list of languages that are off is the worker's own (`ServerConfig.WorkerConfig`, `server.gatewayWorkerConfigFor`): the site's, the ones an admin switched off on that worker, and the ones it did not declare (`--worker-languages`) unless an admin switched them on (LLD 13, "Languages per node"). The revision therefore differs from worker to worker; `ConfigRevisionFor` gives the one a worker should follow. - *Not sent on purpose:* the MongoDB or Firestore settings (a worker keeps files, LLD 05), keys, the admin list, and limits such as capacity, which are the worker's own flags. Together with the secret (section 6.1a), this is everything a worker takes from the gateway. A failure to listen ends `Run` before the worker connects to the gateway. A listener that stops later cancels the worker's context, like a standalone server. On shutdown `runWorker` closes both servers, then waits for the live WebSockets. @@ -232,7 +233,7 @@ Randomized weighted selection, the method of `ServerPool.Select` in `sish-lb/lb. Differences from sish-lb: the candidate list is rebuilt on every pick because eligibility changes with state and load; there are no global flags; the pool has its own `*rand.Rand` under a mutex instead of calling `rand.Seed` on the shared generator; the key is the session, not a hostname. -Placement happens once per session, on the first page load, before the language is known. So the language is not part of selection: a worker that lacks the requested REPL (`--worker-languages`) answers that terminal with 503 "this language is not available on your execution node". With no `--worker-languages` a worker is taken to have every REPL. +Placement happens once per session, on the first page load, before the language is known. So the language is not part of selection: a worker that lacks the requested REPL (`--worker-languages`) has that terminal refused with "this language is not available on your execution node" (a notice the page shows; a plain 503 for a request that is not a WebSocket). With no `--worker-languages` a worker is taken to have every REPL. An admin can override this per node and language (LLD 13, "Languages per node"): Off refuses a language the node can run, On takes one it did not declare. The picker hides what the visitor's node refuses (`Router.NodeOf`). ## 9. Tunnel (`src/tunnel`) diff --git a/docs/lld/13-admin-dashboard.md b/docs/lld/13-admin-dashboard.md index fbd618c2..c480743c 100644 --- a/docs/lld/13-admin-dashboard.md +++ b/docs/lld/13-admin-dashboard.md @@ -43,6 +43,7 @@ All of them run on the gateway or the standalone server, never on a worker. The | `/admin/users//signout`, `/block`, `/unblock` | POST | See section 4. | | `/admin/workers` | GET | Gateway: every node (LLD 11 section 11). | | `/admin/workers//drain`, `/undrain`, `/reconnect` | POST | Gateway: stop or resume new sessions; drop the connection of a worker, which connects again by itself. | +| `/admin/workers//languages` | GET, POST | Gateway: the Languages card of a node (`` is a worker id, or `local` for the gateway). GET lists the languages with what the node declares and what an admin decided; POST `{"language": "rappel", "rule": "default" \| "off" \| "on"}` sets one. Saved in `SiteSettings.NodeLanguages`, audited. See below. | | `/admin/sessions` | GET | Gateway: the execution contexts with user, node, terminals, home, last activity and expiry. | | `/admin/sessions//end` | POST | Gateway: close the session's terminals and forget its placement. | | `/admin/sessions//move` | POST | Gateway: body `{"to": ""}`. See section 4. | @@ -127,6 +128,21 @@ The dashboard's own reads (GET under `/admin/` that succeed) are left out of the `statsStore` counts, where `wrapControls` lets a terminal through, one terminal per language per day, and the visitors of the day. A visitor is a keyed hash of the account or, for a guest, the address, different on each day, so one visitor who opens several terminals counts once. Only today keeps these hashes (to count across a restart); older days keep numbers only, 60 days in all. The file is `admin-stats.json`, saved at most every 30 seconds. A gateway counts the terminals of its workers; a worker's own port is not counted. +### Languages per node + +The site-wide switch turns a language off for everybody. The Languages card in a node's drawer (*Workers*, then the node) decides per node, for every language that has a terminal (JavaScript runs in the browser and is not listed): **Default** follows what the node declares (`--worker-languages`; a node that did not list its languages runs everything), **Off** refuses new terminals of it although the node can run it, **On** takes it although the node did not declare it, for a language installed after the worker started. Open terminals keep running. The choices are saved with the site settings (`SiteSettings.NodeLanguages`, node id to `{off, on}`), by `/admin/workers//languages` only: the Settings page does not send them and keeps what is stored. They are audited ("worker pi-1: rappel switched off"). + +How a choice takes effect (`server/node_languages.go`, `admin_node_languages.go`): + +- *New terminals:* the gateway's router asks `Config.NodeLanguage` for the rule of the language on the session's node (`gateway/router.go`). Off closes the terminal's WebSocket with a notice the page shows ("This language is switched off on your execution node for now"), as the site-wide switch does; an admin is let through. On skips the check against the node's declared languages. Otherwise the declared list decides, as before (the same text, "this language is not available on your execution node", now as a notice). +- *The picker:* `/settings.js` is served by the gateway, so it asks `Router.NodeOf` (a lookup of the visitor's session, nothing is created) which node the visitor is on, and lists as off what that node refuses: the site-wide languages, the node's Off, and what it did not declare unless On. A visitor whose node is not known yet gets the site's list. +- *The worker itself:* its `WorkerConfig` carries the same list (`tunnel.ServerConfig.WorkerConfig`, per worker, with its own revision), for the people who open the worker's own port. +- *The gateway's own node* (`local`) has the same card. + +The card also shows what the node's host says about tracing programs. Each node asks once, when it starts, with `openrepl-ptrace-probe` (LLD 04 section 2); a worker reports it in its registration (`RegisterRequest.Ptrace`), the gateway for itself. A host that cannot trace shows a "no ptrace" pill in the table and in the drawer, a warning in the card, and "needs ptrace" next to the assembly REPL (`rappel`), which cannot work there. It is only information: an admin decides whether to switch the language off. + +The drawer is redrawn on every refresh; the Languages card is created once per opened drawer and not redrawn with it, so an open menu is not closed. + ## 7. Front end `admin.js` is one file without libraries. A view is `{root, refresh, poll}`; the router shows the view of the URL hash (`#workers`, `#site`, ...) and hides the fleet views when the server is not a gateway. diff --git a/install_prerequisite.sh b/install_prerequisite.sh index 615a7772..9eae4ac5 100755 --- a/install_prerequisite.sh +++ b/install_prerequisite.sh @@ -153,6 +153,17 @@ else chmod 755 /usr/local/bin/rappel cd .. fi +# rappel traces its child with ptrace: on a host without it the wrapper says so, and +# starts the real rappel (OPENREPL_RAPPEL_BIN) everywhere else +if [ -x "$GOTTY_DIR/rappel/bin/rappel" ]; then + if [ -f "$SCRIPT_DIR/scripts/openrepl-rappel" ]; then + rm -f /usr/local/bin/rappel + install -m 755 "$SCRIPT_DIR/scripts/openrepl-rappel" /usr/local/bin/rappel + else + echo "ERROR: $SCRIPT_DIR/scripts/openrepl-rappel is missing" + retVal=1 + fi +fi #install gointerpreter git clone https://github.com/vickeykumar/Go-interpreter.git @@ -208,6 +219,23 @@ mount -t cgroup -o none,name=systemd cgroup /sys/fs/cgroup/systemd 2>&1 || true #apt-get install -y --no-install-recommends gdb cp $SCRIPT_DIR/bin/gdb /usr/local/bin/ || true chmod 755 /usr/local/bin/gdb || true + +# Debug (gdb) needs ptrace, and some hosts do not have it: the Raspberry Pi workers +# (an amd64 image under qemu-user), x86 code under Rosetta, sandboxes. There gdb +# talks to qemu instead, which runs the program and serves gdb on a port: +# openrepl-ptrace-probe asks the host whether ptrace works (built once, here) +# openrepl-gdb what the Debug button starts: gdb, or gdb + qemu +apt-get install -y --no-install-recommends qemu-user +# qemu describes its registers to gdb in XML, so that route needs a gdb built with XML +# support; the bundled gdb 8.1.1 (above, first on the PATH) is not. Ubuntu's is. +apt-get install -y --no-install-recommends gdb +if [ -f "$SCRIPT_DIR/scripts/ptrace-probe.c" ] && [ -f "$SCRIPT_DIR/scripts/openrepl-gdb" ]; then + gcc -O1 -o /usr/local/bin/openrepl-ptrace-probe "$SCRIPT_DIR/scripts/ptrace-probe.c" || retVal=1 + install -m 755 "$SCRIPT_DIR/scripts/openrepl-gdb" /usr/local/bin/openrepl-gdb || retVal=1 +else + echo "ERROR: $SCRIPT_DIR/scripts/ptrace-probe.c or openrepl-gdb is missing" + retVal=1 +fi chmod -R 777 /tmp/home || true #cleanup @@ -252,20 +280,58 @@ if [ $run_tests -eq 1 ]; then "sqlite3 --version" "tsc --version" "ts-node --version" - "echo "nop" | rappel" ) - # Loop through the array and execute each command - for cmd in "${test_commands[@]}"; do - $cmd - if [ $? -eq 0 ]; then + # Loop through the array and execute each command, through bash so that a pipe is one + while IFS= read -r cmd; do + bash -c "$cmd" + status=$? + if [ $status -eq 0 ]; then echo "test [$cmd] => PASSED" else - echo "test [$cmd] => FAILED with status code $?" + echo "test [$cmd] => FAILED with status code $status" retVal=1 fi + done < <(printf '%s\n' "${test_commands[@]}") + + # Debugging. What the host allows is the host's choice (a builder may have no ptrace), + # so only the route through qemu has to work; the others are reported. + echo "test [ptrace on this host] => $(openrepl-ptrace-probe 2>&1)" + debug_dir=$(mktemp -d) + cat > "$debug_dir/t.c" <<'EOF' +#include +int sq(int x) { return x * x; } +int main(void) { printf("%d\n", sq(3)); return 0; } +EOF + gcc -ggdb -g -static -o "$debug_dir/t.o" "$debug_dir/t.c" + openrepl-ptrace-probe > /dev/null 2>&1 + has_ptrace=$? + for route in qemu ptrace; do + if [ $route = ptrace ] && [ $has_ptrace -eq 1 ]; then + echo "test [debug a program with $route] => SKIPPED (no ptrace here)" + continue + fi + out=$(cd "$debug_dir" && OPENREPL_GDB_ROUTE=$route timeout 120 openrepl-gdb ./t.o -batch -ex 'break sq' -ex run -ex bt -ex delete -ex continue 2>&1) + if echo "$out" | grep -q 'Breakpoint 1, sq (x=3)'; then + echo "test [debug a program with $route] => PASSED" + elif [ $route = qemu ]; then + echo "test [debug a program with $route] => FAILED" + echo "$out" + retVal=1 + else + echo "test [debug a program with $route] => FAILED (not fatal: ptrace is the host's)" + echo "$out" + fi done + rm -rf "$debug_dir" + if [ $has_ptrace -eq 1 ]; then + echo "test [echo nop | rappel] => SKIPPED (no ptrace here)" + elif echo 'nop' | timeout 60 rappel > /dev/null 2>&1; then + echo "test [echo nop | rappel] => PASSED" + else + echo "test [echo nop | rappel] => FAILED (not fatal)" + fi fi exit $retVal diff --git a/scripts/openrepl-gdb b/scripts/openrepl-gdb new file mode 100755 index 00000000..f6079d8a --- /dev/null +++ b/scripts/openrepl-gdb @@ -0,0 +1,189 @@ +#!/bin/bash +# openrepl-gdb [--gdb COMMAND] PROGRAM [GDB ARGUMENTS...] +# +# What the Debug button starts instead of a bare gdb (C, C++, Go, Rust, assembly). +# gdb traces the program with ptrace, and not every host has it, so this looks at +# the host first (openrepl-ptrace-probe) and takes one of these routes: +# +# ptrace the host allows it: gdb as usual +# emulator the container itself runs under qemu-user (an amd64 image on a +# Raspberry Pi worker). Emulated programs cannot be traced, but the +# emulator can serve gdb: QEMU_GDB= in the environment of the +# program makes the emulator that runs it wait for gdb on that port. +# qemu no ptrace and no emulator around us (x86 code under Rosetta, a +# sandbox without ptrace): the image's own qemu-x86_64 runs the +# program the same way +# none nothing works: say so and stop +# +# On the last two routes the program starts paused at its first instruction, and +# `run` (or `r`) is made to mean `continue`, because a remote target cannot "run". +# +# --gdb picks another gdb front end (rust-gdb). OPENREPL_GDB_ROUTE=ptrace|emulator|qemu +# forces a route; the install script's test and the docs use it. + +gdbcmd=gdb +if [ "$1" = "--gdb" ]; then + gdbcmd=$2 + shift 2 +fi +prog=$1 +if [ -z "$prog" ]; then + echo "usage: openrepl-gdb [--gdb COMMAND] PROGRAM [GDB ARGUMENTS...]" >&2 + exit 2 +fi +shift +# env and qemu need a path, gdb takes either +case "$prog" in +*/*) ;; +*) prog=./$prog ;; +esac + +route=${OPENREPL_GDB_ROUTE:-auto} +why= +noaslr= +if [ "$route" = auto ]; then + if command -v openrepl-ptrace-probe >/dev/null 2>&1; then + why=$(openrepl-ptrace-probe 2>/dev/null) + case $? in + 0) route=ptrace ;; + 2) route=ptrace noaslr=1 ;; + *) + if QEMU_VERSION=1 /bin/true 2>/dev/null | grep -q '^qemu-'; then + route=emulator + elif [ "$(uname -m)" = x86_64 ] && command -v qemu-x86_64 >/dev/null 2>&1; then + route=qemu + else + route=none + fi + ;; + esac + else + route=ptrace # an image from before the probe: as it always was + fi +fi + +case $route in +ptrace) + args=() + # the host refuses to switch address randomization off: do not let gdb warn about it + [ -n "$noaslr" ] && args=(-iex "set disable-randomization off") + exec "$gdbcmd" "${args[@]}" "$prog" "$@" + ;; +emulator | qemu) ;; +*) + echo "Debugging is not available on this server: it does not allow ptrace${why:+ ($why)} and has no QEMU to debug through." >&2 + echo "Run still works." >&2 + exit 1 + ;; +esac + +# --- remote routes: the program runs under qemu and gdb connects to its port --- + +# qemu describes its registers to gdb in XML, so gdb must be built with XML support +# (expat). The gdb 8.1.1 the repo bundles (bin/gdb) is not, and on a host where it +# comes first on the PATH it fails with "Remote 'g' packet reply is too long" and the +# program runs away on its own. Take the first gdb on the PATH that has it. +gdb_with_xml() { + local candidate + for candidate in $(type -aP "$1" 2>/dev/null); do + if "$candidate" --configuration 2>/dev/null | grep -q -- '--with-expat'; then + echo "$candidate" + return 0 + fi + done + return 1 +} +case $gdbcmd in +rust-gdb) + # rust-gdb starts the gdb named by RUST_GDB + if RUST_GDB=$(gdb_with_xml gdb); then export RUST_GDB; else RUST_GDB=; fi + have_gdb=${RUST_GDB:+yes} + ;; +gdb) + if gdbcmd=$(gdb_with_xml gdb); then have_gdb=yes; else gdbcmd=gdb; have_gdb=; fi + ;; +*) have_gdb=yes ;; # some other gdb the caller chose +esac +if [ -z "$have_gdb" ]; then + echo "Debugging through QEMU needs a gdb with XML support, and this server has none (the bundled gdb 8.1.1 has not)." >&2 + echo "Install one (apt install gdb). Run still works." >&2 + exit 1 +fi + +port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])' 2>/dev/null) +port=${port:-$((20000 + RANDOM % 30000))} + +if [ "$route" = emulator ]; then + launch=(env "QEMU_GDB=$port" "$prog") +else + launch=(env "QEMU_GDB=$port" QEMU_LD_PREFIX=/ qemu-x86_64 "$prog") +fi + +init=$(mktemp) +qpid= +wpid= +cleanup() { + [ -n "$wpid" ] && kill "$wpid" 2>/dev/null + [ -n "$qpid" ] && kill "$qpid" 2>/dev/null + rm -f "$init" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 143' TERM +trap : INT # Ctrl-C is gdb's (it stops the program), not this script's + +cat >"$init" <<'EOF' +set confirm off +define run + continue +end +define r + continue +end +set confirm on +EOF + +# The program keeps the terminal for its input, as under a plain gdb. The shell +# ignores Ctrl-C for it (it is a background job), so Ctrl-C reaches only gdb. +have_tty= +if { : /dev/null; then + have_tty=1 + "${launch[@]}" /dev/null && sleep 1; do + { : /dev/null && continue + for child in $(pgrep -P $$); do + [ "$child" != "$BASHPID" ] && kill -KILL "$child" 2>/dev/null + done + exit + done + ) & + wpid=$! +fi + +listening() { + grep -Eqi ":$(printf '%04X' "$port") [0-9A-F]+:[0-9A-F]+ 0A " /proc/net/tcp /proc/net/tcp6 2>/dev/null +} +tries=0 +until listening || ! kill -0 "$qpid" 2>/dev/null || [ $tries -ge 150 ]; do + sleep 0.1 + tries=$((tries + 1)) +done +if ! listening; then + echo "The program could not be started under QEMU for debugging." >&2 + exit 1 +fi + +echo "This server cannot trace programs (ptrace), so the program runs under QEMU and gdb connects to it." +echo "It starts paused: set your breakpoints, then type c (or run) to start it." +"$gdbcmd" -q -iex "set sysroot /" -ex "target remote 127.0.0.1:$port" -x "$init" "$prog" "$@" diff --git a/scripts/openrepl-rappel b/scripts/openrepl-rappel new file mode 100755 index 00000000..39006ce7 --- /dev/null +++ b/scripts/openrepl-rappel @@ -0,0 +1,24 @@ +#!/bin/bash +# Installed as /usr/local/bin/rappel, in front of the real rappel (the assembly REPL). +# +# rappel runs each instruction you type in a child process that it traces with +# ptrace. On a host without ptrace (the Raspberry Pi workers: an amd64 image under +# qemu-user) it can only print two lines about a failed ptrace call and sit at a +# dead prompt. So check first, and say what is wrong. Where ptrace works this does +# nothing but start rappel. +# +# OPENREPL_RAPPEL_BIN names the real rappel (the install script puts it in /opt/gotty). + +real=${OPENREPL_RAPPEL_BIN:-/opt/gotty/rappel/bin/rappel} + +if command -v openrepl-ptrace-probe >/dev/null 2>&1; then + why=$(openrepl-ptrace-probe 2>/dev/null) + if [ $? -eq 1 ]; then + echo "The assembly REPL (rappel) runs your instructions in a traced process, which needs ptrace." + echo "This server does not allow ptrace${why:+ ($why)}, so the REPL cannot start here." + echo "Write your assembly in the editor and press Run or Debug instead." + exit 1 + fi +fi + +exec "$real" "$@" diff --git a/scripts/ptrace-probe.c b/scripts/ptrace-probe.c new file mode 100644 index 00000000..62901e8f --- /dev/null +++ b/scripts/ptrace-probe.c @@ -0,0 +1,67 @@ +/* Can a debugger work on this host? + * + * gdb needs two things from the kernel: to trace a child (PTRACE_TRACEME) and to + * read its registers (PTRACE_GETREGS). Some hosts allow neither (a kernel or a + * sandbox without ptrace: "Function not implemented"), some only the first (x86 + * code run through Rosetta: "Input/output error"). This does exactly those two + * things and says which one failed. + * + * exit 0 ptrace works (prints "ok") + * exit 2 ptrace works, but the address space randomization cannot be switched + * off, so gdb prints a warning (prints "ok-aslr") + * exit 1 ptrace does not work (prints why) + * + * It is built once, with the image (install_prerequisite.sh), and installed as + * /usr/local/bin/openrepl-ptrace-probe; openrepl-gdb runs it on every Debug. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + pid_t pid = fork(); + if (pid < 0) { + printf("fork: %s\n", strerror(errno)); + return 1; + } + if (pid == 0) { + if (ptrace(PTRACE_TRACEME, 0, 0, 0) != 0) _exit(errno == ENOSYS ? 101 : 100); + raise(SIGSTOP); + _exit(0); + } + + int st = 0; + if (waitpid(pid, &st, 0) < 0) { + printf("waitpid: %s\n", strerror(errno)); + return 1; + } + if (WIFEXITED(st)) { + printf("traceme: %s\n", WEXITSTATUS(st) == 101 ? "not implemented" : "not permitted"); + return 1; + } + + struct user_regs_struct regs; + int regs_ok = ptrace(PTRACE_GETREGS, pid, 0, ®s) == 0; + int why = errno; + kill(pid, SIGKILL); + waitpid(pid, &st, 0); + if (!regs_ok) { + printf("getregs: %s\n", strerror(why)); + return 1; + } + + int current = personality(0xffffffff); + if (current == -1 || personality(current | ADDR_NO_RANDOMIZE) == -1) { + printf("ok-aslr\n"); + return 2; + } + printf("ok\n"); + return 0; +} diff --git a/src/gateway/admin.go b/src/gateway/admin.go index 6de0ca52..3a9e0496 100644 --- a/src/gateway/admin.go +++ b/src/gateway/admin.go @@ -33,6 +33,7 @@ type WorkerInfo struct { Terminals int64 `json:"terminals,omitempty"` Languages []string `json:"languages,omitempty"` + Ptrace string `json:"ptrace,omitempty"` // what its host says about tracing programs: "ok", "ok-aslr", or why not RemoteAddr string `json:"remoteAddr,omitempty"` LastSeen string `json:"lastSeen,omitempty"` ConnectionID string `json:"connectionId,omitempty"` @@ -242,6 +243,7 @@ func (rt *Router) workerInfo(ts *tunnel.Server) []WorkerInfo { reg := tw.Info() info.Terminals = tw.Active() info.Languages = reg.Languages + info.Ptrace = reg.Ptrace info.RemoteAddr = tw.RemoteAddr() info.LastSeen = tw.LastSeen().UTC().Format(time.RFC3339) info.ConnectionID = tw.ConnectionID() @@ -249,9 +251,12 @@ func (rt *Router) workerInfo(ts *tunnel.Server) []WorkerInfo { info.Version = reg.Version info.Connected = tw.Connected().UTC().Format(time.RFC3339) info.ConfigRev = tw.ConfigRev() - info.ConfigCurrent = info.ConfigRev != 0 && info.ConfigRev == ts.ConfigRevision() + info.ConfigCurrent = info.ConfigRev != 0 && info.ConfigRev == ts.ConfigRevisionFor(tw) } } + if b.ID() == LocalID { + info.Ptrace = rt.localPtrace + } if rt.syncInfo != nil { if si, ok := rt.syncInfo(b.ID()); ok && si.Connected { info.Sync = &WorkerSync{Homes: si.Homes, ClockOffsetMs: si.ClockOffset.Milliseconds()} diff --git a/src/gateway/backend.go b/src/gateway/backend.go index 29f75734..a6f92a29 100644 --- a/src/gateway/backend.go +++ b/src/gateway/backend.go @@ -57,8 +57,25 @@ type LocalConfig struct { Weight int // Capacity reports the gateway's own load. Optional. Capacity func() (used, max int64) + // Ptrace is what the gateway's host answers about tracing programs (see + // tunnel.RegisterRequest.Ptrace), for the admin API. Optional. + Ptrace string } +// LanguageRule is what an admin decided for one language on one node. +type LanguageRule int + +const ( + // LanguageDefault follows what the node says it can run (--worker-languages). + LanguageDefault LanguageRule = iota + // LanguageOff refuses new terminals of the language on the node, even if it + // can run them. + LanguageOff + // LanguageOn lets the node take the language although it did not declare it: + // an admin installed it since the worker started. + LanguageOn +) + // LocalBackend runs the request with the existing OpenREPL handlers, with // no proxy hop in between. type LocalBackend struct { diff --git a/src/gateway/nodelanguage_test.go b/src/gateway/nodelanguage_test.go new file mode 100644 index 00000000..176a4332 --- /dev/null +++ b/src/gateway/nodelanguage_test.go @@ -0,0 +1,134 @@ +package gateway + +import ( + "net/http" + "strings" + "testing" + "time" +) + +// nodeRules is a Config.NodeLanguage that answers from a table: node -> rel -> rule. +func nodeRules(table map[string]map[string]LanguageRule) func(http.ResponseWriter, *http.Request, string, string) LanguageRule { + return func(_ http.ResponseWriter, _ *http.Request, node, rel string) LanguageRule { + return table[node][rel] + } +} + +func asTerminal(r *http.Request) { + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") +} + +func nodeRouter(w Backend, rules map[string]map[string]LanguageRule, refused *[]string) *Router { + rt := NewRouter(Config{ + Site: &recorder{}, + PathPrefix: "/", + Secret: []byte("secret"), + GuestTTL: time.Hour, + Terminal: terminalFor(map[string]string{"ws_python": "python", "ws_go": "gointerpreter"}), + NodeLanguage: nodeRules(rules), + RefuseTerminal: func(rw http.ResponseWriter, r *http.Request, reason string) bool { + *refused = append(*refused, reason) + rw.WriteHeader(http.StatusSwitchingProtocols) // stands in for the close with a reason + return true + }, + }) + rt.AddBackend(w) + return rt +} + +func TestALanguageSwitchedOffOnANodeIsRefusedWithAReasonThePageShows(t *testing.T) { + w := &named{id: "worker-1", weight: 10, langs: []string{"python", "gointerpreter"}} + var refused []string + rt := nodeRouter(w, map[string]map[string]LanguageRule{"worker-1": {"ws_go": LanguageOff}}, &refused) + + first := do(rt, "GET", "/ws_python", asTerminal) + c := cookieOf(t, first) + if first.Code != http.StatusOK || w.hits != 1 { + t.Fatalf("python -> %d, hits %d", first.Code, w.hits) + } + // the node declares go, but an admin switched it off: the page is told why + rec := do(rt, "GET", "/ws_go", func(r *http.Request) { asTerminal(r); r.AddCookie(c) }) + if rec.Code != http.StatusSwitchingProtocols || w.hits != 1 || len(refused) != 1 || !strings.Contains(refused[0], "switched off") { + t.Fatalf("go -> %d, hits %d, reasons %q", rec.Code, w.hits, refused) + } + // without a WebSocket there is nobody to tell with a close: a plain 503 with the same text + rec = do(rt, "GET", "/ws_go", func(r *http.Request) { r.AddCookie(c) }) + if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "switched off") || w.hits != 1 { + t.Fatalf("go without websocket -> %d %q", rec.Code, rec.Body.String()) + } + // files and the other language still work; a rule is for one language only + do(rt, "GET", "/ws_filebrowser", func(r *http.Request) { r.AddCookie(c) }) + do(rt, "GET", "/ws_python", func(r *http.Request) { asTerminal(r); r.AddCookie(c) }) + if w.hits != 3 { + t.Fatalf("hits = %d, want 3", w.hits) + } +} + +func TestALanguageSwitchedOnLetsInWhatTheNodeDidNotDeclare(t *testing.T) { + // started with --worker-languages python; go was installed later + w := &named{id: "worker-1", weight: 10, langs: []string{"python"}} + var refused []string + rules := map[string]map[string]LanguageRule{} + rt := nodeRouter(w, rules, &refused) + + first := do(rt, "GET", "/ws_python", asTerminal) + c := cookieOf(t, first) + rec := do(rt, "GET", "/ws_go", func(r *http.Request) { asTerminal(r); r.AddCookie(c) }) + if w.hits != 1 || len(refused) != 1 || !strings.Contains(refused[0], "not available") { + t.Fatalf("undeclared go, no rule -> %d, hits %d, reasons %q", rec.Code, w.hits, refused) + } + + rules["worker-1"] = map[string]LanguageRule{"ws_go": LanguageOn} + rec = do(rt, "GET", "/ws_go", func(r *http.Request) { asTerminal(r); r.AddCookie(c) }) + if rec.Code != http.StatusOK || w.hits != 2 || len(refused) != 1 { + t.Fatalf("go switched on -> %d, hits %d, reasons %q", rec.Code, w.hits, refused) + } + + // back to the default: refused again; a rule changes the next terminal at once + rules["worker-1"] = nil + do(rt, "GET", "/ws_go", func(r *http.Request) { asTerminal(r); r.AddCookie(c) }) + if w.hits != 2 || len(refused) != 2 { + t.Fatalf("go back to default: hits %d, reasons %q", w.hits, refused) + } +} + +func TestTheGatewaysOwnNodeCanHaveALanguageSwitchedOff(t *testing.T) { + site := &recorder{} + var refused []string + rt := NewRouter(Config{ + Site: site, PathPrefix: "/", Secret: []byte("secret"), GuestTTL: time.Hour, + Local: LocalConfig{Weight: 10}, + Terminal: terminalFor(map[string]string{"ws_python": "python"}), + NodeLanguage: nodeRules(map[string]map[string]LanguageRule{LocalID: {"ws_python": LanguageOff}}), + RefuseTerminal: func(rw http.ResponseWriter, r *http.Request, reason string) bool { + refused = append(refused, reason) + rw.WriteHeader(http.StatusSwitchingProtocols) + return true + }, + }) + rec := do(rt, "GET", "/ws_python", asTerminal) + if rec.Code != http.StatusSwitchingProtocols || len(refused) != 1 || len(site.paths) != 0 { + t.Fatalf("local python -> %d, reasons %q, site %v", rec.Code, refused, site.paths) + } +} + +func TestNodeOfOnlyLooksAtTheRequestersSession(t *testing.T) { + w := &named{id: "worker-1", weight: 10, langs: []string{"python"}} + var refused []string + rt := nodeRouter(w, nil, &refused) + + if got := rt.NodeOf(requestWith(&http.Cookie{Name: "nobody", Value: "x"})); got != "" { + t.Fatalf("a visitor without a session is on %q", got) + } + if rt.Registry().Len() != 0 { + t.Fatal("looking up a node created a session") + } + c := cookieOf(t, do(rt, "GET", "/ws_python", asTerminal)) + if got := rt.NodeOf(requestWith(c)); got != "worker-1" { + t.Fatalf("the session's node = %q", got) + } + if rt.Registry().Len() != 1 { + t.Fatalf("sessions = %d", rt.Registry().Len()) + } +} diff --git a/src/gateway/notice.go b/src/gateway/notice.go index 3a307cc0..da5535fc 100644 --- a/src/gateway/notice.go +++ b/src/gateway/notice.go @@ -75,6 +75,15 @@ func (rt *Router) refuse(w http.ResponseWriter, r *http.Request, msg string, ret http.Error(w, msg, http.StatusServiceUnavailable) } +// refuseTerminal refuses a terminal for a reason the user is told: a WebSocket +// is closed with it (Config.RefuseTerminal), anything else gets a 503. +func (rt *Router) refuseTerminal(w http.ResponseWriter, r *http.Request, reason string) { + if rt.refuseTerm != nil && isWebSocketRequest(r) && rt.refuseTerm(w, r, reason) { + return + } + http.Error(w, reason, http.StatusServiceUnavailable) +} + // refuseError is refuse for an error from placing a session. func (rt *Router) refuseError(w http.ResponseWriter, r *http.Request, err error, code int) { var re *retryError diff --git a/src/gateway/router.go b/src/gateway/router.go index 22f1d248..0c901e12 100644 --- a/src/gateway/router.go +++ b/src/gateway/router.go @@ -100,6 +100,15 @@ type Config struct { // placed again, and reports whether it handled the response. The page // shows a countdown. Optional: without it the answer is a plain 503. TerminalNotice func(w http.ResponseWriter, r *http.Request, retryIn time.Duration) bool + // NodeLanguage returns the rule an admin set for the language of the + // terminal route rel (without the prefix) on a node. It may return + // LanguageDefault for the requester, for example an admin, who is let + // through a switched off language. Optional: no rules. + NodeLanguage func(w http.ResponseWriter, r *http.Request, node, rel string) LanguageRule + // RefuseTerminal answers a terminal's WebSocket request by closing it with a + // reason the page shows. It reports whether it handled the response. + // Optional: without it the answer is a plain 503. + RefuseTerminal func(w http.ResponseWriter, r *http.Request, reason string) bool // UserLabel names a signed-in user in the admin API, e.g. by email // address. Optional: without it only the user id is shown. UserLabel func(uid string) string @@ -137,6 +146,10 @@ type Router struct { homeOf func(Identity) string notice func(http.ResponseWriter, *http.Request, time.Duration) bool + nodeLanguage func(http.ResponseWriter, *http.Request, string, string) LanguageRule + refuseTerm func(http.ResponseWriter, *http.Request, string) bool + localPtrace string + userLabel func(uid string) string syncInfo func(backendID string) (SyncInfo, bool) @@ -205,6 +218,10 @@ func NewRouter(cfg Config) *Router { homeOf: cfg.HomeOf, notice: cfg.TerminalNotice, + nodeLanguage: cfg.NodeLanguage, + refuseTerm: cfg.RefuseTerminal, + localPtrace: cfg.Local.Ptrace, + userLabel: cfg.UserLabel, syncInfo: cfg.SyncInfo, terms: make(map[string]map[uint64]context.CancelFunc), @@ -433,12 +450,22 @@ func (rt *Router) execute(w http.ResponseWriter, r *http.Request) { return } if rt.terminal != nil { - if command, weight, ok := rt.terminal(rt.rel(r.URL.Path)); ok { - if !b.HasLanguage(command) { - http.Error(w, "this language is not available on your execution node", http.StatusServiceUnavailable) - return + if rel := rt.rel(r.URL.Path); true { + if command, weight, ok := rt.terminal(rel); ok { + rule := LanguageDefault + if rt.nodeLanguage != nil { + rule = rt.nodeLanguage(w, r, b.ID(), rel) + } + switch { + case rule == LanguageOff: + rt.refuseTerminal(w, r, "This language is switched off on your execution node for now. Please pick another one.") + return + case rule != LanguageOn && !b.HasLanguage(command): + rt.refuseTerminal(w, r, "this language is not available on your execution node") + return + } + r = withSessionWeight(r, weight) } - r = withSessionWeight(r, weight) } } if b.ID() != LocalID { @@ -483,6 +510,26 @@ func BackendOf(r *http.Request) string { return b } +// NodeOf returns the backend the requester's session is assigned to, or "" when +// it has none yet. It only looks: no session is created or touched. The page's +// settings script uses it to hide the languages that are off on the visitor's +// node. +func (rt *Router) NodeOf(r *http.Request) string { + key := "" + if uid := rt.uid(r); uid != "" { + key = "u:" + uid + } else if guest := rt.affinity.GuestID(r); guest != "" { + key = "g:" + guest + } + if key == "" { + return "" + } + if ec, found := rt.registry.Resolve(key); found { + return ec.BackendID + } + return "" +} + // routed returns the backend that owns the jid or homedir a request names. // A fork link and a shared session's file requests come from another // session, so they follow the key instead of the requester's own affinity. diff --git a/src/resources/about.html b/src/resources/about.html index a68a126b..080ed758 100644 --- a/src/resources/about.html +++ b/src/resources/about.html @@ -63,7 +63,7 @@

About OpenREPL

What you can do

    -
  • Run and debug your code, with gdb for C, C++ and Rust.
  • +
  • Run and debug your code, with gdb for C, C++, Go, Rust and assembly.
  • Share a live link so others can watch and type along, or make a code link to a copy of your code.
  • Fork a terminal to run a server and a client side by side.
  • Keep files in your workspace, and sign in to keep them between visits.
  • diff --git a/src/resources/css/admin.css b/src/resources/css/admin.css index 583aadc6..e22071cd 100644 --- a/src/resources/css/admin.css +++ b/src/resources/css/admin.css @@ -509,3 +509,12 @@ dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top .signed-out p { color: var(--muted); margin: 0 0 16px; } .loading { padding: 40px; text-align: center; color: var(--muted); } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* the Languages card of a node: the name and the choice on one line, what it means below */ +.node-langs { display: grid; gap: 6px; } +.node-lang { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, 48%); gap: 6px 10px; align-items: center; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; } +.node-lang .what { min-width: 0; } +.node-lang .what b { display: block; font-weight: 600; overflow-wrap: anywhere; } +.node-lang .what .sub { margin: 0; overflow-wrap: anywhere; } +.node-lang select.field { width: 100%; } +.node-lang .status { grid-column: 1 / -1; } diff --git a/src/resources/index.html b/src/resources/index.html index 8422f08e..89a2efb6 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -575,7 +575,7 @@

    Everything you need to go from idea to output

    Run and debug in one place

    -

    Press Run to compile and execute your file. Choose Debug from the Run menu to step through C, C++ and Rust with gdb.

    +

    Press Run to compile and execute your file. Choose Debug from the Run menu to step through C, C++, Go, Rust and assembly with gdb.

    (gdb) break main.c:5
     Breakpoint 1 at main.c, line 5.
     (gdb) run
    @@ -670,6 +670,7 @@ 

    Questions, answered

    Can Genie explain or fix just part of my code?

    Yes. Select the code in the editor and right-click it: Explain, Fix problems, Add comments or Write tests. Changes show as a diff you accept or reject.

    Can Genie help me practise without giving the answer?

    Yes. On the Practice page the coach gives up to three hints per question, reviews your solution and states its complexity, and never writes the code for you.

    Can Genie manage my files?

    Yes, in Agent mode. It can open a file in the editor, create, copy, move, rename and delete files and folders in your Files panel. Renaming, moving and deleting are asked about every time.

    +

    Why does Debug start paused?

    On some servers programs cannot be traced the usual way, so Debug runs yours under QEMU and gdb connects to it. It waits at the first instruction: set your breakpoints, then type c or run. Ctrl+C stops it again.

    Can I run it myself?

    Yes. Pull the Docker image and run it on your own machine or server. The README on GitHub has the steps.

    diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js index 8c41bf55..1d1b5d78 100644 --- a/src/resources/js/admin.js +++ b/src/resources/js/admin.js @@ -622,9 +622,74 @@ }); } + // ptraceNote says what a node's host answered about tracing programs: it is + // what Debug and the assembly REPL (rappel) need. "" means it is not known. + function ptraceBad(answer) { return !!answer && answer.indexOf('ok') !== 0; } + + // languagesCard is the Languages card of a node's drawer: for each language what + // the node says, what an admin decided, and a choice. It keeps itself (the + // drawer around it is redrawn every few seconds, which would close a menu). + function languagesCard(node) { + var box = h('div'), where = node === 'local' ? 'the gateway' : node; + var intro = h('p', { class: 'sub', text: 'Default is what the worker declares. Off refuses new terminals of a language the worker can run; On takes one it did not declare, for example one you installed after starting it. Open terminals keep running.' }); + var note = h('div'), list = h('div', { class: 'node-langs' }); + box.appendChild(intro); box.appendChild(note); box.appendChild(list); + + function status(r, ptrace) { + var pills = []; + if (r.siteOff) pills.push(pill('off for the whole site', 'info')); + else if (r.takes) pills.push(pill('takes terminals', 'ok')); + else if (r.rule === 'off') pills.push(pill('switched off here', 'fail')); + else if (!r.declared) pills.push(pill('not installed here', 'plain')); + else pills.push(pill('node offline', 'plain')); + if (r.rule === 'on' && !r.declared) pills.push(pill('on, not declared by the worker', 'info')); + if (r.needsPtrace && ptraceBad(ptrace)) pills.push(pill('needs ptrace', 'warn')); + return h('div', { class: 'actions status' }, pills); + } + + function draw(reply) { + note.textContent = ''; + if (ptraceBad(reply.ptrace)) { + note.appendChild(h('p', { class: 'callout', text: 'This node\'s host cannot trace programs (' + reply.ptrace + '). The assembly REPL (rappel) cannot work here. Debug still works: it runs the program under QEMU and starts paused.' })); + } else if (reply.ptrace) { + note.appendChild(h('p', { class: 'sub' }, pill('ptrace works', 'ok'))); + } + if (!reply.online) note.appendChild(h('p', { class: 'sub', text: 'This node is offline, so its own list of languages is not known. Your choices are kept.' })); + list.textContent = ''; + reply.languages.forEach(function (r) { + var sel = h('select', { class: 'field', 'aria-label': r.name + ' on ' + where, + onchange: function () { set(r, sel); } }, + h('option', { value: 'default', text: 'Default (' + (r.declared ? 'on' : 'not installed') + ')' }), + h('option', { value: 'off', text: 'Off' }), + h('option', { value: 'on', text: 'On' })); + sel.value = r.rule; + list.appendChild(h('div', { class: 'node-lang' }, + h('div', { class: 'what' }, h('b', { text: r.name }), h('span', { class: 'sub', text: r.command || '' })), + sel, status(r, reply.ptrace))); + }); + } + + function load() { + return api.get('admin/workers/' + encodeURIComponent(node) + '/languages').then(draw, fail); + } + + function set(r, sel) { + var rule = sel.value; + sel.disabled = true; + return api.post('admin/workers/' + encodeURIComponent(node) + '/languages', { language: r.value, rule: rule }).then(function (reply) { + toast(r.name + (rule === 'off' ? ' is switched off on ' : rule === 'on' ? ' is switched on for ' : ' follows the list of ') + where + '.'); + draw(reply); + }, function (err) { fail(err); return load(); }); + } + + load(); + return { el: box, load: load }; + } + function viewWorkers() { var root = h('div'), box = h('div'), addBox = h('div'); var data = null, sort = { key: 'id', dir: 'asc' }, openId = null; + var topBox = null; // the part of the open drawer that is redrawn; the Languages card below it is not root.appendChild(pageHead('Workers', 'The gateway and the workers connected to it. New sessions are shared out in proportion to the weights.')); root.appendChild(box); root.appendChild(addBox); @@ -632,7 +697,8 @@ var cols = [ { key: 'id', label: 'Node', sort: function (w) { return w.id; }, cell: function (w) { return h('div', null, h('b', { text: w.id === 'local' ? 'gateway (local)' : w.id }), - h('div', { class: 'sub', text: w.id === 'local' ? 'runs sessions itself' : (w.version || '') })); } }, + h('div', { class: 'sub', text: w.id === 'local' ? 'runs sessions itself' : (w.version || '') }), + ptraceBad(w.ptrace) ? pill('no ptrace', 'warn') : null); } }, { key: 'state', label: 'State', sort: function (w) { return w.state; }, cell: function (w) { return pill(w.state.toLowerCase(), stateTone(w.state)); } }, { key: 'weight', label: 'Weight', num: true, sort: function (w) { return w.weight; }, cell: function (w) { return String(w.weight); } }, { key: 'mem', label: 'Memory', sort: function (w) { return w.usedMB; }, cell: function (w) { return usageBar(w.usedMB, w.maxMB); } }, @@ -667,13 +733,15 @@ function openWorker(id) { var w = data.workers.filter(function (x) { return x.id === id; })[0]; if (!w) return; - openDrawer(id, id === 'local' ? 'gateway (local)' : id, h('div'), function () { openId = null; }); + topBox = h('div'); + var langs = h('div', null, h('h3', { text: 'Languages on this node' }), languagesCard(id).el); + openDrawer(id, id === 'local' ? 'gateway (local)' : id, h('div', null, topBox, langs), function () { openId = null; topBox = null; }); openId = id; // after openDrawer: it closes the previous drawer, whose onclose clears this fillDrawer(w); } function fillDrawer(w) { - var d = drawer.content; + var d = topBox; d.textContent = ''; var offset = w.sync ? w.sync.clockOffsetMs : null; d.appendChild(h('div', { class: 'actions' }, pill(w.state.toLowerCase(), stateTone(w.state)), @@ -690,7 +758,8 @@ w.connected ? ['Connected', h('span', null, when(w.connected), ' ', h('span', { class: 'sub', text: '(' + localTime(w.connected) + ')' }))] : null, w.lastSeen ? ['Last heard from', when(w.lastSeen)] : null, w.id !== 'local' && w.connectionId ? ['Site rules', w.configCurrent ? pill('up to date', 'ok') : pill(w.configRev ? 'out of date (revision ' + w.configRev + ')' : 'not received yet', 'warn')] : null, - ['Languages', w.languages && w.languages.length ? h('span', { class: 'actions' }, w.languages.map(function (l) { return pill(l, 'plain'); })) : (w.id === 'local' ? 'all' : 'all it was started with')] + ['Languages it declares', w.languages && w.languages.length ? h('span', { class: 'actions' }, w.languages.map(function (l) { return pill(l, 'plain'); })) : (w.id === 'local' ? 'all' : 'all it was started with')], + w.ptrace ? ['Tracing programs', ptraceBad(w.ptrace) ? pill('no: ' + w.ptrace, 'warn') : pill('works', 'ok')] : null ])); d.appendChild(h('h3', { text: 'Load' })); d.appendChild(kv([ diff --git a/src/resources/knowledge/run-and-debug.md b/src/resources/knowledge/run-and-debug.md index a31955f7..754f00cc 100644 --- a/src/resources/knowledge/run-and-debug.md +++ b/src/resources/knowledge/run-and-debug.md @@ -1,6 +1,6 @@ --- title: Running and debugging code -keywords: run, execute, compile, debug, gdb, breakpoint, ctrl enter, arguments, environment variables, env, editor, output +keywords: run, execute, compile, debug, gdb, breakpoint, qemu, paused, ctrl enter, arguments, environment variables, env, editor, output link: /about.html --- -Write code in the editor and press Run, or Ctrl+Enter, to compile and execute it in the terminal. Debug, or Shift+Ctrl+Enter, steps through C, C++ and Rust code with gdb. Program arguments and environment variables used by Run and Debug can be set in the editor, separated by spaces. You can also type directly in the terminal, which works like any REPL of that language. +Write code in the editor and press Run, or Ctrl+Enter, to compile and execute it in the terminal. Debug, or Shift+Ctrl+Enter, steps through C, C++, Go, Rust and assembly code with gdb. Program arguments and environment variables used by Run and Debug can be set in the editor, separated by spaces. You can also type directly in the terminal, which works like any REPL of that language. On a server that cannot trace programs, the program runs under QEMU and gdb connects to it: it starts paused, so set breakpoints and type c or run to start it. The assembly REPL needs the same tracing, so there use the editor's Run or Debug. diff --git a/src/resources/meta/demos.xml b/src/resources/meta/demos.xml index 017795d2..a0ab6897 100644 --- a/src/resources/meta/demos.xml +++ b/src/resources/meta/demos.xml @@ -198,7 +198,7 @@ if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE" ]; then fi if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE.o" ]; then if $debug; then - gdb "$WORKDIR/$FILE".o $@; + openrepl-gdb "$WORKDIR/$FILE".o $@; else #second quoate with escape, as -c option takes a string argument. $SHELL -c "\"$WORKDIR/$FILE\".o $@"; @@ -325,7 +325,7 @@ if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE" ]; then if $debug; then go build -gcflags=all="-N -l" -o "$FILE".o "$FILE" $@; echo "add-auto-load-safe-path $GOROOT/src/runtime/runtime-gdb.py" > $HOME/.gdbinit - gdb "$FILE".o -d $GOROOT $@; + openrepl-gdb "$FILE".o -d $GOROOT $@; else $COMPILER run "$WORKDIR/$FILE" $@; fi @@ -464,7 +464,7 @@ if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE" ]; then if $debug; then go build -gcflags=all="-N -l" -o "$FILE".o $FILE $@; echo "add-auto-load-safe-path $GOROOT/src/runtime/runtime-gdb.py" > $HOME/.gdbinit - gdb "$FILE".o -d "$GOROOT" $@; + openrepl-gdb "$FILE".o -d "$GOROOT" $@; else $COMPILER run $FILE $@; fi @@ -2242,7 +2242,7 @@ cd $WORKDIR; if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE" ]; then if $debug; then $COMPILER -g -o "$FILE".o $FILE $@; - rust-gdb "$FILE".o $@; + openrepl-gdb --gdb rust-gdb "$FILE".o $@; else $COMPILER $FILE -o "$FILE".o; "$WORKDIR/$FILE".o $@; @@ -2592,16 +2592,16 @@ section .text _start: ; write Hello, World! to stdout - mov eax, 4 ; sys_write - mov ebx, 1 ; file descriptor 1 (stdout) - mov ecx, hello ; message to write - mov edx, 13 ; message length - int 0x80 ; call kernel + mov rax, 1 ; sys_write + mov rdi, 1 ; file descriptor 1 (stdout) + mov rsi, hello ; message to write + mov rdx, 13 ; message length + syscall ; call kernel ; exit the program - mov eax, 1 ; sys_exit - xor ebx, ebx ; exit code 0 - int 0x80 ; call kernel + mov rax, 60 ; sys_exit + xor rdi, rdi ; exit code 0 + syscall ; call kernel COMPILER=nasm @@ -2627,7 +2627,7 @@ fi if [ $? -eq 0 ] && [ -f "$WORKDIR/$FILE.o" ]; then ld -o "$WORKDIR/$FILE".out "$WORKDIR/$FILE".o if $debug; then - gdb "$WORKDIR/$FILE".out $@; + openrepl-gdb "$WORKDIR/$FILE".out $@; else #second quoate with escape, as -c option takes a string argument. $SHELL -c "\"$WORKDIR/$FILE\".out $@"; diff --git a/src/server/admin_node_languages.go b/src/server/admin_node_languages.go new file mode 100644 index 00000000..638e2504 --- /dev/null +++ b/src/server/admin_node_languages.go @@ -0,0 +1,194 @@ +package server + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "gateway" +) + +// The Languages card of a node in the dashboard: +// +// GET /admin/workers//languages the languages with what each node says and what an admin decided +// POST /admin/workers//languages {"language": "rappel", "rule": "default" | "off" | "on"} +// +// is a worker id, or "local" for the gateway itself. Both run behind +// adminAPI. The rules are saved with the site settings (SiteSettings.NodeLanguages). + +// nodeLanguageRow is one language of a node's card. +type nodeLanguageRow struct { + Value string `json:"value"` // the picker's name for it + Name string `json:"name"` // what the picker calls it + Command string `json:"command"` // the REPL it starts + // Declared is whether the node says it can run it (always true for a node + // that did not list its languages, and for one that is offline: its list is + // not known then). + Declared bool `json:"declared"` + // Rule is what the admin decided: "default", "off" or "on". + Rule string `json:"rule"` + // SiteOff means the whole site has it switched off (Settings page). + SiteOff bool `json:"siteOff"` + // Takes is whether the node takes new terminals of it now. + Takes bool `json:"takes"` + // NeedsPtrace is true for a language that cannot work without ptrace. + NeedsPtrace bool `json:"needsPtrace,omitempty"` +} + +type nodeLanguagesReply struct { + Node string `json:"node"` + Online bool `json:"online"` + // Ptrace is what the node's host answered about tracing programs: "ok", + // "ok-aslr", or why not; empty when unknown. + Ptrace string `json:"ptrace,omitempty"` + Languages []nodeLanguageRow `json:"languages"` +} + +// nodeInfo finds what is known of a node: whether it is connected, whether it +// can run a REPL command, and what its host says about tracing programs. +func (server *Server) nodeInfo(node string) (online bool, canRun func(string) bool, ptrace string) { + router := server.admin.router + if node == gateway.LocalID { + ptrace = localPtrace + } else if ts := server.admin.tunnel; ts != nil { + if tw := ts.Worker(node); tw != nil { + ptrace = tw.Info().Ptrace + } + } + if router == nil { + return false, nil, ptrace + } + if b, found := router.Backend(node); found && b.State() != gateway.Offline { + return true, b.HasLanguage, ptrace + } + return false, nil, ptrace +} + +// nodeKnown reports whether the node exists (it is connected, or an admin has +// choices saved for it, which they may want to clear). +func (server *Server) nodeKnown(node string) bool { + if node == "local" { + return true + } + if server.admin.router != nil { + if _, found := server.admin.router.Backend(node); found { + return true + } + } + _, saved := GetSiteSettings().NodeLanguages[node] + return saved +} + +func (server *Server) nodeLanguagesReply(node string) nodeLanguagesReply { + s := GetSiteSettings() + online, canRun, ptrace := server.nodeInfo(node) + reply := nodeLanguagesReply{Node: node, Online: online, Ptrace: ptrace, Languages: []nodeLanguageRow{}} + for _, l := range languageChoices() { + command, routed := server.nodeCommand(l.Value) + if !routed { + continue // nothing a node runs (JavaScript is a console in the browser) + } + declared := true + if canRun != nil { + declared = canRun(command) + } + rule := s.NodeRule(node, l.Value) + row := nodeLanguageRow{ + Value: l.Value, Name: l.Name, Command: command, + Declared: declared, Rule: ruleName(rule), SiteOff: s.LanguageDisabled(l.Value), + NeedsPtrace: languagesNeedingPtrace[l.Value], + } + row.Takes = online && !row.SiteOff && (rule == gateway.LanguageOn || (rule == gateway.LanguageDefault && declared)) + reply.Languages = append(reply.Languages, row) + } + return reply +} + +// handleNodeLanguages serves the two routes above. +func (server *Server) handleNodeLanguages(w http.ResponseWriter, r *http.Request, node string) { + if node != "local" && !nodeIDPattern.MatchString(node) { + adminError(w, http.StatusBadRequest, "That is not a node id.") + return + } + if !server.nodeKnown(node) { + adminError(w, http.StatusNotFound, "No such node.") + return + } + switch r.Method { + case http.MethodGet: + adminJSON(w, http.StatusOK, server.nodeLanguagesReply(node)) + case http.MethodPost: + r.Body = http.MaxBytesReader(w, r.Body, 4*1024) + var in struct { + Language string `json:"language"` + Rule string `json:"rule"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + adminError(w, http.StatusBadRequest, "The request was not valid JSON.") + return + } + lang := strings.ToLower(strings.TrimSpace(in.Language)) + if _, routed := server.nodeCommand(lang); !routed { + adminError(w, http.StatusBadRequest, "That language has no terminal to switch.") + return + } + var rule gateway.LanguageRule + switch in.Rule { + case "default": + rule = gateway.LanguageDefault + case "off": + rule = gateway.LanguageOff + case "on": + rule = gateway.LanguageOn + default: + adminError(w, http.StatusBadRequest, `The rule must be "default", "off" or "on".`) + return + } + if err := server.setNodeRule(r, node, lang, rule); err != nil { + switch err { + case errSettingsConflict: + adminError(w, http.StatusConflict, "Someone else changed the settings at the same moment. Try again.") + case errSettingsUnavailable: + adminError(w, http.StatusServiceUnavailable, "The settings store is not reachable right now, so nothing was saved. Try again in a moment.") + default: + log.Println("saving a node's languages failed: ", err) + adminError(w, http.StatusBadRequest, err.Error()) + } + return + } + adminJSON(w, http.StatusOK, server.nodeLanguagesReply(node)) + default: + w.Header().Set("Allow", "GET, POST") + adminError(w, http.StatusMethodNotAllowed, "Use GET or POST.") + } +} + +// setNodeRule saves one rule. It reads, changes and writes the whole settings, so +// a store that moved on in between (MongoDB, another admin) is retried. +func (server *Server) setNodeRule(r *http.Request, node, lang string, rule gateway.LanguageRule) error { + var err error + for try := 0; try < 3; try++ { + before := GetSiteSettings() + after := before.withNodeRule(node, lang, rule) + if err = SaveSiteSettings(after, currentSettingsVersion()); err == errSettingsConflict { + continue + } + if err != nil { + return err + } + for _, change := range nodeLanguageChanges(before, GetSiteSettings()) { + server.audit(r, "settings", change) + } + return nil + } + return err +} + +// currentSettingsVersion is the version of the settings last read from the store +// (0 for the file store). +func currentSettingsVersion() int64 { + settingsMu.Lock() + defer settingsMu.Unlock() + return settingsVersion +} diff --git a/src/server/gateway.go b/src/server/gateway.go index 6e108721..b443a330 100644 --- a/src/server/gateway.go +++ b/src/server/gateway.go @@ -36,6 +36,7 @@ func (server *Server) wrapGateway(ctx context.Context, site http.Handler, pathPr // The sync manager is created below, once the router exists; the router's // hooks reach it through this variable. var syncMgr *wsync.Manager + localPtrace = probeHost("gateway") // asked once: the dashboard shows it, and tells which languages cannot work cfg := gateway.Config{ Site: site, PathPrefix: pathPrefix, @@ -57,6 +58,7 @@ func (server *Server) wrapGateway(ctx context.Context, site http.Handler, pathPr Secret: cookie.SECRET_KEY, GuestTTL: utils.DEADLINE_MINUTES * time.Minute, Local: gateway.LocalConfig{ + Ptrace: localPtrace, Weight: server.options.LocalWeight, Capacity: func() (int64, int64) { return int64(counter.weight()), int64(server.options.MaxConnection) @@ -84,6 +86,8 @@ func (server *Server) wrapGateway(ctx context.Context, site http.Handler, pathPr } cfg.TerminalNotice = server.terminalNotice + cfg.NodeLanguage = server.nodeLanguageRule + cfg.RefuseTerminal = server.refuseTerminal cfg.UserLabel = func(uid string) string { if up, err := user.FetchUserProfileData(uid); err == nil { return up.Email @@ -160,6 +164,7 @@ func (server *Server) wrapGateway(ctx context.Context, site http.Handler, pathPr } router := gateway.NewRouter(cfg) + server.bindNodeLanguages(router) server.admin.router = router server.routes = newRouteTracker(localRoutes{router.Routes()}) if server.options.LocalWeight <= 0 { @@ -183,6 +188,7 @@ func (server *Server) wrapGateway(ctx context.Context, site http.Handler, pathPr AuthToken: func() string { return server.options.Credential }, Secret: utils.Secret, Config: server.gatewayWorkerConfig, + WorkerConfig: server.gatewayWorkerConfigFor, } router.BindTunnel(&tcfg) if server.options.WorkspaceSync { @@ -367,6 +373,13 @@ func (server *Server) handleGatewayAdmin(w http.ResponseWriter, r *http.Request) http.NotFound(w, r) return } + // The languages of a node are saved with the site settings, which the + // gateway package does not know: /admin/workers//languages is served here. + rel := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, server.admin.prefix), "admin/"), "/") + if parts := strings.Split(rel, "/"); len(parts) == 3 && parts[0] == "workers" && parts[2] == "languages" { + server.handleNodeLanguages(w, r, parts[1]) + return + } server.gatewayAdmin.ServeHTTP(w, r) } diff --git a/src/server/knowledge_test.go b/src/server/knowledge_test.go index a34e17f2..8631611a 100644 --- a/src/server/knowledge_test.go +++ b/src/server/knowledge_test.go @@ -129,6 +129,8 @@ func TestSearchFindsTheRightNoteForQuestionsAboutTheSite(t *testing.T) { {"which languages are supported", "languages"}, {"is there a rust repl", "languages"}, {"how do I debug c code with gdb", "run-and-debug"}, + {"why does debug start paused and say qemu", "run-and-debug"}, + {"can I debug go or assembly with gdb", "run-and-debug"}, {"how do I upload a file", "files-and-workspace"}, {"what happens to my files after an hour", "files-and-workspace"}, {"do I need an account", "sign-in-and-accounts"}, diff --git a/src/server/node_languages.go b/src/server/node_languages.go new file mode 100644 index 00000000..41c62737 --- /dev/null +++ b/src/server/node_languages.go @@ -0,0 +1,402 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "log" + "net/http" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "gateway" + "tunnel" +) + +// Languages per node. +// +// The site-wide switch (SiteSettings.DisabledLanguages) turns a language off for +// everybody. A node (the gateway itself, "local", or a worker) has its own +// answer on top of that, in two parts: +// +// - what the node says it can run: a worker started with --worker-languages +// lists its REPL commands, any other node runs everything; +// - what an admin decided for one language on that node +// (SiteSettings.NodeLanguages): off (refuse new terminals of it, although the +// node can run it) or on (take it, although the node did not declare it, +// because it was installed since the worker started). +// +// Only new terminals are refused; open ones keep running. The router enforces +// the answer where it picks the backend of a terminal (gateway.Config.NodeLanguage); +// the page hides the languages that are off on the visitor's node (settings.js); +// a worker also gets its own list in its WorkerConfig, for the people who open +// the worker's own port. + +// maxNodeLanguageNodes bounds how many nodes can have choices saved. +const maxNodeLanguageNodes = 200 + +// nodeIDPattern is the id of a node: a worker id (tunnel) or "local". +var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +// NodeLanguages is what an admin decided about the languages of one node. +// Languages are the picker's values (python, cpp, evcxr, ...). +type NodeLanguages struct { + // Off are refused on the node although it can run them. + Off []string `json:"off,omitempty"` + // On are taken on the node although it did not declare them. + On []string `json:"on,omitempty"` +} + +func cleanLanguageList(in []string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, l := range in { + l = strings.ToLower(strings.TrimSpace(l)) + if l == "" || seen[l] { + continue + } + if !languagePattern.MatchString(l) { + return nil, fmt.Errorf("%q is not a language name", l) + } + seen[l] = true + out = append(out, l) + } + if len(out) > maxDisabledLangs { + return nil, fmt.Errorf("at most %d languages can be set for one node", maxDisabledLangs) + } + sort.Strings(out) + return out, nil +} + +// normalizeNodeLanguages cleans the choices of an untrusted source: valid node +// ids and language names, sorted, no language both on and off, no empty entry. +func normalizeNodeLanguages(in map[string]NodeLanguages) (map[string]NodeLanguages, error) { + if len(in) > maxNodeLanguageNodes { + return nil, fmt.Errorf("choices can be saved for at most %d nodes", maxNodeLanguageNodes) + } + out := map[string]NodeLanguages{} + for id, nl := range in { + if id != "local" && !nodeIDPattern.MatchString(id) { + return nil, fmt.Errorf("%q is not a node id", id) + } + off, err := cleanLanguageList(nl.Off) + if err != nil { + return nil, err + } + on, err := cleanLanguageList(nl.On) + if err != nil { + return nil, err + } + for _, l := range off { + for _, o := range on { + if l == o { + return nil, fmt.Errorf("%s cannot be both on and off for node %s", l, id) + } + } + } + if len(off) > 0 || len(on) > 0 { + out[id] = NodeLanguages{Off: off, On: on} + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +func without(list []string, v string) []string { + var out []string + for _, x := range list { + if x != v { + out = append(out, x) + } + } + return out +} + +// NodeRule is what the admin decided for the language on the node. +func (s SiteSettings) NodeRule(node, lang string) gateway.LanguageRule { + nl := s.NodeLanguages[node] + switch { + case contains(nl.Off, lang): + return gateway.LanguageOff + case contains(nl.On, lang): + return gateway.LanguageOn + } + return gateway.LanguageDefault +} + +// withNodeRule returns the settings with the rule set; s is not changed. +func (s SiteSettings) withNodeRule(node, lang string, rule gateway.LanguageRule) SiteSettings { + nodes := map[string]NodeLanguages{} + for id, nl := range s.NodeLanguages { + nodes[id] = NodeLanguages{Off: append([]string(nil), nl.Off...), On: append([]string(nil), nl.On...)} + } + nl := nodes[node] + nl.Off, nl.On = without(nl.Off, lang), without(nl.On, lang) + switch rule { + case gateway.LanguageOff: + nl.Off = append(nl.Off, lang) + case gateway.LanguageOn: + nl.On = append(nl.On, lang) + } + nodes[node] = nl + s.NodeLanguages = nodes + return s +} + +func ruleName(r gateway.LanguageRule) string { + switch r { + case gateway.LanguageOff: + return "off" + case gateway.LanguageOn: + return "on" + } + return "default" +} + +func nodeLabel(id string) string { + if id == "local" { + return "the gateway" + } + return "worker " + id +} + +// nodeLanguageChanges describes the differences in the choices for the audit log. +func nodeLanguageChanges(a, b SiteSettings) []string { + ids := map[string]bool{} + for id := range a.NodeLanguages { + ids[id] = true + } + for id := range b.NodeLanguages { + ids[id] = true + } + var sorted []string + for id := range ids { + sorted = append(sorted, id) + } + sort.Strings(sorted) + var out []string + for _, id := range sorted { + langs := map[string]bool{} + for _, nl := range []NodeLanguages{a.NodeLanguages[id], b.NodeLanguages[id]} { + for _, l := range nl.Off { + langs[l] = true + } + for _, l := range nl.On { + langs[l] = true + } + } + var names []string + for l := range langs { + names = append(names, l) + } + sort.Strings(names) + for _, l := range names { + from, to := a.NodeRule(id, l), b.NodeRule(id, l) + if from == to { + continue + } + what := map[gateway.LanguageRule]string{ + gateway.LanguageOff: "switched off", + gateway.LanguageOn: "switched on (the node did not declare it)", + gateway.LanguageDefault: "back to what the node declares", + }[to] + out = append(out, fmt.Sprintf("%s: %s %s", nodeLabel(id), l, what)) + } + } + return out +} + +// ---- what a node refuses ----------------------------------------------------- + +// browserLanguages run in the visitor's browser (JavaScript is a console in an +// iframe, LLD 04): they have a route on the server, but no node ever runs them, +// so what a node declares or an admin chooses does not apply to them. +var browserLanguages = map[string]bool{"javascript": true} + +// nodeCommand is the REPL command a node starts for the picker's language, and +// whether the language is one a node runs at all. +func (server *Server) nodeCommand(lang string) (string, bool) { + if browserLanguages[lang] { + return "", false + } + command, routed := server.terminals["ws_"+lang] + return command, routed +} + +// nodeCanRun reports whether a node says it can run the REPL command: a worker +// that declared a list can run those, any other node everything. +func nodeCanRun(declared []string, command string) bool { + return len(declared) == 0 || contains(declared, command) +} + +// disabledOnNode lists the languages (the picker's values) a node does not take +// new terminals for: the site-wide ones, the ones switched off on the node, and +// the ones it cannot run unless an admin switched them on. canRun may be nil +// (the node runs everything). +func (server *Server) disabledOnNode(s SiteSettings, node string, canRun func(command string) bool) []string { + out := []string{} + for _, l := range languageChoices() { + command, routed := server.nodeCommand(l.Value) + if !routed { + // nothing a node runs (JavaScript is a console in the browser): only the site-wide switch + if s.LanguageDisabled(l.Value) { + out = append(out, l.Value) + } + continue + } + switch s.NodeRule(node, l.Value) { + case gateway.LanguageOff: + out = append(out, l.Value) + case gateway.LanguageOn: + if s.LanguageDisabled(l.Value) { + out = append(out, l.Value) + } + default: + if s.LanguageDisabled(l.Value) || (canRun != nil && !canRun(command)) { + out = append(out, l.Value) + } + } + } + sort.Strings(out) + return out +} + +// workerConfigWith is the site rules as a WorkerConfig with the given languages +// switched off, and a revision made from the content. +func (server *Server) workerConfigWith(disabled []string) *tunnel.WorkerConfig { + s := GetSiteSettings() + level := s.Announcement.Level + if level == "" { + level = "info" // what normalize makes of an empty level; the revision must not tell them apart + } + cfg := &tunnel.WorkerConfig{ + ColorOfTheDay: s.ColorOfTheDay, + AnnouncementText: s.Announcement.Text, + AnnouncementLevel: level, + Maintenance: s.Maintenance.Enabled, + MaintenanceMessage: s.Maintenance.Message, + DisabledLanguages: append([]string(nil), disabled...), + } + data, _ := json.Marshal(cfg) + h := fnv.New64a() + h.Write(data) + cfg.Revision = int64(h.Sum64()&0x7fffffffffffffff) | 1 // never 0, which means "none yet" + return cfg +} + +// gatewayWorkerConfigFor is what the gateway hands to one worker: the site rules +// with the languages that are off on that worker. +func (server *Server) gatewayWorkerConfigFor(w *tunnel.Worker) *tunnel.WorkerConfig { + declared := w.Info().Languages + disabled := server.disabledOnNode(GetSiteSettings(), w.ID(), func(command string) bool { + return nodeCanRun(declared, command) + }) + return server.workerConfigWith(disabled) +} + +// nodeLanguageRule is the router's hook: the rule for the language of a terminal +// route on a node. An admin is let through a language that is off on the node, +// as the site-wide switch lets them through. +func (server *Server) nodeLanguageRule(w http.ResponseWriter, r *http.Request, node, rel string) gateway.LanguageRule { + lang, ok := server.terminalLanguage(rel) + if !ok || lang == "" { + return gateway.LanguageDefault + } + rule := GetSiteSettings().NodeRule(node, lang) + if rule == gateway.LanguageOff && server.isAdmin(w, r) { + return gateway.LanguageDefault + } + return rule +} + +// refuseTerminal closes a terminal's WebSocket with a reason the page shows. +func (server *Server) refuseTerminal(w http.ResponseWriter, r *http.Request, reason string) bool { + server.closeWithNotice(w, r, reason) + return true +} + +// nodeDisabledForRequest, when set (on a gateway), returns the languages that +// are off on the node that serves the visitor, or nil when it is not known. +// settings.js uses it, so that the picker hides what the visitor's node does not +// take. +var nodeDisabledForRequest func(r *http.Request) []string + +func (server *Server) bindNodeLanguages(router *gateway.Router) { + nodeDisabledForRequest = func(r *http.Request) []string { + node := router.NodeOf(r) + if node == "" || node == "-" { + return nil + } + var canRun func(string) bool + if b, found := router.Backend(node); found { + canRun = b.HasLanguage + } + return server.disabledOnNode(GetSiteSettings(), node, canRun) + } +} + +// ---- asking the host whether programs can be traced ------------------------------- + +// ptraceProbe asks the host, with the probe the image ships +// (scripts/ptrace-probe.c), whether programs can be traced: "ok", "ok-aslr", or +// why not. "" means it could not find out (no probe installed). Debug and the +// assembly REPL need it; the dashboard shows the answer. +func ptraceProbe() string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "openrepl-ptrace-probe").Output() + text := strings.TrimSpace(string(out)) + if i := strings.IndexByte(text, '\n'); i >= 0 { + text = text[:i] + } + if text == "" { + if _, notFound := err.(*exec.Error); notFound { + return "" + } + if err != nil { + log.Println("ptrace probe: ", err) + } + return "" + } + return text +} + +// localPtrace is the gateway's own answer, asked once when it starts. +var localPtrace string + +// probeHost asks the host and logs the answer, for the operator reading the log. +func probeHost(who string) string { + answer := ptraceProbe() + switch { + case answer == "": + log.Printf("%s: no ptrace probe installed (openrepl-ptrace-probe); not known whether programs can be traced here", who) + case ptraceWorks(answer): + log.Printf("%s: programs can be traced on this host (%s)", who, answer) + default: + log.Printf("%s: programs CANNOT be traced on this host (%s): Debug will run them under QEMU, and the assembly REPL cannot work", who, answer) + } + return answer +} + +// ptraceWorks reports whether the probe's answer says programs can be traced. +func ptraceWorks(answer string) bool { return strings.HasPrefix(answer, "ok") } + +// languagesNeedingPtrace are the picker languages that cannot work without it: +// the assembly REPL traces its child process. (Debug in the others falls back to +// QEMU, see scripts/openrepl-gdb.) +var languagesNeedingPtrace = map[string]bool{"rappel": true} diff --git a/src/server/node_languages_test.go b/src/server/node_languages_test.go new file mode 100644 index 00000000..567569b2 --- /dev/null +++ b/src/server/node_languages_test.go @@ -0,0 +1,419 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gateway" +) + +// ---- the stored choices ------------------------------------------------------------ + +func TestNodeChoicesAreCleanedAndRefuseNonsense(t *testing.T) { + got, err := normalizeNodeLanguages(map[string]NodeLanguages{ + "pi-1": {Off: []string{" Rappel ", "cpp", "rappel", ""}, On: []string{"go"}}, + "local": {Off: []string{"python"}}, + "worker": {}, // nothing chosen: not kept + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || strings.Join(got["pi-1"].Off, ",") != "cpp,rappel" || strings.Join(got["pi-1"].On, ",") != "go" { + t.Fatalf("cleaned: %+v", got) + } + if got, err := normalizeNodeLanguages(map[string]NodeLanguages{"a": {}}); err != nil || got != nil { + t.Fatalf("an empty entry was kept: %+v %v", got, err) + } + for name, in := range map[string]map[string]NodeLanguages{ + "a bad node id": {"../x": {Off: []string{"go"}}}, + "a bad language": {"pi-1": {Off: []string{"Go Lang!"}}}, + "on and off together": {"pi-1": {Off: []string{"go"}, On: []string{"go"}}}, + "a node id with a slash": {"a/b": {Off: []string{"go"}}}, + } { + if _, err := normalizeNodeLanguages(in); err == nil { + t.Errorf("%s was accepted", name) + } + } + tooMany := map[string]NodeLanguages{} + for i := 0; i <= maxNodeLanguageNodes; i++ { + tooMany["w"+strings.Repeat("x", i%5)+string(rune('a'+i%26))+strings.Repeat("y", i/26)] = NodeLanguages{Off: []string{"go"}} + } + if _, err := normalizeNodeLanguages(tooMany); err == nil { + t.Error("more nodes than the limit were accepted") + } +} + +func TestARuleIsSetWithoutChangingTheSettingsItCameFrom(t *testing.T) { + var s SiteSettings + s1 := s.withNodeRule("pi-1", "rappel", gateway.LanguageOff) + s2 := s1.withNodeRule("pi-1", "go", gateway.LanguageOn) + s3 := s2.withNodeRule("pi-1", "rappel", gateway.LanguageOn) // off -> on moves it + s4 := s3.withNodeRule("pi-1", "go", gateway.LanguageDefault) + if s.NodeRule("pi-1", "rappel") != gateway.LanguageDefault || s1.NodeRule("pi-1", "go") != gateway.LanguageDefault { + t.Fatal("an earlier copy changed") + } + if s2.NodeRule("pi-1", "rappel") != gateway.LanguageOff || s2.NodeRule("pi-1", "go") != gateway.LanguageOn { + t.Fatalf("s2: %+v", s2.NodeLanguages) + } + if s3.NodeRule("pi-1", "rappel") != gateway.LanguageOn || s2.NodeRule("pi-1", "rappel") != gateway.LanguageOff { + t.Fatalf("s3: %+v / s2: %+v", s3.NodeLanguages, s2.NodeLanguages) + } + if s4.NodeRule("pi-1", "go") != gateway.LanguageDefault || s4.NodeRule("other", "go") != gateway.LanguageDefault { + t.Fatalf("s4: %+v", s4.NodeLanguages) + } + if clean, err := s4.normalize(); err != nil || len(clean.NodeLanguages["pi-1"].Off) != 0 || strings.Join(clean.NodeLanguages["pi-1"].On, ",") != "rappel" { + t.Fatalf("normalized: %+v %v", clean.NodeLanguages, err) + } +} + +func TestTheAuditLogSaysWhatChangedOnWhichNode(t *testing.T) { + var a SiteSettings + b := a.withNodeRule("pi-1", "rappel", gateway.LanguageOff).withNodeRule("local", "go", gateway.LanguageOn) + got := strings.Join(nodeLanguageChanges(a, b), "\n") + for _, want := range []string{"the gateway: go switched on", "worker pi-1: rappel switched off"} { + if !strings.Contains(got, want) { + t.Errorf("audit lacks %q: %s", want, got) + } + } + back := strings.Join(nodeLanguageChanges(b, a), "\n") + if !strings.Contains(back, "worker pi-1: rappel back to what the node declares") { + t.Errorf("going back: %s", back) + } + if len(nodeLanguageChanges(b, b)) != 0 { + t.Error("no change was reported as one") + } +} + +// ---- what a node refuses ----------------------------------------------------------- + +func languageTestServer() *Server { + return &Server{terminals: map[string]string{ + "ws": "", "ws_python": "python", "ws_c": "cling", "ws_cpp": "cling", "ws_go": "gointerpreter", + "ws_evcxr": "evcxr", "ws_rappel": "rappel", + "ws_javascript": "javascript", // a route like every demo, though its console runs in the browser + }} +} + +func TestANodeRefusesWhatItCannotRunWhatAnAdminSwitchedOffAndWhatTheSiteSwitchedOff(t *testing.T) { + s := languageTestServer() + declared := []string{"python", "cling", "rappel"} + canRun := func(command string) bool { return nodeCanRun(declared, command) } + has := func(list []string, v string) bool { return contains(list, v) } + + // nothing chosen: the languages the worker did not list are off for its visitors + got := s.disabledOnNode(SiteSettings{}, "pi-1", canRun) + if !has(got, "go") || !has(got, "evcxr") || has(got, "python") || has(got, "cpp") || has(got, "rappel") { + t.Fatalf("by declaration: %v", got) + } + // JavaScript runs in the browser: a node's list says nothing about it + if has(got, "javascript") { + t.Fatalf("javascript was hidden because the worker did not declare it: %v", got) + } + // a node that did not list its languages runs everything + if got := s.disabledOnNode(SiteSettings{}, "pi-1", nil); len(got) != 0 { + t.Fatalf("no list: %v", got) + } + // the admin switches rappel off, go on (installed since), and the site switches python off + set := SiteSettings{DisabledLanguages: []string{"python"}}. + withNodeRule("pi-1", "rappel", gateway.LanguageOff). + withNodeRule("pi-1", "go", gateway.LanguageOn) + got = s.disabledOnNode(set, "pi-1", canRun) + if !has(got, "rappel") || has(got, "go") || !has(got, "python") || !has(got, "evcxr") { + t.Fatalf("with choices: %v", got) + } + // the rules are for pi-1 only + if other := s.disabledOnNode(set, "pi-2", nil); has(other, "rappel") || !has(other, "python") { + t.Fatalf("another node: %v", other) + } + // a language on a node can not undo the site-wide switch + both := SiteSettings{DisabledLanguages: []string{"go"}}.withNodeRule("pi-1", "go", gateway.LanguageOn) + if got := s.disabledOnNode(both, "pi-1", canRun); !has(got, "go") { + t.Fatalf("switched on beat the site: %v", got) + } + // the list is sorted and never nil (the page reads it as an array) + if got := s.disabledOnNode(SiteSettings{}, "x", nil); got == nil { + t.Fatal("nil list") + } +} + +func TestEachWorkersConfigFollowsItsOwnLanguages(t *testing.T) { + adminTestServer(t) + s := languageTestServer() + plain := s.workerConfigWith(nil) + again := s.workerConfigWith([]string{}) + one := s.workerConfigWith([]string{"rappel"}) + two := s.workerConfigWith([]string{"go", "rappel"}) + if plain.Revision != again.Revision { + t.Fatal("the same rules got another revision") + } + if one.Revision == plain.Revision || two.Revision == one.Revision || len(two.DisabledLanguages) != 2 { + t.Fatalf("revisions %d %d %d, %v", plain.Revision, one.Revision, two.Revision, two.DisabledLanguages) + } + if g := s.gatewayWorkerConfig(); g.Revision != plain.Revision { + t.Fatal("the config for workers without choices changed") + } +} + +func TestAnAdminIsLetThroughALanguageSwitchedOffOnTheNode(t *testing.T) { + s, _ := adminTestServer(t) + s.terminals = languageTestServer().terminals + if err := SaveSiteSettings(SiteSettings{}.withNodeRule("pi-1", "python", gateway.LanguageOff).withNodeRule("pi-1", "go", gateway.LanguageOn)); err != nil { + t.Fatal(err) + } + visitor := httptest.NewRequest("GET", "/ws_python", nil) + admin := httptest.NewRequest("GET", "/ws_python", nil) + admin.Header.Set("X-Test-Admin", "yes") + w := httptest.NewRecorder() + if got := s.nodeLanguageRule(w, visitor, "pi-1", "ws_python"); got != gateway.LanguageOff { + t.Fatalf("visitor: %v", got) + } + if got := s.nodeLanguageRule(w, admin, "pi-1", "ws_python"); got != gateway.LanguageDefault { + t.Fatalf("admin: %v", got) + } + // "on" is for everybody; another node and the plain shell have no rule + if got := s.nodeLanguageRule(w, visitor, "pi-1", "ws_go"); got != gateway.LanguageOn { + t.Fatalf("go on: %v", got) + } + if s.nodeLanguageRule(w, visitor, "pi-2", "ws_python") != gateway.LanguageDefault || s.nodeLanguageRule(w, visitor, "pi-1", "ws") != gateway.LanguageDefault { + t.Fatal("a rule leaked to another node or to the shell") + } +} + +func TestThePickerOfAVisitorHidesWhatTheirNodeRefuses(t *testing.T) { + adminTestServer(t) + defer func() { nodeDisabledForRequest = nil }() + nodeDisabledForRequest = func(r *http.Request) []string { + if r.Header.Get("X-Node") == "pi-1" { + return []string{"go", "rappel"} + } + return nil + } + SaveSiteSettings(SiteSettings{DisabledLanguages: []string{"python"}}) + + on := httptest.NewRequest("GET", "/settings.js", nil) + on.Header.Set("X-Node", "pi-1") + w := httptest.NewRecorder() + handleSettingsJS(w, on) + if !strings.Contains(w.Body.String(), `"disabledLanguages":["go","rappel"]`) { + t.Fatalf("on pi-1: %s", w.Body.String()) + } + // a visitor whose node is not known gets the site's list + w = httptest.NewRecorder() + handleSettingsJS(w, httptest.NewRequest("GET", "/settings.js", nil)) + if !strings.Contains(w.Body.String(), `"disabledLanguages":["python"]`) { + t.Fatalf("unknown node: %s", w.Body.String()) + } +} + +// ---- the dashboard's route --------------------------------------------------------- + +type stubNode struct { + id string + langs []string +} + +func (b *stubNode) ID() string { return b.id } +func (b *stubNode) State() gateway.State { return gateway.Online } +func (b *stubNode) Weight() int { return 10 } +func (b *stubNode) Capacity() (int64, int64) { return 0, 0 } +func (b *stubNode) HasLanguage(c string) bool { return nodeCanRun(b.langs, c) } +func (b *stubNode) Serve(w http.ResponseWriter, r *http.Request) error { return nil } + +func nodeLanguagesTestServer(t *testing.T) (*Server, http.Handler) { + t.Helper() + s, _ := adminTestServer(t) + s.terminals = languageTestServer().terminals + router := gateway.NewRouter(gateway.Config{Site: http.NotFoundHandler(), PathPrefix: "/", Secret: []byte("s"), Local: gateway.LocalConfig{Weight: 10}}) + router.AddBackend(&stubNode{id: "pi-1", langs: []string{"python", "cling"}}) + s.admin.router = router + s.admin.prefix = "/" + s.gatewayAdmin = http.NotFoundHandler() + mux := http.NewServeMux() + mux.Handle("/admin/workers/", s.adminAPI(http.HandlerFunc(s.handleGatewayAdmin))) + mux.Handle("/admin/audit", s.adminAPI(http.HandlerFunc(s.handleAdminAudit))) + return s, mux +} + +func rowOf(t *testing.T, w *httptest.ResponseRecorder, lang string) nodeLanguageRow { + t.Helper() + var reply nodeLanguagesReply + decode(t, w, &reply) + for _, r := range reply.Languages { + if r.Value == lang { + return r + } + } + t.Fatalf("no row for %s in %s", lang, w.Body.String()) + return nodeLanguageRow{} +} + +func TestTheLanguagesCardOfAWorker(t *testing.T) { + _, h := nodeLanguagesTestServer(t) + + w := get(h, "/admin/workers/pi-1/languages") + if w.Code != 200 { + t.Fatalf("GET -> %d %s", w.Code, w.Body.String()) + } + var reply nodeLanguagesReply + decode(t, w, &reply) + if !reply.Online || reply.Node != "pi-1" || len(reply.Languages) == 0 { + t.Fatalf("reply: %+v", reply) + } + for _, r := range reply.Languages { + if r.Value == "javascript" { + t.Fatal("a language with no terminal is in the list") + } + } + if r := rowOf(t, w, "python"); !r.Declared || !r.Takes || r.Rule != "default" { + t.Fatalf("python: %+v", r) + } + if r := rowOf(t, w, "go"); r.Declared || r.Takes || r.Command != "gointerpreter" { + t.Fatalf("go (not declared): %+v", r) + } + if r := rowOf(t, w, "rappel"); !r.NeedsPtrace || r.Declared { + t.Fatalf("rappel: %+v", r) + } + + // switch go on although the worker did not declare it: it takes terminals now + w = post(h, "/admin/workers/pi-1/languages", `{"language":"go","rule":"on"}`) + if w.Code != 200 || !rowOf(t, w, "go").Takes || rowOf(t, w, "go").Rule != "on" || rowOf(t, w, "go").Declared { + t.Fatalf("go on -> %d %s", w.Code, w.Body.String()) + } + if GetSiteSettings().NodeRule("pi-1", "go") != gateway.LanguageOn { + t.Fatal("the rule was not saved") + } + // and python off although it is declared + w = post(h, "/admin/workers/pi-1/languages", `{"language":"python","rule":"off"}`) + if r := rowOf(t, w, "python"); w.Code != 200 || r.Takes || r.Rule != "off" || !r.Declared { + t.Fatalf("python off -> %d %+v", w.Code, r) + } + // the site switch wins over a rule that says on + SaveSiteSettings(GetSiteSettings().withNodeRule("pi-1", "cpp", gateway.LanguageOn)) + SaveSiteSettings(func() SiteSettings { s := GetSiteSettings(); s.DisabledLanguages = []string{"cpp"}; return s }()) + if r := rowOf(t, get(h, "/admin/workers/pi-1/languages"), "cpp"); r.Takes || !r.SiteOff { + t.Fatalf("cpp: %+v", r) + } + // back to default + w = post(h, "/admin/workers/pi-1/languages", `{"language":"python","rule":"default"}`) + if r := rowOf(t, w, "python"); r.Rule != "default" || !r.Takes { + t.Fatalf("python default: %+v", r) + } + + // the audit log says what changed, once per change and on which node + var audit struct{ Entries []auditEntry } + decode(t, get(h, "/admin/audit"), &audit) + var lines []string + for _, e := range audit.Entries { + lines = append(lines, e.Detail) + } + all := strings.Join(lines, "\n") + for _, want := range []string{"worker pi-1: go switched on", "worker pi-1: python switched off", "worker pi-1: python back to what the node declares"} { + if strings.Count(all, want) != 1 { + t.Errorf("audit log should have %q once: %s", want, all) + } + } +} + +func TestTheLanguagesCardRefusesWhatItShould(t *testing.T) { + _, h := nodeLanguagesTestServer(t) + for name, c := range map[string]struct { + call adminCall + want int + }{ + "a visitor": {adminCall{method: "GET", path: "/admin/workers/pi-1/languages"}, 401}, + "a change without the header": {adminCall{method: "POST", path: "/admin/workers/pi-1/languages", body: `{"language":"go","rule":"on"}`, admin: true}, 403}, + "an unknown node": {adminCall{method: "GET", path: "/admin/workers/ghost/languages", admin: true}, 404}, + "a bad node id": {adminCall{method: "GET", path: "/admin/workers/a%20b/languages", admin: true}, 400}, + "a language without a terminal": {adminCall{method: "POST", path: "/admin/workers/pi-1/languages", body: `{"language":"javascript","rule":"off"}`, admin: true, header: true}, 400}, + "a language that does not exist": {adminCall{method: "POST", path: "/admin/workers/pi-1/languages", body: `{"language":"cobol","rule":"off"}`, admin: true, header: true}, 400}, + "a rule that does not exist": {adminCall{method: "POST", path: "/admin/workers/pi-1/languages", body: `{"language":"go","rule":"maybe"}`, admin: true, header: true}, 400}, + "not JSON": {adminCall{method: "POST", path: "/admin/workers/pi-1/languages", body: `nope`, admin: true, header: true}, 400}, + "another method": {adminCall{method: "DELETE", path: "/admin/workers/pi-1/languages", admin: true, header: true}, 405}, + } { + if w := c.call.do(h); w.Code != c.want { + t.Errorf("%s -> %d, want %d (%s)", name, w.Code, c.want, strings.TrimSpace(w.Body.String())) + } + } + if len(GetSiteSettings().NodeLanguages) != 0 { + t.Fatalf("a refused request changed the settings: %+v", GetSiteSettings().NodeLanguages) + } +} + +func TestTheGatewayItselfHasALanguagesCardWithItsPtraceAnswer(t *testing.T) { + _, h := nodeLanguagesTestServer(t) + old := localPtrace + localPtrace = "getregs: Input/output error" + defer func() { localPtrace = old }() + + w := get(h, "/admin/workers/local/languages") + var reply nodeLanguagesReply + decode(t, w, &reply) + if w.Code != 200 || reply.Ptrace != "getregs: Input/output error" || !reply.Online { + t.Fatalf("local: %d %+v", w.Code, reply) + } + // the gateway runs everything it has: every language is declared and taken + if r := rowOf(t, w, "go"); !r.Declared || !r.Takes { + t.Fatalf("go on the gateway: %+v", r) + } + w = post(h, "/admin/workers/local/languages", `{"language":"rappel","rule":"off"}`) + if r := rowOf(t, w, "rappel"); w.Code != 200 || r.Takes { + t.Fatalf("rappel off on the gateway: %d %+v", w.Code, r) + } +} + +func TestSavingTheSettingsPageKeepsTheChoicesOfNodes(t *testing.T) { + s, mux := adminTestServer(t) + _ = s + if err := SaveSiteSettings(SiteSettings{}.withNodeRule("pi-1", "rappel", gateway.LanguageOff)); err != nil { + t.Fatal(err) + } + // the page sends its own fields and no nodeLanguages + w := post(mux, "/admin/settings", `{"disabledLanguages":["cpp"],"colorOfTheDay":true}`) + if w.Code != 200 { + t.Fatalf("save -> %d %s", w.Code, w.Body.String()) + } + got := GetSiteSettings() + if got.NodeRule("pi-1", "rappel") != gateway.LanguageOff || !got.LanguageDisabled("cpp") { + t.Fatalf("after the Settings page: %+v", got) + } + // and a page that tries to set them does not + post(mux, "/admin/settings", `{"nodeLanguages":{"pi-9":{"off":["go"]}}}`) + if got := GetSiteSettings(); got.NodeRule("pi-9", "go") != gateway.LanguageDefault || got.NodeRule("pi-1", "rappel") != gateway.LanguageOff { + t.Fatalf("the Settings page set node choices: %+v", got.NodeLanguages) + } +} + +// ---- asking the host -------------------------------------------------------------- + +func TestThePtraceProbeAnswerIsItsFirstLineAndAMissingProbeIsNotAnAnswer(t *testing.T) { + dir := t.TempDir() + script := func(body string) { + if err := os.WriteFile(filepath.Join(dir, "openrepl-ptrace-probe"), []byte("#!/bin/sh\n"+body), 0755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + script("echo ok\nexit 0\n") + if got := ptraceProbe(); got != "ok" || !ptraceWorks(got) { + t.Fatalf("a host that can: %q", got) + } + script("echo ok-aslr\nexit 2\n") + if got := ptraceProbe(); got != "ok-aslr" || !ptraceWorks(got) { + t.Fatalf("a host that cannot switch randomization off: %q", got) + } + script("echo 'getregs: Input/output error'\necho more\nexit 1\n") + if got := ptraceProbe(); got != "getregs: Input/output error" || ptraceWorks(got) { + t.Fatalf("a host that cannot: %q", got) + } + // an image from before the probe: unknown, not a failure + os.Remove(filepath.Join(dir, "openrepl-ptrace-probe")) + t.Setenv("PATH", dir) + if got := ptraceProbe(); got != "" { + t.Fatalf("no probe installed: %q", got) + } +} diff --git a/src/server/settings.go b/src/server/settings.go index 5571042b..ae195442 100644 --- a/src/server/settings.go +++ b/src/server/settings.go @@ -45,6 +45,12 @@ type SiteSettings struct { // DisabledLanguages are the values of the language picker (python, cpp, ...) // that are switched off: the page hides them and their terminals refuse. DisabledLanguages []string `json:"disabledLanguages"` + // NodeLanguages are an admin's choices for single nodes (the gateway itself, + // "local", and each worker by id): languages switched off on one node only, + // and languages switched on that the node did not declare. See + // node_languages.go. The Settings page does not send them; /admin/workers + // changes them. + NodeLanguages map[string]NodeLanguages `json:"nodeLanguages,omitempty"` // Genie holds the AI assistant's switch and its rate limits. Genie GenieSettings `json:"genie"` // Admins are the accounts added in the dashboard, lower case, besides the @@ -161,6 +167,12 @@ func (s SiteSettings) normalize() (SiteSettings, error) { sort.Strings(langs) s.DisabledLanguages = langs + nodes, err := normalizeNodeLanguages(s.NodeLanguages) + if err != nil { + return s, err + } + s.NodeLanguages = nodes + for name, v := range map[string]float64{"guest": s.Genie.GuestPerMinute, "signed-in": s.Genie.UserPerMinute} { if v < 0 || v > maxGenieRate { return s, fmt.Errorf("the %s Genie rate must be between 0 and %d requests per minute", name, int(maxGenieRate)) @@ -451,7 +463,15 @@ func (s SiteSettings) public() publicSettings { // handleSettingsJS serves the public settings as a script that defines // window.site_settings. Pages load it before js/preprocessing.js. func handleSettingsJS(rw http.ResponseWriter, req *http.Request) { - data, err := json.Marshal(GetSiteSettings().public()) + pub := GetSiteSettings().public() + if f := nodeDisabledForRequest; f != nil { + // On a gateway the languages that are off depend on the node that + // serves the visitor's session. + if langs := f(req); langs != nil { + pub.DisabledLanguages = langs + } + } + data, err := json.Marshal(pub) if err != nil { data = []byte("{}") } @@ -550,8 +570,9 @@ func (server *Server) handleAdminSettings(rw http.ResponseWriter, req *http.Requ base = *posted.Version } before := GetSiteSettings() - s.Secrets = before.Secrets // the form cannot set keys; /admin/keys does - s.Admins = before.Admins // nor admins; /admin/admins does, for owners + s.Secrets = before.Secrets // the form cannot set keys; /admin/keys does + s.Admins = before.Admins // nor admins; /admin/admins does, for owners + s.NodeLanguages = before.NodeLanguages // nor the languages of single nodes; /admin/workers does if err := SaveSiteSettings(s, base); err != nil { if _, bad := s.normalize(); bad != nil { adminError(rw, http.StatusBadRequest, bad.Error()) @@ -608,6 +629,7 @@ func settingsChanges(a, b SiteSettings) []string { if strings.Join(a.DisabledLanguages, ",") != strings.Join(b.DisabledLanguages, ",") { out = append(out, "languages switched off: "+strings.Join(b.DisabledLanguages, ", ")) } + out = append(out, nodeLanguageChanges(a, b)...) if a.Genie.Disabled != b.Genie.Disabled { out = append(out, "Genie "+map[bool]string{true: "switched off", false: "switched on"}[b.Genie.Disabled]) } diff --git a/src/server/worker.go b/src/server/worker.go index 3390ef6a..9e0b521d 100644 --- a/src/server/worker.go +++ b/src/server/worker.go @@ -60,6 +60,7 @@ func (server *Server) runWorker(ctx context.Context, handlers http.Handler, coun Languages: languages, Capacity: capacity, Weight: server.options.WorkerWeight, + Ptrace: probeHost("worker"), }, Load: func() tunnel.Heartbeat { return tunnel.Heartbeat{Used: int64(counter.weight()), Active: counter.count()} diff --git a/src/server/worker_config.go b/src/server/worker_config.go index e5a453ca..36dea096 100644 --- a/src/server/worker_config.go +++ b/src/server/worker_config.go @@ -1,8 +1,6 @@ package server import ( - "encoding/json" - "hash/fnv" "log" "net/http" "strings" @@ -21,26 +19,10 @@ import ( // gatewayWorkerConfig is what the gateway hands to workers: the site rules of // its settings, with a revision made from their content, so that the same -// rules have the same revision on every gateway and after a restart. +// rules have the same revision on every gateway and after a restart. A worker +// gets its own languages on top of that (gatewayWorkerConfigFor). func (server *Server) gatewayWorkerConfig() *tunnel.WorkerConfig { - s := GetSiteSettings() - level := s.Announcement.Level - if level == "" { - level = "info" // what normalize makes of an empty level; the revision must not tell them apart - } - cfg := &tunnel.WorkerConfig{ - ColorOfTheDay: s.ColorOfTheDay, - AnnouncementText: s.Announcement.Text, - AnnouncementLevel: level, - Maintenance: s.Maintenance.Enabled, - MaintenanceMessage: s.Maintenance.Message, - DisabledLanguages: append([]string(nil), s.DisabledLanguages...), - } - data, _ := json.Marshal(cfg) - h := fnv.New64a() - h.Write(data) - cfg.Revision = int64(h.Sum64()&0x7fffffffffffffff) | 1 // never 0, which means "none yet" - return cfg + return server.workerConfigWith(GetSiteSettings().DisabledLanguages) } // applyWorkerConfig makes the gateway's site rules the worker's settings. The diff --git a/src/tunnel/protocol.go b/src/tunnel/protocol.go index bad836bf..223b2cde 100644 --- a/src/tunnel/protocol.go +++ b/src/tunnel/protocol.go @@ -47,6 +47,10 @@ type RegisterRequest struct { Languages []string `json:"languages,omitempty"` Capacity int64 `json:"capacity"` // memory-weight units it can run Weight int `json:"weight"` + // Ptrace is what the worker's host answered when asked whether programs can + // be traced (openrepl-ptrace-probe): "ok", "ok-aslr", or why not. Empty means + // the worker did not find out. The dashboard shows it; it decides nothing. + Ptrace string `json:"ptrace,omitempty"` } // RegisterReply is the gateway's answer. It carries what the worker needs to diff --git a/src/tunnel/server.go b/src/tunnel/server.go index 03d0800b..5336626e 100644 --- a/src/tunnel/server.go +++ b/src/tunnel/server.go @@ -46,6 +46,10 @@ type ServerConfig struct { // Config is the settings the gateway wants workers to follow. It is called // at every registration and heartbeat, so it should be cheap. nil: none. Config func() *WorkerConfig + // WorkerConfig is Config for settings that differ from worker to worker (the + // languages an admin switched off on one node). When it is set it is used + // instead of Config. + WorkerConfig func(w *Worker) *WorkerConfig // HeartbeatInterval is how often workers report; Timeout is how long a // silent worker stays ONLINE. Defaults: 10s and 30s. HeartbeatInterval time.Duration @@ -429,10 +433,8 @@ func (s *Server) ServeConn(c net.Conn) { atomic.StoreInt64(&worker.configRev, hb.ConfigRev) } var hr HeartbeatReply - if s.cfg.Config != nil { - if cur := s.cfg.Config(); cur != nil && cur.Revision != atomic.LoadInt64(&worker.configRev) { - hr.Config = cur - } + if cur := s.configFor(worker); cur != nil && cur.Revision != atomic.LoadInt64(&worker.configRev) { + hr.Config = cur } data, _ := json.Marshal(hr) req.Reply(true, data) @@ -549,10 +551,8 @@ func (s *Server) register(conn ssh.Conn, payload []byte) (*Worker, RegisterReply if s.cfg.Secret != nil { rep.Secret = s.cfg.Secret() } - if s.cfg.Config != nil { - if rep.Config = s.cfg.Config(); rep.Config != nil { - atomic.StoreInt64(&w.configRev, rep.Config.Revision) - } + if rep.Config = s.configFor(w); rep.Config != nil { + atomic.StoreInt64(&w.configRev, rep.Config.Revision) } return w, rep, nil } @@ -600,8 +600,20 @@ func (s *Server) watch(w *Worker) { } } +// configFor is the WorkerConfig the worker should follow now (nil: none). +func (s *Server) configFor(w *Worker) *WorkerConfig { + switch { + case s.cfg.WorkerConfig != nil: + return s.cfg.WorkerConfig(w) + case s.cfg.Config != nil: + return s.cfg.Config() + } + return nil +} + // ConfigRevision is the revision of the WorkerConfig workers should follow now -// (0 when there is none). +// (0 when there is none). With per-worker settings (ServerConfig.WorkerConfig) +// use ConfigRevisionFor. func (s *Server) ConfigRevision() int64 { if s.cfg.Config == nil { return 0 @@ -611,3 +623,12 @@ func (s *Server) ConfigRevision() int64 { } return 0 } + +// ConfigRevisionFor is the revision of the WorkerConfig the worker should follow +// now (0 when there is none). +func (s *Server) ConfigRevisionFor(w *Worker) int64 { + if cur := s.configFor(w); cur != nil { + return cur.Revision + } + return 0 +} diff --git a/src/tunnel/tunnel_test.go b/src/tunnel/tunnel_test.go index e9039bff..9b3672e6 100644 --- a/src/tunnel/tunnel_test.go +++ b/src/tunnel/tunnel_test.go @@ -751,3 +751,59 @@ func TestAReconnectingWorkerGetsTheConfigAgainOnlyIfItChanged(t *testing.T) { t.Fatalf("OnConfig was called %d times for one revision", calls) } } + +// ---- settings that differ from worker to worker ------------------------------------ + +func TestEachWorkerGetsItsOwnConfigWhenTheGatewayHasPerWorkerSettings(t *testing.T) { + var mu sync.Mutex + off := map[string][]string{"worker-1": {"rappel"}, "worker-2": {"cpp"}} + gw := newGateway(t, func(c *ServerConfig) { + c.WorkerConfig = func(w *Worker) *WorkerConfig { + mu.Lock() + defer mu.Unlock() + return &WorkerConfig{Revision: int64(w.ID()[len(w.ID())-1])*1000 + int64(len(off[w.ID()])), DisabledLanguages: off[w.ID()]} + } + // the global Config is ignored when WorkerConfig is set + c.Config = func() *WorkerConfig { return &WorkerConfig{Revision: 1, DisabledLanguages: []string{"wrong"}} } + }) + var got1, got2 []WorkerConfig + var gmu sync.Mutex + w1 := newWorker(t, gw.url, "worker-1", nil, func(c *ClientConfig) { + c.OnConfig = func(cfg *WorkerConfig) { gmu.Lock(); got1 = append(got1, *cfg); gmu.Unlock() } + }) + w2 := newWorker(t, gw.url, "worker-2", nil, func(c *ClientConfig) { + c.OnConfig = func(cfg *WorkerConfig) { gmu.Lock(); got2 = append(got2, *cfg); gmu.Unlock() } + }) + <-w1.reply + <-w2.reply + waitFor(t, "both configs", func() bool { gmu.Lock(); defer gmu.Unlock(); return len(got1) == 1 && len(got2) == 1 }) + if len(got1[0].DisabledLanguages) != 1 || got1[0].DisabledLanguages[0] != "rappel" || got2[0].DisabledLanguages[0] != "cpp" { + t.Fatalf("worker-1 %+v, worker-2 %+v", got1[0], got2[0]) + } + waitFor(t, "registration", func() bool { return gw.srv.Worker("worker-1") != nil && gw.srv.Worker("worker-2") != nil }) + if r1, r2 := gw.srv.ConfigRevisionFor(gw.srv.Worker("worker-1")), gw.srv.ConfigRevisionFor(gw.srv.Worker("worker-2")); r1 == r2 || r1 == 0 { + t.Fatalf("revisions %d and %d", r1, r2) + } + + // an admin changes one worker's languages: only that worker is handed a new config + mu.Lock() + off["worker-1"] = []string{"rappel", "go"} + mu.Unlock() + waitFor(t, "worker-1 follows the change", func() bool { gmu.Lock(); defer gmu.Unlock(); return len(got1) == 2 }) + time.Sleep(150 * time.Millisecond) + gmu.Lock() + defer gmu.Unlock() + if len(got1[1].DisabledLanguages) != 2 || len(got2) != 1 { + t.Fatalf("worker-1 %+v; worker-2 was handed %d configs", got1[1], len(got2)) + } +} + +func TestAWorkerSaysWhetherItsHostCanTracePrograms(t *testing.T) { + gw := newGateway(t, nil) + wk := newWorker(t, gw.url, "pi-1", nil, func(c *ClientConfig) { c.Register.Ptrace = "traceme: not implemented" }) + <-wk.reply + waitFor(t, "registration", func() bool { return gw.srv.Worker("pi-1") != nil }) + if got := gw.srv.Worker("pi-1").Info().Ptrace; got != "traceme: not implemented" { + t.Fatalf("ptrace = %q", got) + } +}