Plan M4a: gatewayd in 15 tasks, with skeletons and given tests
Each task's tests were run against a reference at its end state; the end states were replayed from master in order with the gate at each step (650 to 762 tests); each skeleton compiles against its tests and fails them. The reference is kept off this machine. Lessons T27 (every wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,10 @@ merged, reviewed in `docs/implementer-log.md`; its open findings (17, 18, 20) ar
|
||||
closed by M3b task 02. M3b (the container runner and the four tools, `docs/specs/2026-09-22-m3b-runner.md`) is done and
|
||||
merged: tool containers run from the image `deploy/tools-image.nix` builds, checked on straylight.
|
||||
M4 is split: M4a (`gatewayd`, conversations over Mattermost) is specified in
|
||||
`docs/specs/2026-09-23-m4a-gateway.md`, a draft for review; M4b (approvals over Mattermost) follows.
|
||||
`docs/specs/2026-09-23-m4a-gateway.md` (approved) and planned in `docs/plans/M4a/` (15 tasks,
|
||||
branch `m4a`); M4b (approvals over Mattermost) follows. The owner's Mattermost settings and the
|
||||
bot's token are in `mattermost.env` at the repository root: ignored by git, never committed, and
|
||||
the token is never printed.
|
||||
Straylight now serves Ornith as four slots over one 262,144-token pool; see
|
||||
`docs/inference-contract.md`, "Deployment change, 2026-09-20", before relying on cache behaviour.
|
||||
`docs/runbook.md` has an entry for every
|
||||
@@ -55,8 +58,8 @@ requests, check `GET /slots?model=ornith-1.5-35b-a3b` so you do not evict someon
|
||||
runs `brokerd`, `loopd` and an approval end to end against it.
|
||||
- `bxctl` is the owner CLI: `bxctl chat` talks to a running `loopd serve --config <path>`;
|
||||
`bxctl approvals`, `approve`, `refuse`, `grants check` and `audit verify` talk to `brokerd serve`
|
||||
(or read the audit log directly); `bxctl reindex` comes in M5. Roles not yet built (`gatewayd`,
|
||||
`toolkit`) print "not implemented" and exit 2.
|
||||
(or read the audit log directly); `bxctl reindex` comes in M5. `gatewayd` prints "not
|
||||
implemented" and exits 2 until M4a task 15.
|
||||
|
||||
## Architecture in brief
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ How it is used:
|
||||
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. |
|
||||
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
|
||||
|
||||
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. |
|
||||
| T28 | Before hand-over, break the reference on purpose, one line at a time (drop a check, move a bound by one, skip a save), and run the given tests against each change. A change the tests still pass is a missing test, unless it cannot change behaviour. Rustfmt reflows lines: match on text that survives formatting. | M4a planning: 69 changes over six modules; the tests missed 8. Five were real gaps and got tests (a reconnect that re-sent "interrupted" for a running turn was one), two could not change behaviour, and one was left (an event frame with another id, which `loopd` never sends). |
|
||||
|
||||
## What worked and should be kept
|
||||
|
||||
- Byte-exact fixtures, compared in both directions. No wire-format defect reached review.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# M4a task 01: SHA-1 in `proto`
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `proto: sha1, for the WebSocket handshake check`
|
||||
|
||||
## Goal
|
||||
|
||||
`gatewayd` speaks WebSocket to Mattermost. The server proves it understood the handshake by
|
||||
sending back the SHA-1 of our key (RFC 6455, section 4.2.2), so we need SHA-1. It lives in
|
||||
`proto`, which has no dependencies of its own. It is used for that check and nothing else: it must
|
||||
never protect anything.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/proto/tests/sha1.rs`, and the skeleton `crates/proto/src/sha1.rs`
|
||||
- Modify: `crates/proto/src/lib.rs` (`pub mod sha1;`), `docs/implementer-log.md`
|
||||
|
||||
## Interface (in the skeleton)
|
||||
|
||||
```rust
|
||||
pub fn sha1(data: &[u8]) -> [u8; 20]; // written: new, update, finish
|
||||
|
||||
pub struct Sha1 { state: [u32; 5], block: [u8; 64], filled: usize, length: u64 }
|
||||
impl Sha1 {
|
||||
pub fn new() -> Sha1; // written: the five initial words
|
||||
pub fn update(&mut self, data: &[u8]); // todo
|
||||
pub fn finish(self) -> [u8; 20]; // todo
|
||||
fn compress(&mut self, block: &[u8; 64]); // todo
|
||||
}
|
||||
```
|
||||
|
||||
`length` counts **bits** fed so far. Each `todo!()` has its steps above it. No indexing that can go
|
||||
out of bounds: `w[i]` on the fixed `[u32; 80]` inside `compress` is fine; slicing `data` or `block`
|
||||
by a computed range is not (use `get`, `get_mut`, `split_at`). Use `as_chunks::<4>()` to read
|
||||
big-endian words.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/proto/tests/sha1.rs crates/proto/tests/` and
|
||||
`cp docs/plans/M4a/files/crates/proto/src/sha1.rs crates/proto/src/`. Add `pub mod sha1;` to
|
||||
`crates/proto/src/lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p proto --test sha1`. Expected: it compiles, and 4 tests
|
||||
fail with `not yet implemented`.
|
||||
- [ ] **3. Fill the three functions**, one at a time: `compress`, then `update`, then `finish`. Run
|
||||
`cargo check -p proto` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p proto --test sha1`. Expected: 4 passed. The tests include
|
||||
FIPS 180 and RFC 3174 vectors, a million `a`s, the lengths around the 64-byte block where the
|
||||
padding changes shape (checked with `sha1sum`), and the RFC 6455 handshake example; every case is
|
||||
also fed in pieces.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/proto docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p proto --test sha1` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test vector fails and you cannot find why after checking `compress` against the comment twice.
|
||||
@@ -0,0 +1,86 @@
|
||||
# M4a task 02: `gatewayd`'s dependencies and test certificates
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: dependencies for TLS and secrets; test-only certificates`
|
||||
|
||||
## Goal
|
||||
|
||||
`gatewayd` talks to Mattermost over TLS when the URL is `https` (the owner's decision: the tailnet
|
||||
serves it that way). This task adds the dependencies it needs and nothing that uses them yet, so
|
||||
that the next tasks can. It also copies in the test-only certificates the TLS tests use.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `deny.toml` (replaces the old one), `crates/gatewayd/tests/fixtures/tls/` (the whole
|
||||
directory: 9 files)
|
||||
- Modify: `Cargo.toml`, `crates/gatewayd/Cargo.toml`, `crates/gatewayd/src/lib.rs`,
|
||||
`docs/dependencies.md`, `docs/implementer-log.md`, `Cargo.lock`
|
||||
|
||||
## The changes, exactly
|
||||
|
||||
**`Cargo.toml`**, at the end of `[workspace.dependencies]`, after `emsha`:
|
||||
|
||||
```toml
|
||||
rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
zeroize = "1.9.0"
|
||||
```
|
||||
|
||||
**`crates/gatewayd/Cargo.toml`**, `[dependencies]` becomes:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
proto.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-native-certs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
zeroize.workspace = true
|
||||
```
|
||||
|
||||
**`crates/gatewayd/src/lib.rs`**: replace its one comment line with
|
||||
|
||||
```rust
|
||||
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
|
||||
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
||||
```
|
||||
|
||||
**`deny.toml`**: copied, not edited. It allows the licences ISC and BSD-3-Clause (owner's approval,
|
||||
2026-09-23), and judges dependencies only for Linux and the Mac, since `rustls` pulls in a second
|
||||
`windows-sys` for Windows that we never build.
|
||||
|
||||
**`docs/dependencies.md`**: in the table, the rows for `serde` and `serde_json` add `` `gatewayd` ``
|
||||
to "Used by", the `toml` row adds `` `gatewayd` from M4a ``, and three rows are added at the end:
|
||||
|
||||
```markdown
|
||||
| `rustls` | 0.23.45 | `gatewayd` | TLS to Mattermost over the tailnet (M4a, owner's decision). `default-features = false` with `ring`, `std`, `tls12`. With its dependencies on Linux: `ring` (Apache-2.0 AND ISC; builds C and assembly), `rustls-webpki` and `untrusted` (ISC), `rustls-pki-types`, `subtle` (BSD-3-Clause), `zeroize`, `once_cell`, `getrandom`, `libc`, `cfg-if`. Apache-2.0 OR ISC OR MIT. |
|
||||
| `rustls-native-certs` | 0.8.4 | `gatewayd` | The host's trusted certificates, so a CA installed on the host is trusted too; adds `openssl-probe`. Apache-2.0 OR ISC OR MIT. |
|
||||
| `zeroize` | 1.9.0 | `gatewayd` | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. |
|
||||
```
|
||||
|
||||
The fixtures' `README.md` says what each certificate is. Every key in that directory is public and
|
||||
for these tests only.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then `cp docs/plans/M4a/files/deny.toml deny.toml` and
|
||||
`mkdir -p crates/gatewayd/tests/fixtures && cp -r docs/plans/M4a/files/crates/gatewayd/tests/fixtures/tls crates/gatewayd/tests/fixtures/`
|
||||
- [ ] **2. Make the four edits above.**
|
||||
- [ ] **3. Build once.** `cargo build -p gatewayd`, so that `Cargo.lock` is updated. Expected: it
|
||||
builds (the crates are already in the local cache; if cargo says it needs the network and cannot
|
||||
reach it, stop and report).
|
||||
- [ ] **4. Check.** `git status --short` lists `Cargo.lock`, `Cargo.toml`, `deny.toml`, the two
|
||||
`gatewayd` files, `docs/dependencies.md` and the fixtures directory, and nothing else.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
`cargo-deny` must pass: if it reports a licence or a duplicate, stop and report it word for word.
|
||||
- [ ] **6. Log and commit.**
|
||||
`git add Cargo.toml Cargo.lock deny.toml crates/gatewayd docs/dependencies.md docs/implementer-log.md && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with the new dependencies in `Cargo.lock`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `cargo-deny` fails, or cargo wants a version other than the ones above.
|
||||
@@ -0,0 +1,95 @@
|
||||
# M4a task 03: `gatewayd.toml`
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: config, gatewayd.toml into a checked Config`
|
||||
|
||||
## Goal
|
||||
|
||||
`gatewayd.toml` says where Mattermost is, where the token comes from, who may talk to Boxmaker and
|
||||
in which channels, and a few limits. It is **our** format: an unknown key anywhere is an error, and
|
||||
a value that is wrong is refused at start with a message that names it. Spec section 3.
|
||||
|
||||
```toml
|
||||
[mattermost]
|
||||
url = "https://straylight.scylla-hammerhead.ts.net" # https or http; no path
|
||||
ca_file = "/etc/boxmaker/extra-ca.pem" # optional; added to the system's roots
|
||||
|
||||
[secrets.mattermost_token]
|
||||
credential = "mattermost-token" # exactly one of: credential, env, file
|
||||
|
||||
[allow]
|
||||
users = ["abcdefghijklmnopqrstuvwxyz"] # Mattermost user ids (26 characters)
|
||||
channels = [] # channels and group messages allowed, by id; default none
|
||||
|
||||
[loop]
|
||||
socket = "" # empty: <home>/run/loop/loop.sock
|
||||
|
||||
[paths]
|
||||
home = "/var/lib/boxmaker" # default: BOXMAKER_HOME, then /var/lib/boxmaker
|
||||
|
||||
[limits]
|
||||
queue = 20
|
||||
typing_every_ms = 3000
|
||||
ping_every_ms = 30000
|
||||
dead_after_ms = 60000
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/config.rs`, `crates/gatewayd/tests/support/tmp.rs`, and the
|
||||
skeleton `crates/gatewayd/src/config.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod config;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Every type is written, with its serde attributes: `Config`, `MattermostConfig`, `SecretSpec`,
|
||||
`SecretSource`, `AllowConfig`, `LoopConfig`, `Paths`, `Limits` (with their defaults), `ServerUrl`,
|
||||
`ConfigError`, and the constant `MATTERMOST_TOKEN = "mattermost_token"`. Do not change them. The
|
||||
functions are `todo!()`, each with its steps above it.
|
||||
|
||||
## The messages, exactly
|
||||
|
||||
`Config::problem` returns the first problem, in this order:
|
||||
|
||||
| Problem | Message |
|
||||
|---|---|
|
||||
| `url` fails `parse_url` | `parse_url`'s message: `[mattermost] url "<url>" must be http:// or https://, a host, an optional port, and nothing else` (the url with `{:?}`) |
|
||||
| `ca_file` not absolute | `[mattermost] ca_file "<path>" must be an absolute path` (`{:?}`) |
|
||||
| no `[secrets.mattermost_token]` | `[secrets.mattermost_token] is missing` |
|
||||
| a secret whose `source()` fails | `[secrets.<name>] <source's message>` |
|
||||
| `allow.users` empty | `[allow] users is empty: a gateway that answers nobody is a mistake` |
|
||||
| an id in `allow.users` or `allow.channels` that is not `valid_id` | `[allow] "<id>" is not a Mattermost id (26 characters of a-z and 0-9)` (`{:?}`) |
|
||||
| a limit that is 0 (checked in the order queue, typing_every_ms, ping_every_ms, dead_after_ms) | `[limits] <name> must be at least 1` |
|
||||
|
||||
`SecretSpec::source`'s messages:
|
||||
|
||||
| Case | Message |
|
||||
|---|---|
|
||||
| not exactly one of the three | `needs exactly one of credential, env and file` |
|
||||
| `credential` empty or with a byte that is not an ASCII letter, digit, `_`, `.` or `-` | `credential "<name>" is not a credential name (letters, digits, _ . -)` |
|
||||
| `env` empty or with a byte that is not `A-Z`, `0-9` or `_` | `env "<var>" is not a variable name (A-Z, 0-9, _)` |
|
||||
| `file` not absolute | `file "<path>" must be an absolute path` |
|
||||
|
||||
`Config::token_source` gives `source()` of `mattermost_token`, or `[secrets.mattermost_token] is
|
||||
missing`. `ConfigError`'s `Display` is `<path>: <error or message>`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`mkdir -p crates/gatewayd/tests/support && cp docs/plans/M4a/files/crates/gatewayd/tests/config.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/tmp.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/config.rs crates/gatewayd/src/`.
|
||||
Add `pub mod config;` to `crates/gatewayd/src/lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test config`. Expected: it compiles and 7 tests
|
||||
fail with `not yet implemented`.
|
||||
- [ ] **3. Fill the functions one at a time**: `valid_id`, `parse_url`, `SecretSpec::source`,
|
||||
`ConfigError`'s `fmt`, then the `Config` methods. `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test config`. Expected: 7 passed.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test config` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test wants a type changed. The types are the format; report instead.
|
||||
@@ -0,0 +1,85 @@
|
||||
# M4a task 04: secrets, and the runbook entries for `gatewayd`
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: secrets from a credential, the environment or a file; runbook entries`
|
||||
|
||||
## Goal
|
||||
|
||||
`gatewayd`'s one secret is the Mattermost token. It can come from three places, chosen per secret
|
||||
in `gatewayd.toml` (spec section 4, P15 in the brief):
|
||||
|
||||
| Form | Reads | Refused when |
|
||||
|---|---|---|
|
||||
| `credential = "<name>"` | `$CREDENTIALS_DIRECTORY/<name>`, which systemd fills for a service with `LoadCredentialEncrypted=` | the variable is unset, or the file cannot be read |
|
||||
| `env = "<VAR>"` | the environment variable | unset, or not UTF-8 |
|
||||
| `file = "<absolute path>"` | the file | not absolute; a symbolic link; not a regular file; not owned by the user `gatewayd` runs as; any group or other permission bit set |
|
||||
|
||||
In every form one trailing newline is removed, and an empty value is refused. A secret read from a
|
||||
**file** is in plaintext on disk: `gatewayd` still starts, but `load` returns this warning for it,
|
||||
exactly (`<name>` and `<path>` filled in, `path.display()`):
|
||||
|
||||
```text
|
||||
gatewayd: warning: secret <name> is read in plaintext from <path>; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)
|
||||
```
|
||||
|
||||
The value must never reach a log: `Secret` has no `Display`, its `Debug` prints `Secret(…)`, it is
|
||||
wiped when dropped (`zeroize::Zeroizing`), and `expose()` is the only way to the text.
|
||||
|
||||
This task also adds the runbook entries for every fail-closed state of `gatewayd`, all seven at
|
||||
once, so that later tasks can point at them.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/secrets.rs`, and the skeleton `crates/gatewayd/src/secrets.rs`
|
||||
- Append: `docs/plans/M4a/files/runbook-gatewayd.md` to the end of `docs/runbook.md`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod secrets;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `Secret` (with `new`, `expose` and its `Debug`), `Loaded { secret, warning }`,
|
||||
`SecretError { name, why }` and its `Display` (`secret <name>: <why>` then a newline and
|
||||
`see docs/runbook.md#secret-unavailable`), and the constants `RUNBOOK` and `RUNBOOK_FILE`. To fill:
|
||||
`load`, `check_file` and `value`.
|
||||
|
||||
`load` takes the environment as a function, `env: &dyn Fn(&str) -> Option<OsString>`, so that tests
|
||||
can pass their own: read `CREDENTIALS_DIRECTORY` and `env` variables through it, never through
|
||||
`std::env`. The uid of the user `gatewayd` runs as is the owner of `/proc/self`
|
||||
(`std::fs::metadata("/proc/self")?.uid()`, from `std::os::unix::fs::MetadataExt`); no `unsafe`, no
|
||||
`libc`.
|
||||
|
||||
The `why` of each error, for the reader: say what is wrong and never the value. The reference used
|
||||
these, and you may too:
|
||||
|
||||
| Case | `why` |
|
||||
|---|---|
|
||||
| credential, variable unset | `CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=` |
|
||||
| credential, unreadable | `cannot read the credential <path>: <error>` |
|
||||
| env unset | `the environment variable <VAR> is not set` |
|
||||
| env not UTF-8 | `the environment variable <VAR> is not UTF-8` |
|
||||
| file checks | `<path> is not an absolute path`, `cannot read <path>: <error>`, `<path> is a symbolic link`, `<path> is not a regular file`, `<path> is not owned by the user gatewayd runs as`, `<path> has mode <mode as {:03o}>; only the owner may read it (0600 or 0400)` |
|
||||
| the value | `the value is not UTF-8`, `the value is empty` |
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/secrets.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/secrets.rs crates/gatewayd/src/`
|
||||
and `cat docs/plans/M4a/files/runbook-gatewayd.md >> docs/runbook.md`. Add `pub mod secrets;` to
|
||||
`crates/gatewayd/src/lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test secrets`. Expected: it compiles and 8
|
||||
tests fail.
|
||||
- [ ] **3. Fill `value`, then `check_file`, then `load`.** `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test secrets`. Expected: 8 passed.
|
||||
- [ ] **5. Check the runbook.** `grep -c '^## ' docs/runbook.md` is 7 more than before step 1, and
|
||||
`sh scripts/check-runbook.sh` prints nothing and exits 0.
|
||||
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **7. Log and commit.**
|
||||
`git add crates/gatewayd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test secrets` passes; the seven entries are in `docs/runbook.md`;
|
||||
`make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test needs the secret's value in an error message, or `unsafe` looks necessary.
|
||||
@@ -0,0 +1,63 @@
|
||||
# M4a task 05: a connection, plain or TLS
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: net, TCP or verified TLS to the Mattermost server`
|
||||
|
||||
## Goal
|
||||
|
||||
Every REST call and the WebSocket run over one kind of stream: plain TCP for an `http` URL, or TCP
|
||||
wrapped in TLS through `rustls` for `https`. TLS verifies the server's name against the host in the
|
||||
URL and its chain against the host's trusted certificates (`rustls-native-certs`) plus `ca_file` if
|
||||
given. **Nothing can turn verification off.** Spec section 5.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/net.rs`, `crates/gatewayd/tests/support/tls_server.rs`, and the
|
||||
skeleton `crates/gatewayd/src/net.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod net;`), `crates/gatewayd/Cargo.toml` (below),
|
||||
`docs/implementer-log.md`
|
||||
|
||||
The tests' TLS server needs `rustls` too. Add to the end of `crates/gatewayd/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
|
||||
[dev-dependencies]
|
||||
rustls.workspace = true
|
||||
```
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written, because they are mostly `rustls` API (checked against rustls 0.23.45):
|
||||
`client_config(ca_file)`, which builds the root store and the `ClientConfig` with the `ring`
|
||||
provider, and `Connector::connect`, which connects to each address in turn, sets the read and
|
||||
write timeouts, and for TLS runs the handshake to its end so that a bad certificate is an error
|
||||
from `connect` and not from a later read. Read both before you start.
|
||||
|
||||
Also written: `NetError` (`Roots`, `Connect`, `Tls`) and its `Display`, and the types `Stream`
|
||||
(`Plain(TcpStream)` or `Tls(Box<StreamOwned<ClientConnection, TcpStream>>)`) and `Connector`.
|
||||
|
||||
To fill: `Stream::tcp`, `Stream::set_read_timeout`, `Read` and `Write` for `Stream` (forward to
|
||||
the inner stream in each variant), `Connector::new` and `Connector::server`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/net.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/tls_server.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/net.rs crates/gatewayd/src/`.
|
||||
Add `pub mod net;` to `lib.rs` and the `[dev-dependencies]` above.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test net`. Expected: it compiles and 7 tests
|
||||
fail.
|
||||
- [ ] **3. Fill the functions**, `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test net`. Expected: 7 passed: plain TCP; TLS
|
||||
with the test CA; an unknown CA and a wrong name refused at connect; TLS to a plain server fails
|
||||
without hanging; a bad `ca_file` refused before any connection; nobody listening.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test net` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test passes only with verification weakened in any way (a custom verifier, a skipped name
|
||||
check). That is never the fix.
|
||||
@@ -0,0 +1,57 @@
|
||||
# M4a task 06: HTTP/1.1 requests
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: http, requests over a stream with size caps and rate-limit waits`
|
||||
|
||||
## Goal
|
||||
|
||||
Mattermost's REST calls are HTTP/1.1, one connection per request (`Connection: close`), over a
|
||||
`net::Stream`. This module writes a request and reads the response. The server is not trusted: a
|
||||
response head over 16 KiB or a body over 4 MiB is an error, and every limit is checked **before**
|
||||
reading or allocating. The WebSocket handshake (task 07) reuses `read_head`, which therefore must
|
||||
not read a byte past the blank line that ends the head: the WebSocket's first frame may follow at
|
||||
once.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/http.rs`, and the skeleton `crates/gatewayd/src/http.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod http;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: the constants `MAX_HEAD` (16 KiB), `MAX_BODY` (4 MiB) and `MAX_RATE_WAIT` (60 s);
|
||||
`HttpError` (`Io`, `Protocol(String)`, `TooLarge(&'static str)`) with `Display` and
|
||||
`From<std::io::Error>`; `Head { status, headers }` and `Response { head, body }`.
|
||||
|
||||
To fill, each with its steps above it: `Head::header`, `write_request`, `request`, `read_head`,
|
||||
`read_body`, `read_line`, `read_chunked`, `rate_limit_wait`.
|
||||
|
||||
Rules that apply to all of them:
|
||||
|
||||
- `read_head` reads **one byte at a time**. That is slow and it is right: it must stop exactly at
|
||||
the end of the head.
|
||||
- A retry on `ErrorKind::Interrupted`, and nowhere else.
|
||||
- No subtraction that can wrap and no indexing that can go out of bounds: `saturating_sub`, `get`,
|
||||
`get_mut`. `raw.truncate(raw.len() - 2)` is fine only right after checking `raw.ends_with(b"\r\n")`.
|
||||
- `rate_limit_wait` is about Mattermost's `X-Ratelimit-Reset`, which is a Unix time in seconds; the
|
||||
test also gives it a small number of seconds, which some servers send.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/http.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/http.rs crates/gatewayd/src/`.
|
||||
Add `pub mod http;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test http`. Expected: it compiles and 6 tests
|
||||
fail.
|
||||
- [ ] **3. Fill the functions in the order above**, `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test http`. Expected: 6 passed.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test http` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test needs `read_head` to read ahead into a buffer.
|
||||
@@ -0,0 +1,63 @@
|
||||
# M4a task 07: the WebSocket handshake
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: ws, the WebSocket error type and the opening handshake`
|
||||
|
||||
## Goal
|
||||
|
||||
Mattermost pushes events over a WebSocket at `/api/v4/websocket` (RFC 6455). Our client is our own:
|
||||
blocking, one connection, tested against hostile input. This task is its first part: the error
|
||||
type, base64, and the opening handshake (RFC 6455, section 4.1; spec section 6).
|
||||
|
||||
The request, exactly (it is written for you in `request_text`):
|
||||
|
||||
```text
|
||||
GET <path> HTTP/1.1\r\n
|
||||
Host: <host>\r\n
|
||||
Upgrade: websocket\r\n
|
||||
Connection: Upgrade\r\n
|
||||
Sec-WebSocket-Key: <16 random bytes in base64>\r\n
|
||||
Sec-WebSocket-Version: 13\r\n
|
||||
Authorization: Bearer <token>\r\n
|
||||
\r\n
|
||||
```
|
||||
|
||||
The answer must be status 101, with `Upgrade: websocket`, a `Connection` header holding the token
|
||||
`upgrade`, and `Sec-WebSocket-Accept` equal to base64(SHA-1(key + the GUID
|
||||
`258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)). Anything else is an error. SHA-1 is `proto::sha1::sha1`
|
||||
(task 01); the head is read with `http::read_head` (task 06), which stops at the blank line.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/ws_handshake.rs`, and `crates/gatewayd/src/ws/mod.rs` (complete)
|
||||
and the skeleton `crates/gatewayd/src/ws/handshake.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod ws;`), `docs/implementer-log.md`
|
||||
|
||||
`ws/mod.rs` is complete: `WsError` (`Handshake`, `Protocol`, `TooLarge`, `Closed`, `Dead`, `Io`)
|
||||
with `Display` and `From<std::io::Error>`, and `pub mod handshake;`. Tasks 08 and 09 add their
|
||||
`pub mod` lines to it.
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `GUID`, `ALPHABET`, `request_text`. To fill: `base64`, `accept_for`, `new_key`,
|
||||
`check_response`, `handshake`. The key's 16 bytes come from `random: &mut dyn Read` (in `gatewayd`,
|
||||
`/dev/urandom`; in the tests, fixed bytes), so the tests know the key.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`mkdir -p crates/gatewayd/src/ws && cp docs/plans/M4a/files/crates/gatewayd/src/ws/mod.rs docs/plans/M4a/files/crates/gatewayd/src/ws/handshake.rs crates/gatewayd/src/ws/ && cp docs/plans/M4a/files/crates/gatewayd/tests/ws_handshake.rs crates/gatewayd/tests/`.
|
||||
Add `pub mod ws;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_handshake`. Expected: it compiles; 6
|
||||
fail and 1 passes (`the_request_is_exactly_this`, since `request_text` is given).
|
||||
- [ ] **3. Fill `base64`, `accept_for`, `new_key`, `check_response`, `handshake`**, in that order,
|
||||
`cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_handshake`. Expected: 7 passed,
|
||||
including the RFC 6455 example (`dGhlIHNhbXBsZSBub25jZQ==` gives `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`)
|
||||
and a handshake whose first frame arrives in the same packet as the head and is left unread.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test ws_handshake` passes; `make gate` prints `gate: ok`.
|
||||
@@ -0,0 +1,73 @@
|
||||
# M4a task 08: WebSocket frames
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: ws frames, a strict decoder and a masked encoder`
|
||||
|
||||
## Goal
|
||||
|
||||
After the handshake, everything is frames (RFC 6455, section 5). This task is the codec, without
|
||||
I/O: a `Decoder` that is fed bytes as they arrive, in any pieces, and yields whole messages, and
|
||||
`encode`, which builds our frames. Everything the server sends is untrusted, so the decoder is
|
||||
strict and **checks every length before it allocates** (spec section 6):
|
||||
|
||||
- A frame from the server that is masked, sets a reserved bit, uses an opcode other than
|
||||
continuation (0), text (1), close (8), ping (9) or pong (10), is a control frame (8 to 10) over
|
||||
125 bytes or not final, is a continuation with nothing to continue, or a new text frame while a
|
||||
message is unfinished, is a `Protocol` error.
|
||||
- A length not in its shortest form (126 for a length under 126; 127 for one that fits in 16
|
||||
bits), or a 64-bit length with its top bit set, is a `Protocol` error.
|
||||
- A message over `MAX_MESSAGE` (1 MiB), counted from the length fields of its frames **before**
|
||||
their payloads arrive, is `TooLarge`.
|
||||
- Text must be UTF-8 once whole (a character may be split across frames).
|
||||
- A close frame's payload is empty, or a 2-byte code and a UTF-8 reason; one byte is an error.
|
||||
|
||||
Every frame **we** send is final, masked with the 4 bytes we are given.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/ws_frame.rs`, and the skeleton `crates/gatewayd/src/ws/frame.rs`
|
||||
- Modify: `crates/gatewayd/src/ws/mod.rs` (`pub mod frame;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `MAX_MESSAGE`, the opcode constants, `Incoming` (`Text`, `Ping`, `Pong`,
|
||||
`Close(Option<u16>, String)`), `Decoder { buf, partial }` with `new` and `feed`, and the private
|
||||
`Header { fin, opcode, header_len, payload_len }`.
|
||||
|
||||
To fill, in this order, each with its steps above it:
|
||||
|
||||
1. `Decoder::header(&self) -> Result<Option<Header>, WsError>`: the next frame's header once all of
|
||||
it is in `buf`, checked against every rule above that does not need the payload. `Ok(None)`
|
||||
means "wait for more bytes". It only reads `buf`; it removes nothing.
|
||||
2. `close(payload) -> Result<Incoming, WsError>`.
|
||||
3. `Decoder::next_message(&mut self)`: uses `header`, and when the whole frame is in `buf`, takes it
|
||||
out (`drain`) and acts on it. Control frames may come between the frames of a text message and
|
||||
are returned at once.
|
||||
4. `encode(opcode, payload, mask) -> Vec<u8>`.
|
||||
|
||||
No indexing that can go out of bounds, no `as` casts: read lengths with `get(2..4)` and
|
||||
`u16::from_be_bytes`, and convert with `usize::try_from` / `u8::try_from`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/ws_frame.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/ws/frame.rs crates/gatewayd/src/ws/`.
|
||||
Add `pub mod frame;` to `crates/gatewayd/src/ws/mod.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_frame`. Expected: it compiles and 7
|
||||
tests fail.
|
||||
- [ ] **3. Fill the four functions**, `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_frame`. Expected: 7 passed. The last
|
||||
test feeds 300 random streams, half of them with bits flipped, in random pieces, and compares
|
||||
your decoder with a simple one written inside the test: they must return the same messages, and
|
||||
both fail or both not.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test ws_frame` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The random-stream test fails on a case you believe the naive decoder gets wrong. Report the seed
|
||||
and the bytes; do not change the test.
|
||||
@@ -0,0 +1,71 @@
|
||||
# M4a task 09: the WebSocket connection
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: ws conn, messages, pings, closing and a dead peer`
|
||||
|
||||
## Goal
|
||||
|
||||
The connection that uses tasks 07 and 08: open it, send text, and wait for the next message while
|
||||
keeping the connection alive (spec section 6):
|
||||
|
||||
- A ping from the server is answered with a pong carrying the same payload.
|
||||
- We send a ping every `ping_every`. No bytes at all for `dead_after` means the peer is dead.
|
||||
- A close frame is answered with a close frame, and the connection ends (`Closed`); so does the end
|
||||
of the stream.
|
||||
- Any protocol error ends the connection. `gatewayd` then reconnects (task 14); nothing panics.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/ws_conn.rs`, `crates/gatewayd/tests/support/ws_server.rs`, and the
|
||||
skeleton `crates/gatewayd/src/ws/conn.rs`
|
||||
- Modify: `crates/gatewayd/src/ws/mod.rs` (`pub mod conn;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
```rust
|
||||
pub const PATH: &str = "/api/v4/websocket";
|
||||
pub struct Timing { pub ping_every: Duration, pub dead_after: Duration } // Clone, Copy
|
||||
pub struct Ws { stream, decoder, random, timing, last_heard, last_ping }
|
||||
|
||||
impl Ws {
|
||||
pub fn open(connector: &Connector, token: &str, timing: Timing,
|
||||
mut random: Box<dyn Read + Send>) -> Result<Ws, WsError>;
|
||||
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError>;
|
||||
pub fn send_text(&mut self, text: &str) -> Result<(), WsError>;
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError>;
|
||||
pub fn close(mut self);
|
||||
}
|
||||
pub fn host_header(server: &ServerUrl) -> String;
|
||||
```
|
||||
|
||||
All are `todo!()`, each with its steps above it. `poll` is the one with the most in it: it returns
|
||||
the next text message, or `None` after about `wait` with none, and does the pinging, the pong
|
||||
answers and the dead-peer check on the way. Its read timeout is always the time to the **next
|
||||
thing it must do** (the end of `wait`, the next ping, or the dead-after limit), so a quiet
|
||||
connection neither spins nor oversleeps. Write it as its comment says, step by step; a helper
|
||||
function for step 1 is fine.
|
||||
|
||||
`random` gives the handshake key and a fresh 4-byte mask for every frame we send (in `gatewayd`,
|
||||
`/dev/urandom`).
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/ws_conn.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/ws_server.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/ws/conn.rs crates/gatewayd/src/ws/`.
|
||||
Add `pub mod conn;` to `crates/gatewayd/src/ws/mod.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_conn`. Expected: it compiles and 10
|
||||
tests fail.
|
||||
- [ ] **3. Fill `host_header`, `send`, `send_text`, `open`, `close`, then `poll`**,
|
||||
`cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_conn`, five times. Expected: 10 passed
|
||||
each time, in under a second.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test ws_conn` passes five times running; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test passes only sometimes, or takes seconds.
|
||||
@@ -0,0 +1,70 @@
|
||||
# M4a task 10: Mattermost's events and REST calls
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: mm, Mattermost's events and the four REST calls, typed`
|
||||
|
||||
## Goal
|
||||
|
||||
Mattermost's JSON, typed, and the REST calls `gatewayd` makes. This is **another program's
|
||||
format**: unknown fields are ignored. But every id we keep ends up in a session id or a URL path,
|
||||
so every id must be a valid Mattermost id (26 characters of `a-z0-9`), or the whole post is refused.
|
||||
|
||||
Facts from Mattermost's source at the owner's server version, v11.11.0 (checked by the design
|
||||
model; the tests use these shapes):
|
||||
|
||||
- A WebSocket event is `{"event", "data", "broadcast", "seq"}`. The first is `hello`. `posted`
|
||||
carries the post as a **JSON string** in `data.post` (a string holding JSON, not an object), and
|
||||
the channel's type in `data.channel_type`: `D` direct, `G` group, `O` open, `P` private. A reply
|
||||
to one of our requests has no `event`.
|
||||
- `user_typing` is a request we send: `{"action": "user_typing", "seq": <n>, "data":
|
||||
{"channel_id", "parent_id"}}`; it shows the bot as typing in that thread.
|
||||
- `GET /api/v4/channels/{id}/posts?since=<ms>` returns `{"order": [ids], "posts": {id: post}}`
|
||||
with the posts **changed** after that time. Only the ids in `order` changed; `posts` also holds
|
||||
the roots of their threads, which may be old. Edited and deleted posts come back too. The server
|
||||
takes at most 1000 (`SINCE_LIMIT`); a full answer may have left some out.
|
||||
- REST errors: 401 or 403 is a refused token, 429 is a rate limit (wait for `X-Ratelimit-Reset`),
|
||||
5xx is worth trying again.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/mm_json.rs`, `crates/gatewayd/tests/mm_rest.rs`,
|
||||
`crates/gatewayd/tests/support/http_server.rs`, and the skeletons
|
||||
`crates/gatewayd/src/mm/mod.rs` and `crates/gatewayd/src/mm/rest.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod mm;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeletons
|
||||
|
||||
`mm/mod.rs`, written: `SINCE_LIMIT`; `MmError` (`Net`, `Auth(u16)`, `RateLimited(Duration)`,
|
||||
`Status(u16, String)`, `Json`) and its `Display`, which **quotes** a server's body (`{:?}`) so that
|
||||
a newline in it cannot forge a log line; `Me { id, username }`; `Post { id, user_id, channel_id,
|
||||
root_id, message, create_at, delete_at, kind }` (`kind` is the JSON `type`); `Event` (`Hello`,
|
||||
`Posted { post, channel_type }`, `Other(String)`); `Since { posts, full }`; and the private serde
|
||||
shapes `RawEvent` and `PostList`. To fill: `Post::check`, `json`, `parse_event`, `typing`,
|
||||
`since_list`.
|
||||
|
||||
`mm/rest.rs`, written: `RETRY_5XX` (500 ms), `RETRIES` (2), `BODY_KEPT` (200), `Client`. To fill:
|
||||
`new`, `connector`, `token`, `once`, `call`, `me`, `create_post`, `posts_since`, `direct_channel`.
|
||||
The token reaches the wire only in `once`, as `Authorization: Bearer <token>`; nowhere else calls
|
||||
`expose()`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`mkdir -p crates/gatewayd/src/mm && cp docs/plans/M4a/files/crates/gatewayd/src/mm/mod.rs docs/plans/M4a/files/crates/gatewayd/src/mm/rest.rs crates/gatewayd/src/mm/ && cp docs/plans/M4a/files/crates/gatewayd/tests/mm_json.rs docs/plans/M4a/files/crates/gatewayd/tests/mm_rest.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/http_server.rs crates/gatewayd/tests/support/`.
|
||||
Add `pub mod mm;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test mm_json --test mm_rest`.
|
||||
Expected: it compiles; `mm_json` 6 fail and 1 passes (the `Display` test), `mm_rest` 10 fail.
|
||||
- [ ] **3. Fill `mm/mod.rs`, then `mm/rest.rs`**, one function at a time, `cargo check -p gatewayd`
|
||||
after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test mm_json --test mm_rest`. Expected: 7 and
|
||||
10 passed. `mm_rest` takes about 3 s: two tests wait out a rate limit on purpose.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- Both suites pass; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test needs a post with an invalid id to be accepted.
|
||||
@@ -0,0 +1,80 @@
|
||||
# M4a task 11: which posts become turns, and the queue per session
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: sessions, routing posts to sessions, commands and the queue`
|
||||
|
||||
## Goal
|
||||
|
||||
The decisions, as pure code: no network, no files. For each new post, **in this order** (spec
|
||||
section 7):
|
||||
|
||||
1. Ignore it if it is the bot's own, or a system message (`kind` not empty).
|
||||
2. Ignore it, **silently**, unless the author is in `allow.users`.
|
||||
3. Is it for this Boxmaker? A direct message (`"D"`): always. A channel or group message (`"O"`,
|
||||
`"P"`, `"G"`) whose id is in `allow.channels`: if it **names this bot**, or if it is a reply in a
|
||||
thread this Boxmaker already has a session for **and names nobody else**. Anything else: ignore.
|
||||
`@channel`, `@here` and `@all` name nobody: every agent would answer them.
|
||||
4. The session: its root is `root_id`, or the post's own id when that is empty; the session id is
|
||||
`mm-<root>`. A top-level post is a new session (`resume: false`); a reply resumes it.
|
||||
5. Commands: a message starting with `!`. `!!…` is not a command: one `!` is removed and the rest
|
||||
goes on as a message. `!approve …` and `!deny …` are answered with `M4B_COMMAND`; any other `!`
|
||||
with `UNKNOWN_COMMAND`. A command never reaches `loopd`.
|
||||
6. Otherwise the message joins its session's queue.
|
||||
|
||||
"Names" means `@<username>` in the message, case-insensitive, where the name is the longest run of
|
||||
`a-z`, `0-9`, `.`, `-` and `_` after the `@`, without trailing dots. The spec's examples, with this
|
||||
bot called `boxmaker-straylight` and another agent called Hermes in the channel (the first test
|
||||
checks every row):
|
||||
|
||||
| Post | For Boxmaker? |
|
||||
|---|---|
|
||||
| `@boxmaker-straylight summarise the audit log` (top level) | yes: a new session rooted here |
|
||||
| a reply in that thread: `and the older files?` | yes: its thread, nobody else named |
|
||||
| a reply in that thread: `@hermes what do you think?` | no |
|
||||
| `@boxmaker-straylight @hermes compare notes` | yes (and Hermes answers too) |
|
||||
| `@boxmaker-straylightx hello` | no: a different name |
|
||||
| `@channel standup in five` | no |
|
||||
|
||||
**The queue.** One turn at a time per session. A message for a session with no turn running
|
||||
starts one at once. Messages that arrive while it runs wait; when it ends, **all** waiting messages
|
||||
go together as the next turn, joined with a blank line (`"\n\n"`), in the order they came. A
|
||||
session with `limit` messages already waiting drops the next one, and the caller answers `BUSY`.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/sessions.rs`, and the skeleton `crates/gatewayd/src/sessions.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod sessions;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: the three answer texts (`M4B_COMMAND`, `UNKNOWN_COMMAND`, `BUSY`), `EVERYONE`, `Ignored`
|
||||
(`Own`, `System`, `NotAllowed`, `NotForUs`), `Thread { channel, root }`, `Message { session,
|
||||
thread, resume, text, joins_thread }`, `Route` (`Ignore`, `Reply { thread, text }`, `Queue`),
|
||||
`Router`, `Batch { session, thread, resume, text }`, `Pushed` (`Start(Batch)`, `Waiting`,
|
||||
`Full(Thread)`), `Pending`, `Queues`.
|
||||
|
||||
To fill: `named`, `Router::new`, `Router::for_us`, `Router::route`, `Queues::new`, `push`,
|
||||
`finish`, `running`, `threads`. `route` is a straight line of early returns in the order above; the
|
||||
`known` function it is given answers "does this Boxmaker have a session for this thread root?"
|
||||
(task 14 answers it from the state file). `SessionId` has no `Ord`, so `Queues` keys a `HashMap`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/sessions.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/sessions.rs crates/gatewayd/src/`.
|
||||
Add `pub mod sessions;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test sessions`. Expected: it compiles and 8
|
||||
tests fail.
|
||||
- [ ] **3. Fill `named`, then the `Router`, then the `Queues`**, `cargo check -p gatewayd` after
|
||||
each function.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test sessions`. Expected: 8 passed.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test sessions` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A row of the table above seems to need a different order of the steps.
|
||||
@@ -0,0 +1,63 @@
|
||||
# M4a task 12: the state file
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: state, what was handled, our threads and turns in flight`
|
||||
|
||||
## Goal
|
||||
|
||||
`<home>/gateway/state.json` lets `gatewayd` pick up where it left off (spec section 9):
|
||||
|
||||
```json
|
||||
{"channels": {"<channel id>": 1758650000000}, "recent": ["<post id>", …],
|
||||
"threads": ["<root post id>", …],
|
||||
"in_flight": [{"session": "mm-…", "channel": "<id>", "root": "<id>"}]}
|
||||
```
|
||||
|
||||
- `channels`: the `create_at` of the last post handled in each channel; catching up starts there.
|
||||
A mark never moves back.
|
||||
- `recent`: the ids of the last 500 posts handled (`RECENT_KEPT`), so a post seen twice (live and
|
||||
in a catch-up) is handled once.
|
||||
- `threads`: the roots of the threads this Boxmaker takes part in, in channels; the newest 5,000
|
||||
(`THREADS_KEPT`).
|
||||
- `in_flight`: turns sent to `loopd` and not yet answered; after a restart each gets an
|
||||
"interrupted" reply.
|
||||
|
||||
It is **our** format: unknown fields are errors, and every id must be a valid Mattermost id. It is
|
||||
written atomically after every change, in the same six steps as `brokerd/src/state.rs`'s
|
||||
`persist` (read it). A file that exists but cannot be read, parsed or written stops `gatewayd`
|
||||
with `see docs/runbook.md#gateway-state-damaged`: guessing would answer posts twice. **Only a
|
||||
missing file** is a first start.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/state.rs`, and the skeleton `crates/gatewayd/src/state.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod state;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `RECENT_KEPT`, `THREADS_KEPT`, `RUNBOOK`, `StateError` (`Read(PathBuf, String)`,
|
||||
`Write(PathBuf, io::Error)`) and its `Display` (the path, the problem, and the pointer on its own
|
||||
line), `InFlight`, the private `StateFile`, `State { path, file }`.
|
||||
|
||||
To fill: `StateFile::problem`, `State::load`, `save`, `persist`, and the methods `seen`, `handled`,
|
||||
`since`, `channels`, `mark`, `knows_thread`, `join_thread`, `start_turn`, `end_turn`,
|
||||
`take_in_flight`. Every method that changes the state saves it before it returns, and returns the
|
||||
save's error.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/state.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/state.rs crates/gatewayd/src/`.
|
||||
Add `pub mod state;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test state`. Expected: it compiles and 4 tests
|
||||
fail.
|
||||
- [ ] **3. Fill `problem`, `load`, `persist`, `save`, then the methods**, `cargo check -p gatewayd`
|
||||
after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test state`. Expected: 4 passed, in well under a
|
||||
second.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test state` passes; `make gate` prints `gate: ok`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# M4a task 13: a turn on `loop.sock`, and its answer in the thread
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: deliver, turns on loop.sock and answers posted in their thread`
|
||||
|
||||
## Goal
|
||||
|
||||
A batch of messages (task 11) becomes one `turn` on `loopd`'s socket, exactly as `bxctl chat` sends
|
||||
one (read `crates/bxctl/src/chat.rs`, `run_turn`): one frame `{"kind": "turn", "body": {"session",
|
||||
"content", "resume"}}` with id 1, then events until a final `turn_done` or `error`. What comes back
|
||||
is posted in the thread (spec section 8):
|
||||
|
||||
- No thinking and no status reach Mattermost. The one event that is shown: `approval_pending`,
|
||||
posted once as `waiting for approval <id>: approve or deny it with `bxctl` (Mattermost approvals
|
||||
arrive in M4b)` (the skeleton's `approval_text`).
|
||||
- `turn_done` → its content, split into posts of at most 16,000 **characters** (a Mattermost post
|
||||
holds 16,383), at the last newline before the limit, or at the limit when there is none, in
|
||||
order. An empty answer is posted as `(the answer was empty)`: Mattermost refuses an empty post.
|
||||
- An `error` frame → `Error: <code>: <detail>`, where the code is the snake_case name
|
||||
(`no_such_session`), and the detail carries `loopd`'s runbook pointer when it has one.
|
||||
- `loop.sock` cannot be reached, or closes early, or sends a frame that does not belong →
|
||||
`LOOP_DOWN`, and the messages are not sent again: `loopd` may have run them.
|
||||
- A reply in a thread `loopd` does not know (`no_such_session` with `resume: true`) is sent once
|
||||
more with `resume: false`, creating the session, as `bxctl chat --session` does. Only then.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/deliver.rs`, `crates/gatewayd/tests/support/fake_loop.rs`, and the
|
||||
skeleton `crates/gatewayd/src/deliver.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod deliver;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `MAX_POST` (16,000), `LOOP_DOWN`, `EMPTY_ANSWER`, the trait `Poster` (somewhere to post:
|
||||
Mattermost's `Client`, or a test's record) and its `impl` for `Client`, `Outcome` (`Answer`,
|
||||
`Refused`, `LoopDown(why)`), `approval_text`.
|
||||
|
||||
To fill: `error_text`, `split_answer`, `one_turn`, `run_turn`, `deliver`. `split_answer` counts
|
||||
**characters**, not bytes, and never cuts inside one: find the byte index of the 16,000th character
|
||||
with `char_indices().nth(MAX_POST)`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/deliver.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_loop.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/deliver.rs crates/gatewayd/src/`.
|
||||
Add `pub mod deliver;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test deliver`. Expected: it compiles and 9
|
||||
tests fail.
|
||||
- [ ] **3. Fill `error_text`, `split_answer`, `one_turn`, `run_turn`, `deliver`**,
|
||||
`cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test deliver`. Expected: 9 passed.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd --test deliver` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test seems to need a turn sent twice in any case other than the one above.
|
||||
@@ -0,0 +1,79 @@
|
||||
# M4a task 14: the serve loop
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: serve, the event loop, typing, catch-up and reconnecting`
|
||||
|
||||
## Goal
|
||||
|
||||
The loop that ties tasks 03 to 13 together (spec sections 8 and 9):
|
||||
|
||||
- **Connecting**, at start and after every loss: `GET /users/me`, open the WebSocket, wait for
|
||||
`hello`, then log `gatewayd: connected to <url> as <username>`. A connection that fails is tried
|
||||
again after 1, 2, 5, 10, then every 30 seconds, with one log line per attempt:
|
||||
`gatewayd: cannot reach <url>: <error>; trying again in <n> s`, then a newline and
|
||||
`see docs/runbook.md#mattermost-unreachable`. A refused token (401 or 403) is never tried again:
|
||||
`run` returns `Stop::Auth`.
|
||||
- **After a restart** (the first connection only): every turn left in flight gets
|
||||
`interrupted: gatewayd restarted before the answer arrived; ask again` in its thread. A later
|
||||
reconnect must not do this: those turns are still running.
|
||||
- **Catching up**: the direct channel with each allowed user, and each allowed channel, from its
|
||||
mark. A channel without a mark is marked "now" and not caught up: history is not answered.
|
||||
- **Each post**, live or caught up: posts in channels we do not track are not recorded at all. A
|
||||
tracked post is recorded as handled **before** it is acted on, so after a crash it is not
|
||||
answered twice (the in-flight record reports it instead). Then it is routed (task 11): a stranger
|
||||
is logged by user id and post id only, **never with the text**.
|
||||
- **Turns** run on their own threads (task 13), recorded in flight while they run. When one ends,
|
||||
its session's waiting messages start the next.
|
||||
- **Typing**: every `typing_every_ms`, `user_typing` for every thread with a turn running.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/serve.rs`, `crates/gatewayd/tests/serve_restart.rs`,
|
||||
`crates/gatewayd/tests/support/fake_mm.rs`, `crates/gatewayd/tests/support/gateway.rs`, and the
|
||||
skeletons `crates/gatewayd/src/serve/mod.rs` and `crates/gatewayd/src/serve/handle.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod serve;`), `docs/implementer-log.md`
|
||||
|
||||
## The skeletons
|
||||
|
||||
`serve/mod.rs`, written: the pointers, `INTERRUPTED`, `Log`, `Stop` (`Auth`, `State`, `Start`,
|
||||
`Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the two functions
|
||||
that are glue: **`run`** (the connect-or-back-off loop) and **`Gateway::event_loop`** (finish turns,
|
||||
send typing, wait for an event, handle it). Read both first: they call everything you write.
|
||||
To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `connect`, `Gateway::new`,
|
||||
`Gateway::connected`.
|
||||
|
||||
`serve/handle.rs`, all to fill: `now_ms`, `post`, `tracked`, `handle_post`, `start`, `finished`,
|
||||
`typing`, `catch_up`.
|
||||
|
||||
Each `todo!()` has its steps above it, with the exact log lines. `run` takes the token already
|
||||
loaded and a `stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a
|
||||
flag that is never set.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`mkdir -p crates/gatewayd/src/serve && cp docs/plans/M4a/files/crates/gatewayd/src/serve/mod.rs docs/plans/M4a/files/crates/gatewayd/src/serve/handle.rs crates/gatewayd/src/serve/`,
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/serve.rs docs/plans/M4a/files/crates/gatewayd/tests/serve_restart.rs crates/gatewayd/tests/`
|
||||
and
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_mm.rs docs/plans/M4a/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`.
|
||||
Add `pub mod serve;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test serve --test serve_restart`.
|
||||
Expected: it compiles; `serve` 6 fail; `serve_restart` 6 fail and 1 passes (a damaged state file
|
||||
stops `run` before anything you write is called).
|
||||
- [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `Gateway::new`, `connect`,
|
||||
`connected`), **then `handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`,
|
||||
`handle_post`, `catch_up`). `cargo check -p gatewayd` after each function.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve --test serve_restart`, five times.
|
||||
Expected: 6 and 7 passed each time, in about 2 s.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- Both suites pass five times running; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test passes only sometimes, or a test takes 5 s or more (that is a wait that timed out, not a
|
||||
pass).
|
||||
- `run` or `event_loop` seems to need a change. They are given; report instead.
|
||||
@@ -0,0 +1,57 @@
|
||||
# M4a task 15: `gatewayd serve --config <path>`
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: main, serve --config with its start checks`
|
||||
|
||||
## Goal
|
||||
|
||||
The program. `gatewayd serve --config <path>` loads the configuration and the token, prepares its
|
||||
directory, and runs the serve loop (task 14) until it must stop, then exits 1. Anything else on the
|
||||
command line prints `usage: gatewayd serve --config <path>` and exits 2. Every failure at start
|
||||
prints one line and its runbook pointer and exits 1, **before** anything is created on disk and
|
||||
before any connection:
|
||||
|
||||
| Failure | Output |
|
||||
|---|---|
|
||||
| the config cannot be read, parsed or checked | `gatewayd: <error>` then `see docs/runbook.md#gatewayd-start-failed` |
|
||||
| the token's source is wrong | `gatewayd: <path>: <why>` then the same pointer |
|
||||
| the token cannot be loaded | `gatewayd: secret mattermost_token: <why>` then `see docs/runbook.md#secret-unavailable` (the error's own `Display`) |
|
||||
| `<home>/gateway` cannot be created | `gatewayd: cannot prepare <dir>: <error>` then the start-failed pointer |
|
||||
|
||||
A token read from a file prints task 04's warning, and `gatewayd` goes on. The token itself is
|
||||
never printed: the last test runs the real program and looks for it in everything it wrote.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/main.rs`, and the skeleton `crates/gatewayd/src/main.rs` (replaces
|
||||
the placeholder that printed "not implemented")
|
||||
- Modify: `docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `main`, which parses the arguments like `loopd`'s. To fill: `serve(path)`, whose steps
|
||||
are above its `todo!()`. The directory is `config.state_path()`'s parent, created with
|
||||
`std::fs::DirBuilder`, `recursive(true)` and `mode(0o700)` (`std::os::unix::fs::DirBuilderExt`).
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/main.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/main.rs crates/gatewayd/src/`
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test main`. Expected: it compiles; 4 fail and 1
|
||||
passes (`usage`, since `main` is given).
|
||||
- [ ] **3. Fill `serve`.** `cargo check -p gatewayd`.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test main`. Expected: 5 passed.
|
||||
- [ ] **5. Run everything.** `cargo test -p gatewayd`. Expected: every suite passes.
|
||||
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`,
|
||||
with about 762 tests.
|
||||
- [ ] **7. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
This is the last task of M4a. Stop after the commit.
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A test's output contains the token.
|
||||
@@ -0,0 +1,75 @@
|
||||
# M4a implementation plan: `gatewayd`, conversations over Mattermost
|
||||
|
||||
> **For the implementing model:** do not work from this file. The owner gives you one task file at
|
||||
> a time (`01-…` to `15-…`). This file is the index for the owner and the reviewer.
|
||||
|
||||
**Goal:** a direct message to Boxmaker's bot account on Mattermost becomes a turn in `loopd`, and
|
||||
the answer is posted in its thread. In channels shared with other agents, Boxmaker answers only
|
||||
posts that name it, and replies in its own threads that name nobody else. Anyone not on the
|
||||
allowlist gets nothing at all. `gatewayd` opens no listening port.
|
||||
|
||||
**Architecture:** `proto` gains SHA-1 (01). `gatewayd` gets its dependencies and test certificates
|
||||
(02), its configuration (03), its secrets and the runbook entries (04), then a network layer built
|
||||
bottom-up: TCP or TLS (05), HTTP/1.1 (06), the WebSocket handshake (07), frames (08) and the
|
||||
connection (09). Mattermost's JSON and REST calls (10), the routing of posts to sessions (11), the
|
||||
state file (12) and delivery on `loop.sock` (13) are each one module. The serve loop ties them
|
||||
together (14), and `main` starts it (15).
|
||||
|
||||
**Spec:** `docs/specs/2026-09-23-m4a-gateway.md`. Brief: `docs/design.md` (P15 applied). Every
|
||||
fail-closed message ends with a pointer into `docs/runbook.md`; task 04 adds all seven entries
|
||||
this milestone needs.
|
||||
|
||||
**No task needs Mattermost or a network.** Everything runs against fakes: TLS test servers with a
|
||||
test-only CA, a scripted WebSocket server, a scripted HTTP server, a fake Mattermost and a fake
|
||||
`loopd`. The checks against the owner's server are done by the design model afterwards.
|
||||
|
||||
## Global constraints
|
||||
|
||||
- Everything in `AGENTS.md`, including "Lessons from earlier reviews".
|
||||
- New dependencies, all in task 02 and no others: `rustls` 0.23.45 (no default features; `ring`,
|
||||
`std`, `tls12`), `rustls-native-certs` 0.8.4, `zeroize` 1.9.0; `gatewayd` also uses `serde`,
|
||||
`serde_json` and `toml`, already vetted.
|
||||
- Branch `m4a`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the
|
||||
gate. Review happens once, after task 15.
|
||||
- Most tasks hand over a **skeleton**: the file with its types, constants and signatures written,
|
||||
and `todo!()` bodies with the steps as comments above them. Fill one function at a time and run
|
||||
`cargo check -p <crate>` after each (tip T25). Keep the comments; they say why.
|
||||
- Mattermost's JSON is another program's format: unknown fields are ignored. Our formats
|
||||
(`gatewayd.toml`, `state.json`) reject them.
|
||||
|
||||
## Tasks
|
||||
|
||||
The last column is how the given tests were checked before hand-over (tip T17). Every task had a
|
||||
reference implementation, and the given tests were run against it at that task's end state. Then
|
||||
each task's end state was rebuilt on its own from master in this order, with the gate at every
|
||||
step (tip T26), and the reference was deleted so it cannot be read (tip T18). Each skeleton was
|
||||
checked to compile against its task's tests and fail them.
|
||||
|
||||
| # | File | Delivers | Tests | Check |
|
||||
|---|---|---|---|---|
|
||||
| 01 | `01-proto-sha1.md` | `proto::sha1` | `proto/tests/sha1.rs` | reference; vectors checked with `sha1sum` |
|
||||
| 02 | `02-gatewayd-deps.md` | dependencies, `deny.toml`, TLS test certificates | none new | reference |
|
||||
| 03 | `03-gatewayd-config.md` | `gatewayd.toml` | `config.rs`, `support/tmp.rs` | reference |
|
||||
| 04 | `04-gatewayd-secrets.md` | `SecretStore`: credential, env, file; seven runbook entries | `secrets.rs` | reference |
|
||||
| 05 | `05-gatewayd-net.md` | TCP or TLS, verified | `net.rs`, `support/tls_server.rs` | reference |
|
||||
| 06 | `06-gatewayd-http.md` | HTTP/1.1 requests, size caps, rate-limit waits | `http.rs` | reference |
|
||||
| 07 | `07-gatewayd-ws-handshake.md` | base64, the WebSocket handshake | `ws_handshake.rs` | reference |
|
||||
| 08 | `08-gatewayd-ws-frames.md` | the frame decoder and encoder | `ws_frame.rs` (a naive decoder as oracle, 300 seeds) | reference |
|
||||
| 09 | `09-gatewayd-ws-conn.md` | the connection: pings, close, a dead peer | `ws_conn.rs`, `support/ws_server.rs` | reference; 7 mutations, all caught once a test for the `Host` header was added |
|
||||
| 10 | `10-gatewayd-mm.md` | Mattermost's events and REST calls | `mm_json.rs`, `mm_rest.rs`, `support/http_server.rs` | reference; 11 mutations, 10 caught, 1 that cannot change behaviour |
|
||||
| 11 | `11-gatewayd-sessions.md` | routing, commands, the queue per session | `sessions.rs` | reference; 14 mutations, 13 caught, 1 that cannot change behaviour |
|
||||
| 12 | `12-gatewayd-state.md` | `state.json` | `state.rs` | reference; 12 mutations, all caught |
|
||||
| 13 | `13-gatewayd-deliver.md` | turns on `loop.sock`, answers in the thread | `deliver.rs`, `support/fake_loop.rs` | reference; 11 mutations, 10 caught; the one left accepts an event frame with another id, which `loopd` never sends |
|
||||
| 14 | `14-gatewayd-serve.md` | the serve loop: routing, typing, catch-up, reconnecting | `serve.rs`, `serve_restart.rs`, `support/fake_mm.rs`, `support/gateway.rs` | reference; 14 mutations, all caught once four tests were added; 8 runs clean |
|
||||
| 15 | `15-gatewayd-main.md` | `gatewayd serve --config <path>` | `main.rs` | reference; run against the owner's server, token never printed |
|
||||
|
||||
At the end of task 15: about 762 tests (650 before task 01).
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
git switch m4a
|
||||
BOXMAKER_MODEL=straylight/ornith-1.5-35b-a3b tools/run-plan.sh docs/plans/M4a
|
||||
```
|
||||
|
||||
Keep the OpenCode TUI closed while it runs.
|
||||
@@ -0,0 +1,194 @@
|
||||
//! `gatewayd.toml` into a typed `Config`. Our own format: unknown keys are errors in every table.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub mattermost: MattermostConfig,
|
||||
pub secrets: BTreeMap<String, SecretSpec>,
|
||||
pub allow: AllowConfig,
|
||||
#[serde(default, rename = "loop")]
|
||||
pub loop_: LoopConfig,
|
||||
#[serde(default)]
|
||||
pub paths: Paths,
|
||||
#[serde(default)]
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MattermostConfig {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub ca_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Where one secret comes from: exactly one of the three is set (checked by `Config::load`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SecretSpec {
|
||||
#[serde(default)]
|
||||
pub credential: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A checked `SecretSpec`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SecretSource {
|
||||
Credential(String),
|
||||
Env(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AllowConfig {
|
||||
pub users: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct LoopConfig {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Paths {
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Paths {
|
||||
fn default() -> Self {
|
||||
Paths {
|
||||
home: std::env::var_os("BOXMAKER_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Limits {
|
||||
pub queue: u32,
|
||||
pub typing_every_ms: u64,
|
||||
pub ping_every_ms: u64,
|
||||
pub dead_after_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
queue: 20,
|
||||
typing_every_ms: 3_000,
|
||||
ping_every_ms: 30_000,
|
||||
dead_after_ms: 60_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `url` taken apart.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerUrl {
|
||||
pub tls: bool,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read(PathBuf, std::io::Error),
|
||||
Parse(PathBuf, toml::de::Error),
|
||||
Invalid(PathBuf, String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Each variant: "<path>: <error or why>", with `path.display()`.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
/// The one secret M4a needs.
|
||||
pub const MATTERMOST_TOKEN: &str = "mattermost_token";
|
||||
|
||||
impl Config {
|
||||
/// Parse without the checks `load` makes.
|
||||
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
|
||||
// `toml::from_str`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Config, ConfigError> {
|
||||
// Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The first thing wrong with the values, or `None`.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
// The first of these, in this order, with the exact messages in the task: the url
|
||||
// (`parse_url`); ca_file not absolute; [secrets.mattermost_token] missing; each secret
|
||||
// whose `source()` fails; allow.users empty; any id in allow.users or allow.channels not
|
||||
// `valid_id`; any limit that is 0.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The server's address, from `url`. Call only on a checked `Config`.
|
||||
pub fn server(&self) -> Result<ServerUrl, String> {
|
||||
// `parse_url` of the url.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn token_source(&self) -> Result<SecretSource, String> {
|
||||
// The `source()` of [secrets.mattermost_token], or an error if it is missing.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn loop_socket(&self) -> PathBuf {
|
||||
// [loop] socket, or <home>/run/loop/loop.sock when it is empty.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn state_path(&self) -> PathBuf {
|
||||
// <home>/gateway/state.json.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretSpec {
|
||||
/// Exactly one source, well formed.
|
||||
pub fn source(&self) -> Result<SecretSource, String> {
|
||||
// Exactly one of the three set, else "needs exactly one of credential, env and file".
|
||||
// credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9,
|
||||
// _. file: an absolute path. The messages are in the task.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Mattermost id: 26 characters of `a-z0-9`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
// 26 bytes, each a-z or 0-9.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `http://host[:port]` or `https://host[:port]`, nothing else.
|
||||
pub fn parse_url(url: &str) -> Result<ServerUrl, String> {
|
||||
// http:// or https://, then a host, then optionally ":" and a port. The port: 1..=65535 written
|
||||
// exactly as `port.to_string()` (so no "+1", no "080"). Default 443 for https, 80 for http. The
|
||||
// host: 1..=253 bytes of a-z, 0-9, "." and "-", not starting or ending with "." or "-".
|
||||
// Anything else, including a path, a user or an upper-case letter, is the one error message in
|
||||
// the task.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! One turn on `loop.sock` and its answer in the thread (M4a spec, section 8). Typing is shown by
|
||||
//! `serve`, which owns the WebSocket; this module only sends the turn and posts what comes back.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnEvent, WireError, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
|
||||
use crate::mm::MmError;
|
||||
use crate::mm::rest::Client;
|
||||
use crate::sessions::Batch;
|
||||
|
||||
/// The longest reply we post, in characters (a post holds at most 16,383).
|
||||
pub const MAX_POST: usize = 16_000;
|
||||
pub const LOOP_DOWN: &str = "Boxmaker's loop is not running (see docs/runbook.md#loop-unavailable)";
|
||||
pub const EMPTY_ANSWER: &str = "(the answer was empty)";
|
||||
|
||||
/// Somewhere to post: Mattermost, or a test's record.
|
||||
pub trait Poster: Send + Sync {
|
||||
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError>;
|
||||
}
|
||||
|
||||
impl Poster for Client {
|
||||
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
|
||||
self.create_post(channel, root, text).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// How a turn ended.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Answer(String),
|
||||
Refused(WireError),
|
||||
/// `loop.sock` could not be reached or closed early; why, for the log.
|
||||
LoopDown(String),
|
||||
}
|
||||
|
||||
pub fn approval_text(approval: u64) -> String {
|
||||
format!(
|
||||
"waiting for approval {approval}: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)"
|
||||
)
|
||||
}
|
||||
|
||||
/// "Error: <code>: <detail>"; the detail carries `loopd`'s runbook pointer when there is one.
|
||||
pub fn error_text(error: &WireError) -> String {
|
||||
// "Error: <code>: <detail>", where <code> is the snake_case name serde gives the ErrorCode
|
||||
// (`serde_json::to_value(code)` is a JSON string, e.g. "no_such_session").
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// An answer in posts of at most `MAX_POST` characters: each cut at the last newline before the
|
||||
/// limit (the newline is dropped), or at the limit when there is none.
|
||||
pub fn split_answer(text: &str) -> Vec<String> {
|
||||
// Blank (only whitespace) -> [EMPTY_ANSWER]. Otherwise, while the rest is longer than MAX_POST
|
||||
// *characters*: take the first MAX_POST characters; if they hold a newline past position 0, cut
|
||||
// at the last one and drop that newline; else cut at MAX_POST characters. The last part is the
|
||||
// rest.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Send one turn and read it to its end; `on_event` sees every event.
|
||||
fn one_turn(
|
||||
socket: &Path,
|
||||
batch: &Batch,
|
||||
resume: bool,
|
||||
on_event: &mut dyn FnMut(&TurnEvent),
|
||||
) -> Outcome {
|
||||
// 1. Connect to the socket (else LoopDown("cannot connect to <path>: <e>")).
|
||||
// 2. `write_frame` one Envelope: v PROTOCOL_VERSION, id 1, final true, msg Turn (else
|
||||
// LoopDown).
|
||||
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
|
||||
// -> Answer with its content; (1, final, Error) -> Refused; a read error -> LoopDown("the
|
||||
// turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A turn for a batch. A reply in a thread `loopd` does not know creates the session, as
|
||||
/// `bxctl chat --session` does.
|
||||
pub fn run_turn(socket: &Path, batch: &Batch, on_event: &mut dyn FnMut(&TurnEvent)) -> Outcome {
|
||||
// `one_turn` with batch.resume. When that is Refused with NoSuchSession and batch.resume was
|
||||
// true, one more `one_turn` with resume false. Otherwise the first outcome.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Run a batch's turn and post what comes of it in its thread. A post that fails is logged.
|
||||
pub fn deliver(poster: &dyn Poster, socket: &Path, batch: &Batch, log: &dyn Fn(&str)) {
|
||||
// Post in the batch thread. An ApprovalPending event posts `approval_text(approval)` at once.
|
||||
// Then: Answer -> every part of `split_answer`, in order; Refused -> `error_text`;
|
||||
// LoopDown(why) -> log "gatewayd: <session>: <why>" and post LOOP_DOWN. A post that fails is
|
||||
// logged, exactly "gatewayd: cannot post in <channel> (thread <root>): <error>", and the rest
|
||||
// goes on.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! HTTP/1.1 over a connected stream: one request, one response, `Connection: close` (M4a spec,
|
||||
//! section 5). The response head is read a byte at a time, so nothing past it is consumed: the
|
||||
//! WebSocket handshake reads its frames after it from the same stream.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const MAX_HEAD: usize = 16 * 1024;
|
||||
pub const MAX_BODY: usize = 4 * 1024 * 1024;
|
||||
/// The longest a rate limit is waited out, whatever the server says.
|
||||
pub const MAX_RATE_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HttpError {
|
||||
Io(std::io::Error),
|
||||
/// The response is not HTTP/1.1 as we read it.
|
||||
Protocol(String),
|
||||
/// A head or body over its cap.
|
||||
TooLarge(&'static str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpError::Io(e) => write!(f, "{e}"),
|
||||
HttpError::Protocol(why) => write!(f, "bad HTTP response: {why}"),
|
||||
HttpError::TooLarge(what) => write!(f, "the response {what} is too large"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HttpError {}
|
||||
|
||||
impl From<std::io::Error> for HttpError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
HttpError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// A status line and headers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Head {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Head {
|
||||
/// The first header named `name`, compared case-insensitively.
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
// The value of the first header whose name matches, ignoring ASCII case.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
pub head: Head,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Write one request. `host` is the `Host` header; `headers` come after it, then
|
||||
/// `Content-Length` when there is a body, then `Connection: close`.
|
||||
pub fn write_request(
|
||||
stream: &mut dyn Write,
|
||||
method: &str,
|
||||
host: &str,
|
||||
path: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<(), HttpError> {
|
||||
// Exactly: "<method> <path> HTTP/1.1\r\nHost: <host>\r\n", each header as "<k>: <v>\r\n",
|
||||
// "Content-Length: <n>\r\n" when there is a body, "Connection: close\r\n\r\n", then the body.
|
||||
// Flush.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One request and its whole response.
|
||||
pub fn request(
|
||||
stream: &mut (impl Read + Write),
|
||||
method: &str,
|
||||
host: &str,
|
||||
path: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<Response, HttpError> {
|
||||
// `write_request`, then `read_head`, then `read_body`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The status line and headers, up to and including the blank line, and not a byte more.
|
||||
pub fn read_head(stream: &mut dyn Read) -> Result<Head, HttpError> {
|
||||
// One byte at a time until CRLF CRLF, never more; over MAX_HEAD is TooLarge("head"); end of
|
||||
// stream is Protocol; retry Interrupted. Then: UTF-8; status line "HTTP/1.1" or "HTTP/1.0", a
|
||||
// 3-digit status in 100..=599; each header line "name: value" with a non-empty name without
|
||||
// spaces, the value trimmed. Anything else is Protocol.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The body after `head`: chunked, `Content-Length`, or to the end; at most `MAX_BODY`.
|
||||
pub fn read_body(stream: &mut dyn Read, head: &Head) -> Result<Vec<u8>, HttpError> {
|
||||
// Transfer-Encoding: chunked (any case) -> `read_chunked`. Else Content-Length: parse (else
|
||||
// Protocol), over MAX_BODY is TooLarge("body"), then read_exact that many. Else read to the end
|
||||
// through `take(MAX_BODY + 1)`; more than MAX_BODY is TooLarge("body").
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn read_line(stream: &mut dyn Read, cap: usize) -> Result<String, HttpError> {
|
||||
// Bytes up to CRLF (dropped), at most `cap` (else TooLarge("chunk header")); end of stream is
|
||||
// Protocol.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn read_chunked(stream: &mut dyn Read) -> Result<Vec<u8>, HttpError> {
|
||||
// Loop: a size line (hex, before any ";"), at most 1024 bytes. Size 0: read trailer lines (8
|
||||
// KiB each) until an empty one, and return. Otherwise the size must fit in MAX_BODY minus what
|
||||
// is already read (else TooLarge("body")), read it, then exactly CRLF (else Protocol).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// How long a 429 asks us to wait, from `X-Ratelimit-Reset`: a Unix time if it is one, else a
|
||||
/// number of seconds; never more than `MAX_RATE_WAIT`. One second if the header is missing or bad.
|
||||
pub fn rate_limit_wait(head: &Head, now: SystemTime) -> Duration {
|
||||
// X-Ratelimit-Reset as u64: above 1_000_000_000 it is a Unix time (wait = it - now, at least 1
|
||||
// s), otherwise seconds (at least 1). Missing or not a number: 1 s. Never more than
|
||||
// MAX_RATE_WAIT.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `gatewayd serve --config <path>`: the Mattermost channel. It loads its configuration and its
|
||||
//! token, prepares its directory, then serves until it must stop (exit 1).
|
||||
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use gatewayd::config::{Config, MATTERMOST_TOKEN};
|
||||
use gatewayd::secrets;
|
||||
use gatewayd::serve::{START_FAILED, Tuning, run};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
|
||||
match (words.as_slice(), args.get(2)) {
|
||||
([Some("serve"), Some("--config"), _], Some(path)) => serve(Path::new(path)),
|
||||
_ => {
|
||||
eprintln!("usage: gatewayd serve --config <path>");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(path: &Path) -> ExitCode {
|
||||
// Each failure prints one line (plus its pointer) and returns ExitCode::from(1):
|
||||
// 1. `Config::load`: "gatewayd: <error>\n<START_FAILED>". 2. `token_source`: "gatewayd: <path>:
|
||||
// <why>\n<START_FAILED>".
|
||||
// 3. `secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`: "gatewayd: <error>"
|
||||
// (it carries its pointer). Print the warning, if any, as it is. 4. Create the state file
|
||||
// directory, recursive, 0700: "gatewayd: cannot prepare <dir>: <e>\n<START_FAILED>". 5.
|
||||
// `run` with Tuning::default(), a log that prints each line to standard error, and a stop
|
||||
// flag that is never set; print the Stop it returns.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Mattermost's JSON, typed: the posts and users we read, the events that arrive over the
|
||||
//! WebSocket, and the requests we send over it. Mattermost's JSON is not ours: unknown fields are
|
||||
//! ignored, but every id we keep must be a valid id.
|
||||
|
||||
pub mod rest;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::valid_id;
|
||||
|
||||
/// At most this many changed posts come back from one `posts?since` call (Mattermost v11.11.0,
|
||||
/// `SqlPostStore::GetPostsSince`); a full answer may have left some out.
|
||||
pub const SINCE_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MmError {
|
||||
/// No answer: connecting, TLS, or the HTTP exchange failed.
|
||||
Net(String),
|
||||
/// 401 or 403: the token is refused.
|
||||
Auth(u16),
|
||||
/// 429, still, after waiting as asked.
|
||||
RateLimited(Duration),
|
||||
/// Any other status that is not 2xx, with the start of the body.
|
||||
Status(u16, String),
|
||||
/// The answer is not the JSON we expect.
|
||||
Json(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MmError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MmError::Net(why) => write!(f, "{why}"),
|
||||
MmError::Auth(status) => write!(f, "Mattermost refused the token ({status})"),
|
||||
MmError::RateLimited(wait) => {
|
||||
write!(f, "rate limited; asked to wait {} s", wait.as_secs())
|
||||
}
|
||||
// Quoted: the body is the server's text, and must not forge a log line.
|
||||
MmError::Status(status, body) => write!(f, "status {status}: {body:?}"),
|
||||
MmError::Json(why) => write!(f, "unexpected JSON: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MmError {}
|
||||
|
||||
/// This bot, from `GET /users/me`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Me {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// The fields of a post that `gatewayd` uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub channel_id: String,
|
||||
#[serde(default)]
|
||||
pub root_id: String,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
pub create_at: i64,
|
||||
#[serde(default)]
|
||||
pub delete_at: i64,
|
||||
/// Empty for a message a user wrote; anything else is a system message.
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Every id is a Mattermost id (the root may be empty): they end up in session ids and paths.
|
||||
pub(crate) fn check(self) -> Result<Post, MmError> {
|
||||
// id, user_id and channel_id must be `valid_id`, and root_id empty or `valid_id`; otherwise
|
||||
// Json("a post with an invalid id: <id quoted with {:?}>").
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// An event from the WebSocket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
Hello,
|
||||
/// A new post, and the type of its channel: `D` direct, `G` group, `O` open, `P` private.
|
||||
Posted {
|
||||
post: Post,
|
||||
channel_type: String,
|
||||
},
|
||||
/// Any other event, or a reply to one of our requests, by its name (empty for a reply).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawEvent {
|
||||
#[serde(default)]
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
data: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MmError> {
|
||||
// `serde_json::from_slice`; its error becomes Json(e.to_string()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One WebSocket text message as an event. `posted` carries the post as a JSON **string**.
|
||||
pub fn parse_event(text: &str) -> Result<Event, MmError> {
|
||||
// Parse into RawEvent. "hello" -> Hello. "posted" -> data.post must be a JSON *string*; parse
|
||||
// that string as a Post and `check` it (a missing or non-string post is Json). channel_type is
|
||||
// data.channel_type when it is a string, else "". Any other event name -> Other(name) (a reply
|
||||
// has no event: Other("")).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The WebSocket request that shows this bot as typing in a thread (`parent` is the root).
|
||||
pub fn typing(seq: u64, channel: &str, parent: &str) -> String {
|
||||
// serde_json::json!({"action": "user_typing", "seq": seq, "data": {"channel_id": channel,
|
||||
// "parent_id": parent}}) as a string.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// What a `posts?since` call gave back.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Since {
|
||||
/// Posts created after the time, not deleted, oldest first.
|
||||
pub posts: Vec<Post>,
|
||||
/// Mattermost's limit was reached: some posts may be missing.
|
||||
pub full: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PostList {
|
||||
#[serde(default)]
|
||||
order: Vec<String>,
|
||||
#[serde(default)]
|
||||
posts: std::collections::HashMap<String, Post>,
|
||||
}
|
||||
|
||||
/// The body of `GET /channels/{id}/posts?since=<since>`. Only ids in `order` changed after
|
||||
/// `since`; `posts` also holds the roots of their threads, which may be older. Edited and deleted
|
||||
/// posts come back too: only new posts count.
|
||||
pub fn since_list(body: &[u8], since: i64) -> Result<Since, MmError> {
|
||||
// Parse a PostList. full = order.len() >= SINCE_LIMIT. For each id in `order` (never the keys
|
||||
// of `posts`), skip it if it is not in `posts`; keep the post if create_at > since and
|
||||
// delete_at == 0, after `check`. Sort by (create_at, id), remove repeated ids.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Mattermost's REST calls, one connection per request, with the token in the `Authorization`
|
||||
//! header. A 429 waits as asked; a 5xx is tried twice more; a 401 or 403 is `Auth` at once.
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::valid_id;
|
||||
use crate::http::{self, Response, rate_limit_wait};
|
||||
use crate::mm::{Me, MmError, Post, Since, json, since_list};
|
||||
use crate::net::Connector;
|
||||
use crate::secrets::Secret;
|
||||
use crate::ws::conn::host_header;
|
||||
|
||||
/// The pause before trying a call again after a 5xx.
|
||||
pub const RETRY_5XX: Duration = Duration::from_millis(500);
|
||||
/// How many times a call is tried again after a 5xx, and waits after a 429.
|
||||
pub const RETRIES: u32 = 2;
|
||||
/// How much of an error body is kept for the message.
|
||||
const BODY_KEPT: usize = 200;
|
||||
|
||||
pub struct Client {
|
||||
connector: Connector,
|
||||
token: Secret,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Channel {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
|
||||
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
|
||||
// Store the three.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn connector(&self) -> &Connector {
|
||||
// The Connector.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &Secret {
|
||||
// The Secret.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn once(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Response, MmError> {
|
||||
// 1. `self.connector.connect(self.timeout)`, then `set_read_timeout(Some(self.timeout))`.
|
||||
// 2. Headers: Authorization "Bearer <token>" (the only use of `expose`), Accept
|
||||
// "application/json", and Content-Type "application/json" when there is a body. Host is
|
||||
// `host_header(self.connector.server())`.
|
||||
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One call, tried again as the module comment says; the body of a 2xx answer.
|
||||
fn call(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Vec<u8>, MmError> {
|
||||
// Loop over `once`: 2xx -> the body. 401 or 403 -> Auth(status) at once. 429 -> after
|
||||
// RETRIES waits already, RateLimited(wait); else sleep `rate_limit_wait(&head,
|
||||
// SystemTime::now())` and try again. 5xx -> up to RETRIES more tries, sleeping RETRY_5XX
|
||||
// before each. Anything else -> Status(status, the first BODY_KEPT characters of the body,
|
||||
// lossy UTF-8).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `GET /api/v4/users/me`: who this token is.
|
||||
pub fn me(&self) -> Result<Me, MmError> {
|
||||
// GET /api/v4/users/me into Me; an id that is not `valid_id` or an empty username is Json.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `POST /api/v4/posts`: a post in `channel`, in the thread of `root` (empty: top level).
|
||||
pub fn create_post(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError> {
|
||||
// POST /api/v4/posts with {"channel_id", "root_id", "message"}; the answer is a Post,
|
||||
// `check`ed.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `GET /api/v4/channels/{channel}/posts?since=<ms>`: the posts created after `since`.
|
||||
pub fn posts_since(&self, channel: &str, since: i64) -> Result<Since, MmError> {
|
||||
// A channel that is not `valid_id` is Json, and nothing is sent. Otherwise GET
|
||||
// /api/v4/channels/<channel>/posts?since=<since>, through `since_list`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `POST /api/v4/channels/direct`: the id of the direct channel between two users.
|
||||
pub fn direct_channel(&self, a: &str, b: &str) -> Result<String, MmError> {
|
||||
// POST /api/v4/channels/direct with the JSON array [a, b]; the answer has an "id", which
|
||||
// must be `valid_id` (else Json).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! A connection to the Mattermost server: TCP, or TCP with TLS through `rustls`, verified against
|
||||
//! the host's trusted certificates plus an optional CA file (M4a spec, section 5). Verification
|
||||
//! cannot be turned off.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use rustls::pki_types::{CertificateDer, ServerName};
|
||||
use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
|
||||
|
||||
use crate::config::ServerUrl;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NetError {
|
||||
/// The CA file or the host's certificates could not be loaded.
|
||||
Roots(String),
|
||||
/// No address of the server accepted a connection.
|
||||
Connect(String),
|
||||
/// The TLS handshake failed: an unknown CA, a wrong name, an old protocol.
|
||||
Tls(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
NetError::Roots(why) => write!(f, "cannot load trusted certificates: {why}"),
|
||||
NetError::Connect(why) => write!(f, "cannot connect: {why}"),
|
||||
NetError::Tls(why) => write!(f, "TLS failed: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NetError {}
|
||||
|
||||
/// A connected stream, plain or TLS.
|
||||
pub enum Stream {
|
||||
Plain(TcpStream),
|
||||
Tls(Box<StreamOwned<ClientConnection, TcpStream>>),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// The TCP socket underneath, for timeouts and shutdown.
|
||||
pub fn tcp(&self) -> &TcpStream {
|
||||
// The TcpStream: itself for Plain; `get_ref()` for Tls.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> std::io::Result<()> {
|
||||
// On `self.tcp()`.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
// Forward to the inner stream for each variant.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
// Forward to the inner stream for each variant.
|
||||
todo!()
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
// Forward to the inner stream for each variant.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes connections to one server.
|
||||
#[derive(Clone)]
|
||||
pub struct Connector {
|
||||
server: ServerUrl,
|
||||
tls: Option<Arc<ClientConfig>>,
|
||||
}
|
||||
|
||||
impl Connector {
|
||||
/// For `https`, loads the host's trusted certificates and `ca_file`; an error in either is an
|
||||
/// error here, before any connection.
|
||||
pub fn new(server: ServerUrl, ca_file: Option<&Path>) -> Result<Connector, NetError> {
|
||||
// For tls, `client_config(ca_file)?` in an Arc; for plain, None.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn server(&self) -> &ServerUrl {
|
||||
// The ServerUrl.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Connect, and for TLS complete the handshake, within `timeout` for each step.
|
||||
pub fn connect(&self, timeout: Duration) -> Result<Stream, NetError> {
|
||||
let addrs = (self.server.host.as_str(), self.server.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| NetError::Connect(format!("{}: {e}", self.server.host)))?;
|
||||
let mut last = format!("{} has no address", self.server.host);
|
||||
let mut tcp = None;
|
||||
for addr in addrs {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(s) => {
|
||||
tcp = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = format!("{addr}: {e}"),
|
||||
}
|
||||
}
|
||||
let tcp = tcp.ok_or(NetError::Connect(last))?;
|
||||
tcp.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| NetError::Connect(e.to_string()))?;
|
||||
tcp.set_write_timeout(Some(timeout))
|
||||
.map_err(|e| NetError::Connect(e.to_string()))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
let Some(config) = &self.tls else {
|
||||
return Ok(Stream::Plain(tcp));
|
||||
};
|
||||
let name = ServerName::try_from(self.server.host.clone())
|
||||
.map_err(|e| NetError::Tls(e.to_string()))?;
|
||||
let conn = ClientConnection::new(Arc::clone(config), name)
|
||||
.map_err(|e| NetError::Tls(e.to_string()))?;
|
||||
let mut stream = StreamOwned::new(conn, tcp);
|
||||
while stream.conn.is_handshaking() {
|
||||
stream
|
||||
.conn
|
||||
.complete_io(&mut stream.sock)
|
||||
.map_err(|e| NetError::Tls(e.to_string()))?;
|
||||
}
|
||||
Ok(Stream::Tls(Box::new(stream)))
|
||||
}
|
||||
}
|
||||
|
||||
fn client_config(ca_file: Option<&Path>) -> Result<ClientConfig, NetError> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let (added, _ignored) = roots.add_parsable_certificates(native.certs);
|
||||
if let Some(path) = ca_file {
|
||||
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
|
||||
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
|
||||
if certs.is_empty() {
|
||||
return Err(NetError::Roots(format!(
|
||||
"{} holds no certificate",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
for cert in certs {
|
||||
roots
|
||||
.add(cert)
|
||||
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
|
||||
}
|
||||
} else if added == 0 {
|
||||
return Err(NetError::Roots(
|
||||
"the host has no trusted certificates and no ca_file is set".to_string(),
|
||||
));
|
||||
}
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let config = ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| NetError::Roots(e.to_string()))?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(config)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
|
||||
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::SecretSource;
|
||||
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
|
||||
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
|
||||
|
||||
/// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped.
|
||||
pub struct Secret(Zeroizing<String>);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(text: String) -> Secret {
|
||||
Secret(Zeroizing::new(text))
|
||||
}
|
||||
|
||||
/// The text, for the one place that must send it.
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Secret(…)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded secret, and the warning to print for it, if any.
|
||||
#[derive(Debug)]
|
||||
pub struct Loaded {
|
||||
pub secret: Secret,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SecretError {
|
||||
pub name: String,
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "secret {}: {}\n{RUNBOOK}", self.name, self.why)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SecretError {}
|
||||
|
||||
/// Load secret `name` from `source`. `env` reads an environment variable (in `gatewayd`,
|
||||
/// `std::env::var_os`); tests pass their own.
|
||||
pub fn load(
|
||||
name: &str,
|
||||
source: &SecretSource,
|
||||
env: &dyn Fn(&str) -> Option<OsString>,
|
||||
) -> Result<Loaded, SecretError> {
|
||||
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
|
||||
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
|
||||
// variable, UTF-8; unset is an error. File: `check_file` first, then read it, and set `warning`
|
||||
// to the exact text in the task. Every value goes through `value`. Every error is a
|
||||
// `SecretError` naming the secret, never the value.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// An owner-only regular file, not a link.
|
||||
fn check_file(path: &Path) -> Result<(), String> {
|
||||
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
|
||||
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The text without one trailing newline; not empty; UTF-8.
|
||||
fn value(bytes: Vec<u8>) -> Result<Secret, String> {
|
||||
// UTF-8 (else an error), one trailing newline removed, not empty. Keep it in `Zeroizing`
|
||||
// throughout.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! What the event loop does with a post, a finished turn, and the time between: routing, starting
|
||||
//! turns, typing, and catching up after a gap.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::deliver::{LOOP_DOWN, deliver};
|
||||
use crate::mm::{Post, typing};
|
||||
use crate::serve::{Gateway, Stop};
|
||||
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
|
||||
use crate::state::InFlight;
|
||||
use crate::ws::WsError;
|
||||
use crate::ws::conn::Ws;
|
||||
|
||||
/// Now, in Mattermost's milliseconds.
|
||||
fn now_ms() -> i64 {
|
||||
// Milliseconds since the Unix epoch as i64, with try_from (i64::MAX if it does not fit).
|
||||
todo!()
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
/// Post in a thread; a failure is logged, not fatal.
|
||||
pub(super) fn post(&self, channel: &str, root: &str, text: &str) {
|
||||
// `self.client.create_post`; an error is logged, "gatewayd: cannot post in <channel>
|
||||
// (thread <root>): <error>".
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Is this channel one whose posts `gatewayd` keeps track of?
|
||||
fn tracked(&self, channel: &str, channel_type: &str) -> bool {
|
||||
// channel_type "D", or the channel is in allow.channels.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One new post, live or caught up.
|
||||
pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> {
|
||||
// 1. Not tracked, or seen -> nothing. 2. `state.handled(...)?` before anything else. 3.
|
||||
// Route it: NotAllowed -> log "gatewayd: ignored post <id> from <user>: not allowed"
|
||||
// (never the message); other Ignore -> nothing; Reply -> post it; Queue -> join the
|
||||
// thread when joins_thread, then push: Start -> `start`, Waiting -> nothing, Full ->
|
||||
// post BUSY.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Record the turn as in flight and run it on its own thread.
|
||||
fn start(&mut self, batch: Batch) -> Result<(), Stop> {
|
||||
// `state.start_turn` (session, channel, root). Spawn with std::thread::Builder: `deliver`
|
||||
// with the client, the loop socket, the batch and the log, then send the session on
|
||||
// done_tx. If spawning fails: log "gatewayd: cannot start a thread for <session>: <e>",
|
||||
// post LOOP_DOWN in the thread, and send the session on done_tx.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Turns that ended: out of flight, and the next batch of each session started.
|
||||
pub(super) fn finished(&mut self) -> Result<(), Stop> {
|
||||
// For each session on done_rx (try_recv, never blocking): `end_turn`, then `queues.finish`;
|
||||
// a batch it returns is started.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Show this bot as typing in every thread with a turn running.
|
||||
pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> {
|
||||
// For each of `queues.threads()`: seq += 1, send `typing(seq, channel, root)` as text.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user,
|
||||
/// and each allowed channel. A channel seen for the first time starts from now.
|
||||
pub(super) fn catch_up(&mut self) -> Result<(), Stop> {
|
||||
// Channels: the direct channel with each allowed user ("D"; an error is logged "gatewayd:
|
||||
// no direct channel with <user>: <e>" and skipped), then each allowed channel ("O"). For
|
||||
// each: no mark -> `mark(channel, now_ms())` and skip; else `posts_since(channel, mark)`
|
||||
// (an error is logged "gatewayd: cannot catch up <channel>: <e>" and skipped); when full,
|
||||
// log "gatewayd: <channel>: too many posts to catch up; some may be missed"; `handle_post`
|
||||
// each post in order.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Startup, the event loop and reconnecting (M4a spec, section 9). `run` returns only when
|
||||
//! `gatewayd` must stop: a refused token, a state file it cannot keep, or a stop asked by a test.
|
||||
|
||||
mod handle;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use proto::SessionId;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mm::rest::Client;
|
||||
use crate::mm::{Event, Me, MmError, parse_event};
|
||||
use crate::net::Connector;
|
||||
use crate::secrets::Secret;
|
||||
use crate::sessions::{Queues, Router};
|
||||
use crate::state::{State, StateError};
|
||||
use crate::ws::conn::{Timing, Ws};
|
||||
|
||||
pub const UNREACHABLE: &str = "see docs/runbook.md#mattermost-unreachable";
|
||||
pub const AUTH_FAILED: &str = "see docs/runbook.md#mattermost-auth-failed";
|
||||
pub const START_FAILED: &str = "see docs/runbook.md#gatewayd-start-failed";
|
||||
pub const INTERRUPTED: &str =
|
||||
"interrupted: gatewayd restarted before the answer arrived; ask again";
|
||||
|
||||
/// A log line: `stderr` in `main`, a record in tests.
|
||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
/// Why `run` returned.
|
||||
#[derive(Debug)]
|
||||
pub enum Stop {
|
||||
/// Mattermost refused the token (401 or 403).
|
||||
Auth(u16),
|
||||
State(StateError),
|
||||
/// Something `gatewayd` needs at start is missing; the message names it.
|
||||
Start(String),
|
||||
/// The stop flag was set.
|
||||
Asked,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Stop {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Stop::Auth(status) => {
|
||||
write!(
|
||||
f,
|
||||
"gatewayd: Mattermost refused the token ({status})\n{AUTH_FAILED}"
|
||||
)
|
||||
}
|
||||
Stop::State(e) => write!(f, "gatewayd: {e}"),
|
||||
Stop::Start(why) => write!(f, "gatewayd: {why}\n{START_FAILED}"),
|
||||
Stop::Asked => write!(f, "gatewayd: stopped"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StateError> for Stop {
|
||||
fn from(e: StateError) -> Stop {
|
||||
// Stop::State(e).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Timings a test shortens.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tuning {
|
||||
/// The waits between connection attempts; the last repeats.
|
||||
pub backoff: Vec<Duration>,
|
||||
/// How long one wait for a WebSocket message lasts, at most.
|
||||
pub poll: Duration,
|
||||
/// Connecting and each REST read.
|
||||
pub rest_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Tuning {
|
||||
fn default() -> Self {
|
||||
let secs = [1, 2, 5, 10, 30].map(Duration::from_secs);
|
||||
Tuning {
|
||||
backoff: secs.to_vec(),
|
||||
poll: Duration::from_millis(200),
|
||||
rest_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the event loop works with.
|
||||
pub(crate) struct Gateway {
|
||||
config: Config,
|
||||
client: Arc<Client>,
|
||||
router: Router,
|
||||
me: Me,
|
||||
state: State,
|
||||
queues: Queues,
|
||||
loop_socket: PathBuf,
|
||||
done_tx: Sender<SessionId>,
|
||||
done_rx: Receiver<SessionId>,
|
||||
log: Log,
|
||||
/// The typing requests' sequence number.
|
||||
seq: u64,
|
||||
/// The turns a restart cut off have been answered.
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
/// The wait before attempt `n` (from 0) after a loss.
|
||||
pub fn backoff(tuning: &Tuning, n: usize) -> Duration {
|
||||
// tuning.backoff[n], or its last entry when n is past the end (30 s if the list is empty). No
|
||||
// indexing.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Sleep for `d`, in short steps, unless `stop` is set.
|
||||
fn sleep_unless(stop: &AtomicBool, d: Duration) {
|
||||
// Sleep in steps of at most 20 ms until `d` has passed or `stop` is set.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Run `gatewayd` with its token, until it must stop.
|
||||
pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &AtomicBool) -> Stop {
|
||||
let server = match config.server() {
|
||||
Ok(s) => s,
|
||||
Err(why) => return Stop::Start(why),
|
||||
};
|
||||
let connector = match Connector::new(server, config.mattermost.ca_file.as_deref()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Stop::Start(e.to_string()),
|
||||
};
|
||||
let state = match State::load(&config.state_path()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Stop::State(e),
|
||||
};
|
||||
let client = Arc::new(Client::new(connector, token, tuning.rest_timeout));
|
||||
let mut g = Gateway::new(config, client, state, log);
|
||||
let mut failures = 0;
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Stop::Asked;
|
||||
}
|
||||
let stopped = match connect(&g.config, &g.client, &tuning) {
|
||||
Ok((me, mut ws)) => {
|
||||
failures = 0;
|
||||
g.connected(me)
|
||||
.and_then(|()| g.catch_up())
|
||||
.and_then(|()| g.event_loop(&mut ws, &tuning, stop))
|
||||
}
|
||||
Err(MmError::Auth(status)) => Err(Stop::Auth(status)),
|
||||
Err(e) => {
|
||||
let wait = backoff(&tuning, failures);
|
||||
failures += 1;
|
||||
(g.log)(&format!(
|
||||
"gatewayd: cannot reach {}: {e}; trying again in {} s\n{UNREACHABLE}",
|
||||
g.config.mattermost.url,
|
||||
wait.as_secs()
|
||||
));
|
||||
sleep_unless(stop, wait);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(stop) = stopped {
|
||||
return stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who we are, and a WebSocket that has said hello.
|
||||
fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws), MmError> {
|
||||
// 1. `client.me()?`. 2. Timing from limits (ping_every_ms, dead_after_ms). 3. Open /dev/urandom
|
||||
// (an error is Net("/dev/urandom: <e>")). 4. `Ws::open(client.connector(),
|
||||
// client.token().expose(), timing, ..)`.
|
||||
// 5. Poll with tuning.poll until a text that `parse_event`s to Hello, for at most dead_after.
|
||||
// Every WebSocket error, and no hello in time, is Net("websocket: <why>").
|
||||
todo!()
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
|
||||
// A done channel, a Router with empty ids (set on connect), an empty Me, loop_socket from
|
||||
// config, Queues with limit limits.queue (usize::try_from), seq 0, restarted false.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A connection is up: say so, route as this user, and on the first one, answer the turns a
|
||||
/// restart cut off.
|
||||
fn connected(&mut self, me: Me) -> Result<(), Stop> {
|
||||
// Log exactly "gatewayd: connected to <url> as <username>". A new Router from me and the
|
||||
// allow lists; store me. The first time only (`restarted`): for each turn `take_in_flight`
|
||||
// gives back, post INTERRUPTED in its channel and root. A later reconnect must not: those
|
||||
// turns are still running.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Read events until the connection is lost (`Ok`) or `gatewayd` must stop.
|
||||
fn event_loop(&mut self, ws: &mut Ws, tuning: &Tuning, stop: &AtomicBool) -> Result<(), Stop> {
|
||||
let typing_every = Duration::from_millis(self.config.limits.typing_every_ms);
|
||||
let mut last_typing = Instant::now();
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(Stop::Asked);
|
||||
}
|
||||
self.finished()?;
|
||||
if last_typing.elapsed() >= typing_every {
|
||||
last_typing = Instant::now();
|
||||
if let Err(e) = self.typing(ws) {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let text = match ws.poll(tuning.poll.min(typing_every)) {
|
||||
Ok(Some(text)) => text,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match parse_event(&text) {
|
||||
Ok(Event::Posted { post, channel_type }) => {
|
||||
self.handle_post(&post, &channel_type)?
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => (self.log)(&format!("gatewayd: ignored an event: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Which posts `gatewayd` acts on, which session each belongs to, commands, and the queue of
|
||||
//! messages per session (M4a spec, section 7). Pure: the state file and the network are elsewhere.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use proto::SessionId;
|
||||
|
||||
use crate::mm::Post;
|
||||
|
||||
pub const M4B_COMMAND: &str = "approvals over Mattermost arrive in M4b; use `bxctl approvals`";
|
||||
pub const UNKNOWN_COMMAND: &str = "unknown command; the commands are !approve and !deny";
|
||||
pub const BUSY: &str = "busy: too many messages are waiting in this conversation";
|
||||
|
||||
/// Names that name nobody: every agent in the channel would answer them.
|
||||
const EVERYONE: [&str; 3] = ["channel", "here", "all"];
|
||||
|
||||
/// Why a post was not acted on. Only `NotAllowed` is logged, by user id and post id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Ignored {
|
||||
Own,
|
||||
System,
|
||||
NotAllowed,
|
||||
NotForUs,
|
||||
}
|
||||
|
||||
/// Where an answer goes: a channel, and the root of the thread in it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Thread {
|
||||
pub channel: String,
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// A message for a session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub session: SessionId,
|
||||
pub thread: Thread,
|
||||
/// A reply in a thread: the session should exist already.
|
||||
pub resume: bool,
|
||||
pub text: String,
|
||||
/// A thread in a channel or group message that this Boxmaker now takes part in.
|
||||
pub joins_thread: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
Ignore(Ignored),
|
||||
/// Answer in the thread without a turn (a command).
|
||||
Reply {
|
||||
thread: Thread,
|
||||
text: String,
|
||||
},
|
||||
Queue(Message),
|
||||
}
|
||||
|
||||
pub struct Router {
|
||||
me_id: String,
|
||||
me_name: String,
|
||||
users: BTreeSet<String>,
|
||||
channels: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without
|
||||
/// trailing dots.
|
||||
pub fn named(message: &str) -> Vec<String> {
|
||||
// Find each "@". The name after it is the longest run of ASCII letters, digits, ".", "-" and
|
||||
// "_", with trailing "." removed and lower-cased. Skip empty names. Continue after the name.
|
||||
todo!()
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router {
|
||||
// Store the ids, the username lower-cased, and the two lists as sets.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Is a post in a channel or group message for this Boxmaker? `known` says whether it has a
|
||||
/// session for a thread root.
|
||||
fn for_us(&self, post: &Post, known: &dyn Fn(&str) -> bool) -> bool {
|
||||
// Named this bot -> true. Otherwise true only for a reply (root_id not empty) in a known
|
||||
// thread (`known(root_id)`) that names nobody but channel, here or all.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// What to do with a new post (a post seen before is dropped by the caller first).
|
||||
pub fn route(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route {
|
||||
// Section 7 of the spec, in this order: own post -> Ignore(Own); kind not empty ->
|
||||
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
|
||||
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
|
||||
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
|
||||
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
|
||||
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
|
||||
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
|
||||
// joins_thread = not "D".
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// A turn to send: every message that was waiting, joined with a blank line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Batch {
|
||||
pub session: SessionId,
|
||||
pub thread: Thread,
|
||||
pub resume: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Pushed {
|
||||
/// No turn was running: send this one now.
|
||||
Start(Batch),
|
||||
/// A turn is running; the message waits for the next.
|
||||
Waiting,
|
||||
/// Too many are waiting: the message is dropped, answer `BUSY`.
|
||||
Full(Thread),
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
thread: Thread,
|
||||
resume: bool,
|
||||
waiting: Vec<String>,
|
||||
}
|
||||
|
||||
/// The sessions with a turn running, and the messages waiting for each.
|
||||
pub struct Queues {
|
||||
limit: usize,
|
||||
running: HashMap<SessionId, Pending>,
|
||||
}
|
||||
|
||||
impl Queues {
|
||||
/// `limit` is the most messages that may wait per session.
|
||||
pub fn new(limit: usize) -> Queues {
|
||||
// An empty map.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, message: Message) -> Pushed {
|
||||
// If the session is running: with `limit` messages already waiting -> Full(thread); else
|
||||
// add the text to waiting -> Waiting. Otherwise insert it as running (its later batches
|
||||
// resume: true) and return Start with this message alone.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A session's turn ended: the next batch, or `None`, and the session is no longer running.
|
||||
pub fn finish(&mut self, session: &SessionId) -> Option<Batch> {
|
||||
// Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting
|
||||
// text, joined with "\n\n", as the next Batch (it stays running).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// How many sessions have a turn running.
|
||||
pub fn running(&self) -> usize {
|
||||
// How many sessions are running.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The threads with a turn running, for showing this bot as typing in them.
|
||||
pub fn threads(&self) -> Vec<Thread> {
|
||||
// The thread of every running session.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! `<home>/gateway/state.json`: what `gatewayd` has handled, the threads it takes part in, and the
|
||||
//! turns in flight (M4a spec, section 9). Written atomically after every change. A file that
|
||||
//! cannot be read, parsed or written stops `gatewayd`: guessing would answer posts twice.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::valid_id;
|
||||
|
||||
pub const RECENT_KEPT: usize = 500;
|
||||
pub const THREADS_KEPT: usize = 5000;
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#gateway-state-damaged";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StateError {
|
||||
Read(PathBuf, String),
|
||||
Write(PathBuf, io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StateError::Read(path, why) => write!(f, "{}: {why}\n{RUNBOOK}", path.display()),
|
||||
StateError::Write(path, err) => {
|
||||
write!(f, "{}: cannot write: {err}\n{RUNBOOK}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StateError {}
|
||||
|
||||
/// A turn sent to `loopd` and not yet answered.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InFlight {
|
||||
pub session: String,
|
||||
pub channel: String,
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StateFile {
|
||||
channels: BTreeMap<String, i64>,
|
||||
recent: Vec<String>,
|
||||
threads: Vec<String>,
|
||||
in_flight: Vec<InFlight>,
|
||||
}
|
||||
|
||||
impl StateFile {
|
||||
/// Every id is a Mattermost id; a session is `mm-<id>`.
|
||||
fn problem(&self) -> Option<String> {
|
||||
// Every key of channels, every entry of recent and threads must be `valid_id` ("not a
|
||||
// Mattermost id: <id with {:?}>"). Each in_flight entry: session is "mm-" + a valid id,
|
||||
// channel and root valid ids ("a turn in flight is not valid: <session with {:?}>"). None
|
||||
// when all are good.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
path: PathBuf,
|
||||
file: StateFile,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Read the state; a missing file is a first start.
|
||||
pub fn load(path: &Path) -> Result<State, StateError> {
|
||||
// Read the file. Missing (NotFound only) -> an empty StateFile. Any other read error, a
|
||||
// parse error (serde_json), or `problem()` -> Read(path, why).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Write the state to its path atomically, in six steps; an error leaves the old file.
|
||||
fn save(&self) -> Result<(), StateError> {
|
||||
// `persist`, its error as Write(path, error).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn persist(&self) -> io::Result<()> {
|
||||
// The six steps of brokerd/src/state.rs, persist: 1. the parent directory, recursive, 0700;
|
||||
// 2. serde_json::to_string plus "\n"; 3. "<path>.tmp" (with_extension("json.tmp")), create
|
||||
// + truncate, mode 0600; 4. write_all and sync_all; 5. rename over the path; 6. open the
|
||||
// directory and sync_all.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Was this post handled already?
|
||||
pub fn seen(&self, post_id: &str) -> bool {
|
||||
// Is the id in recent?
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A post was handled (acted on or ignored): remember its id and move its channel's mark.
|
||||
pub fn handled(
|
||||
&mut self,
|
||||
post_id: &str,
|
||||
channel: &str,
|
||||
create_at: i64,
|
||||
) -> Result<(), StateError> {
|
||||
// If not seen: push the id to recent, then drop the oldest beyond RECENT_KEPT. The channel
|
||||
// mark becomes the larger of its old value and create_at (a new channel starts at
|
||||
// create_at). Save.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The `create_at` of the last post handled in a channel, if any.
|
||||
pub fn since(&self, channel: &str) -> Option<i64> {
|
||||
// The mark of the channel.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The channels with a mark, for catching up.
|
||||
pub fn channels(&self) -> Vec<String> {
|
||||
// The channel ids that have a mark.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Start catching up a channel from `at` (milliseconds), if it has no mark yet.
|
||||
pub fn mark(&mut self, channel: &str, at: i64) -> Result<(), StateError> {
|
||||
// A channel that has a mark is left alone (nothing saved). Otherwise set it to `at` and
|
||||
// save.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn knows_thread(&self, root: &str) -> bool {
|
||||
// Is the root in threads?
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// This Boxmaker takes part in a thread; the newest `THREADS_KEPT` are kept.
|
||||
pub fn join_thread(&mut self, root: &str) -> Result<(), StateError> {
|
||||
// Known -> nothing. Else push it, drop the oldest beyond THREADS_KEPT, save.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn start_turn(&mut self, turn: InFlight) -> Result<(), StateError> {
|
||||
// Remove any entry of the same session, push this one, save.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn end_turn(&mut self, session: &str) -> Result<(), StateError> {
|
||||
// Remove the entries of the session, save.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The turns left in flight by the last run, removed from the state.
|
||||
pub fn take_in_flight(&mut self) -> Result<Vec<InFlight>, StateError> {
|
||||
// Take the whole list out (std::mem::take); save only when it was not empty.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! One WebSocket connection to Mattermost (M4a spec, section 6): open it, send text, and poll for
|
||||
//! the next text message while answering pings, sending our own, and noticing a dead peer.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::ServerUrl;
|
||||
use crate::net::{Connector, Stream};
|
||||
use crate::ws::WsError;
|
||||
use crate::ws::frame::{CLOSE, Decoder, Incoming, PING, PONG, TEXT, encode};
|
||||
use crate::ws::handshake::handshake;
|
||||
|
||||
/// Mattermost's WebSocket path.
|
||||
pub const PATH: &str = "/api/v4/websocket";
|
||||
|
||||
/// How often we ping, and how long silence may last.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Timing {
|
||||
pub ping_every: Duration,
|
||||
pub dead_after: Duration,
|
||||
}
|
||||
|
||||
pub struct Ws {
|
||||
stream: Stream,
|
||||
decoder: Decoder,
|
||||
random: Box<dyn Read + Send>,
|
||||
timing: Timing,
|
||||
last_heard: Instant,
|
||||
last_ping: Instant,
|
||||
}
|
||||
|
||||
impl Ws {
|
||||
/// Connect, and complete the handshake with `token`. `random` supplies the key and every mask
|
||||
/// (in `gatewayd`, `/dev/urandom`).
|
||||
pub fn open(
|
||||
connector: &Connector,
|
||||
token: &str,
|
||||
timing: Timing,
|
||||
mut random: Box<dyn Read + Send>,
|
||||
) -> Result<Ws, WsError> {
|
||||
// 1. `connector.connect(timing.dead_after)`; its error becomes
|
||||
// WsError::Handshake(e.to_string()).
|
||||
// 2. `handshake(&mut stream, &host_header(connector.server()), PATH, token, &mut random)?`.
|
||||
// 3. A Ws with a new Decoder, and last_heard and last_ping both now.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError> {
|
||||
// 4 mask bytes from `random` (read_exact), then write `encode(opcode, payload, mask)` and
|
||||
// flush.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn send_text(&mut self, text: &str) -> Result<(), WsError> {
|
||||
// `send` with TEXT.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The next text message, or `None` after about `wait` with none. Pings are answered and sent
|
||||
/// here; a close frame is answered and ends the connection (`Closed`); silence past the
|
||||
/// dead-after limit is `Dead`.
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError> {
|
||||
// Loop, with `until = now + wait`:
|
||||
// 1. Every whole message the decoder has: Text -> return it; Ping(p) -> send PONG with p;
|
||||
// Pong -> nothing; Close(code, _) -> send CLOSE with the code as 2 bytes (or empty),
|
||||
// ignore that error, return Closed.
|
||||
// 2. Silence since last_heard >= dead_after -> Dead.
|
||||
// 3. Since last_ping >= ping_every -> send an empty PING, last_ping = now.
|
||||
// 4. now >= until -> Ok(None).
|
||||
// 5. Read timeout: the least of (until - now), (last_ping + ping_every - now) and
|
||||
// (last_heard + dead_after - now), at least 1 ms. Read into a 16 KiB buffer: 0 bytes ->
|
||||
// Closed; n bytes -> feed them, last_heard = now; WouldBlock, TimedOut or Interrupted ->
|
||||
// go round; any other error -> Io.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Send a close frame, best effort, and drop the connection.
|
||||
pub fn close(mut self) {
|
||||
// Send CLOSE with 1000 as 2 big-endian bytes; ignore the error.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Host` header for a server: the port is written only when it is not the scheme's default.
|
||||
pub fn host_header(server: &ServerUrl) -> String {
|
||||
// The host alone when the port is the default for the scheme (443 for tls, 80 otherwise), else
|
||||
// "host:port".
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! WebSocket frames (RFC 6455, section 5), without I/O. `Decoder` is fed the bytes as they arrive,
|
||||
//! in any pieces, and yields whole messages; `encode` builds our masked frames. Everything the
|
||||
//! server sends is untrusted: every length is checked before anything is allocated.
|
||||
|
||||
use crate::ws::WsError;
|
||||
|
||||
/// The largest message we accept, counted from the length fields.
|
||||
pub const MAX_MESSAGE: usize = 1 << 20;
|
||||
|
||||
pub const CONTINUATION: u8 = 0x0;
|
||||
pub const TEXT: u8 = 0x1;
|
||||
pub const CLOSE: u8 = 0x8;
|
||||
pub const PING: u8 = 0x9;
|
||||
pub const PONG: u8 = 0xA;
|
||||
|
||||
/// A whole message from the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Incoming {
|
||||
Text(String),
|
||||
Ping(Vec<u8>),
|
||||
Pong(Vec<u8>),
|
||||
/// A close frame: the status code if there is one, and the reason.
|
||||
Close(Option<u16>, String),
|
||||
}
|
||||
|
||||
/// Reassembles frames into messages.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Decoder {
|
||||
buf: Vec<u8>,
|
||||
/// A text message whose first frame has come and whose last has not.
|
||||
partial: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A parsed header: what it says and how long it is.
|
||||
struct Header {
|
||||
fin: bool,
|
||||
opcode: u8,
|
||||
header_len: usize,
|
||||
payload_len: usize,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new() -> Decoder {
|
||||
Decoder::default()
|
||||
}
|
||||
|
||||
/// Append bytes as they arrived.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// The next whole message, `None` if more bytes are needed, or the error that ends the
|
||||
/// connection. After an error, do not call again.
|
||||
pub fn next_message(&mut self) -> Result<Option<Incoming>, WsError> {
|
||||
// Loop: `self.header()?`, None -> Ok(None). If the buffer holds less than header_len +
|
||||
// payload_len, Ok(None). Otherwise take the payload out and drain the frame from the
|
||||
// buffer, then by opcode: PING -> Ping, PONG -> Pong, CLOSE -> `close(&payload)`. TEXT
|
||||
// starts a new message, CONTINUATION extends `partial`; with fin the whole message must be
|
||||
// UTF-8 (else Protocol) and is returned as Text; without fin it is kept in `partial` and
|
||||
// the loop goes on. Any other opcode is Protocol.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The next frame's header, once it is all here, checked against every rule that does not need
|
||||
/// the payload.
|
||||
fn header(&self) -> Result<Option<Header>, WsError> {
|
||||
// Return Ok(None) while the bytes the header needs have not all arrived. The rules, each an
|
||||
// error: a reserved bit (0x70 of byte 0) is Protocol; the mask bit (0x80 of byte 1) is
|
||||
// Protocol; an opcode that is not CONTINUATION, TEXT, CLOSE, PING or PONG is Protocol.
|
||||
// Length 126: a u16 in the next 2 bytes, below 126 is Protocol (not the shortest form).
|
||||
// 127: a u64 in the next 8 bytes; top bit set is Protocol; <= 0xFFFF is Protocol. Control
|
||||
// frames (opcode & 0x8): not fin, or over 125 bytes, is Protocol. Data frames: TEXT while
|
||||
// `partial` is Some, or CONTINUATION while it is None, is Protocol; a payload over
|
||||
// MAX_MESSAGE minus what `partial` holds is TooLarge. All of it before any allocation.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
fn close(payload: &[u8]) -> Result<Incoming, WsError> {
|
||||
// Empty: Close(None, ""). One byte: Protocol. Otherwise a big-endian u16 code and a UTF-8
|
||||
// reason (else Protocol).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One whole frame from us: FIN set, masked with `mask`.
|
||||
pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
||||
// Byte 0: 0x80 | opcode. Byte 1: 0x80 | length, where the length is the 7-bit form below 126,
|
||||
// 126 then a u16 up to 0xFFFF, else 127 then a u64. Then the 4 mask bytes, then each payload
|
||||
// byte XOR mask[i % 4]. No `as` casts: use try_from.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The opening handshake (RFC 6455, section 4.1) and the base64 it needs.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use crate::http::{Head, read_head};
|
||||
use crate::ws::WsError;
|
||||
|
||||
/// RFC 6455's magic string, appended to the key before hashing.
|
||||
pub const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Standard base64 with padding (RFC 4648, section 4).
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
// Standard alphabet (ALPHABET), "=" padding: each 3 bytes become 4 characters; a last group of
|
||||
// 1 or 2 bytes becomes 2 or 3 characters and 2 or 1 "=". No indexing that can go out of bounds.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The `Sec-WebSocket-Accept` a server must send for `key`.
|
||||
pub fn accept_for(key: &str) -> String {
|
||||
// base64(sha1(key + GUID)), with `proto::sha1::sha1`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A fresh key: 16 bytes from `random` (in `gatewayd`, `/dev/urandom`), in base64.
|
||||
pub fn new_key(random: &mut dyn Read) -> std::io::Result<String> {
|
||||
// 16 bytes read from `random` with read_exact, then base64.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The request, exactly.
|
||||
pub fn request_text(host: &str, path: &str, key: &str, token: &str) -> String {
|
||||
format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer {token}\r\n\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `head` a server's acceptance of `key`? Status 101, `Upgrade: websocket`, a `Connection`
|
||||
/// holding the token `upgrade`, and the right `Sec-WebSocket-Accept` (case matters there).
|
||||
pub fn check_response(head: &Head, key: &str) -> Result<(), WsError> {
|
||||
// In this order, each a Handshake error: status is not 101 ("status <n>"); no Upgrade header
|
||||
// equal to "websocket" ignoring case; no Connection header with a comma-separated token equal
|
||||
// to "upgrade" ignoring case; Sec-WebSocket-Accept missing, or not exactly `accept_for(key)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The whole handshake on `stream`. Nothing after the server's head is read.
|
||||
pub fn handshake(
|
||||
stream: &mut (impl Read + Write),
|
||||
host: &str,
|
||||
path: &str,
|
||||
token: &str,
|
||||
random: &mut dyn Read,
|
||||
) -> Result<(), WsError> {
|
||||
// A new key; write `request_text` and flush; `read_head` (its error is a Handshake error); then
|
||||
// `check_response`. Read nothing after the head.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! The WebSocket client (RFC 6455; M4a spec, section 6): the handshake, the frame codec, and the
|
||||
//! connection that uses them.
|
||||
|
||||
pub mod handshake;
|
||||
|
||||
/// Why a WebSocket ended or could not start. Every one of these ends the connection; `gatewayd`
|
||||
/// then reconnects.
|
||||
#[derive(Debug)]
|
||||
pub enum WsError {
|
||||
/// The server's answer to the handshake was not an upgrade to a WebSocket.
|
||||
Handshake(String),
|
||||
/// A frame broke the protocol.
|
||||
Protocol(String),
|
||||
/// A message over `MAX_MESSAGE`, refused from its length fields.
|
||||
TooLarge,
|
||||
/// The server closed the connection (a close frame, or the end of the stream).
|
||||
Closed,
|
||||
/// Nothing was heard for the dead-after limit.
|
||||
Dead,
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
WsError::Handshake(why) => write!(f, "WebSocket handshake failed: {why}"),
|
||||
WsError::Protocol(why) => write!(f, "WebSocket protocol error: {why}"),
|
||||
WsError::TooLarge => write!(f, "WebSocket message too large"),
|
||||
WsError::Closed => write!(f, "WebSocket closed"),
|
||||
WsError::Dead => write!(f, "WebSocket silent for too long"),
|
||||
WsError::Io(e) => write!(f, "WebSocket I/O: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WsError {}
|
||||
|
||||
impl From<std::io::Error> for WsError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
WsError::Io(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! `gatewayd.toml` (M4a spec, section 3). Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::{Config, ConfigError, SecretSource, ServerUrl, parse_url, valid_id};
|
||||
use tmp::TempDir;
|
||||
|
||||
const OWNER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
const CHANNEL: &str = "0123456789abcdefghijklmnop";
|
||||
|
||||
fn minimal() -> String {
|
||||
format!(
|
||||
"[mattermost]\nurl = \"https://straylight.scylla-hammerhead.ts.net\"\n\
|
||||
[secrets.mattermost_token]\ncredential = \"mattermost-token\"\n\
|
||||
[allow]\nusers = [\"{OWNER}\"]\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn load(text: &str) -> Result<Config, ConfigError> {
|
||||
let dir = TempDir::new("cfg");
|
||||
let path = dir.write("gatewayd.toml", text);
|
||||
Config::load(&path)
|
||||
}
|
||||
|
||||
fn invalid(text: &str) -> String {
|
||||
match load(text) {
|
||||
Err(ConfigError::Invalid(_, why)) => why,
|
||||
other => panic!("expected Invalid for {text:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minimal_config_gets_every_default() {
|
||||
let c = load(&minimal()).unwrap();
|
||||
assert_eq!(
|
||||
c.server().unwrap(),
|
||||
ServerUrl {
|
||||
tls: true,
|
||||
host: "straylight.scylla-hammerhead.ts.net".to_string(),
|
||||
port: 443
|
||||
}
|
||||
);
|
||||
assert_eq!(c.mattermost.ca_file, None);
|
||||
assert_eq!(
|
||||
c.token_source().unwrap(),
|
||||
SecretSource::Credential("mattermost-token".to_string())
|
||||
);
|
||||
assert_eq!(c.allow.users, vec![OWNER.to_string()]);
|
||||
assert!(c.allow.channels.is_empty());
|
||||
assert_eq!((c.limits.queue, c.limits.typing_every_ms), (20, 3_000));
|
||||
assert_eq!(
|
||||
(c.limits.ping_every_ms, c.limits.dead_after_ms),
|
||||
(30_000, 60_000)
|
||||
);
|
||||
let home = std::env::var_os("BOXMAKER_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
|
||||
assert_eq!(c.loop_socket(), home.join("run/loop/loop.sock"));
|
||||
assert_eq!(c.state_path(), home.join("gateway/state.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_value_can_be_set() {
|
||||
let text = format!(
|
||||
"[mattermost]\nurl = \"http://127.0.0.1:8065\"\nca_file = \"/etc/boxmaker/ca.pem\"\n\
|
||||
[secrets.mattermost_token]\nfile = \"/home/k/.config/boxmaker/token\"\n\
|
||||
[allow]\nusers = [\"{OWNER}\"]\nchannels = [\"{CHANNEL}\"]\n\
|
||||
[loop]\nsocket = \"/run/l.sock\"\n[paths]\nhome = \"/h\"\n\
|
||||
[limits]\nqueue = 5\ntyping_every_ms = 1\nping_every_ms = 2\ndead_after_ms = 3\n"
|
||||
);
|
||||
let c = load(&text).unwrap();
|
||||
assert_eq!(
|
||||
c.server().unwrap(),
|
||||
ServerUrl {
|
||||
tls: false,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8065
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
c.mattermost.ca_file,
|
||||
Some(PathBuf::from("/etc/boxmaker/ca.pem"))
|
||||
);
|
||||
assert_eq!(
|
||||
c.token_source().unwrap(),
|
||||
SecretSource::File(PathBuf::from("/home/k/.config/boxmaker/token"))
|
||||
);
|
||||
assert_eq!(c.allow.channels, vec![CHANNEL.to_string()]);
|
||||
assert_eq!(c.loop_socket(), PathBuf::from("/run/l.sock"));
|
||||
assert_eq!(c.state_path(), PathBuf::from("/h/gateway/state.json"));
|
||||
assert_eq!(
|
||||
(
|
||||
c.limits.queue,
|
||||
c.limits.typing_every_ms,
|
||||
c.limits.ping_every_ms,
|
||||
c.limits.dead_after_ms
|
||||
),
|
||||
(5, 1, 2, 3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_env_secret() {
|
||||
let text = minimal().replace(
|
||||
"credential = \"mattermost-token\"",
|
||||
"env = \"BOXMAKER_MM_TOKEN\"",
|
||||
);
|
||||
assert_eq!(
|
||||
load(&text).unwrap().token_source().unwrap(),
|
||||
SecretSource::Env("BOXMAKER_MM_TOKEN".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls() {
|
||||
for (url, tls, host, port) in [
|
||||
("https://a.example", true, "a.example", 443),
|
||||
("https://a.example:8443", true, "a.example", 8443),
|
||||
("http://localhost", false, "localhost", 80),
|
||||
("http://127.0.0.1:8065", false, "127.0.0.1", 8065),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_url(url),
|
||||
Ok(ServerUrl {
|
||||
tls,
|
||||
host: host.to_string(),
|
||||
port
|
||||
}),
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
for url in [
|
||||
"",
|
||||
"a.example",
|
||||
"ftp://a.example",
|
||||
"https://",
|
||||
"https://a.example/",
|
||||
"https://a.example/api",
|
||||
"https://A.example",
|
||||
"https://a.example:0",
|
||||
"https://a.example:65536",
|
||||
"https://a.example:0443",
|
||||
"https://a.example:",
|
||||
"https://user@a.example",
|
||||
"https://a.example?x",
|
||||
"https://.a.example",
|
||||
"https://a.example.",
|
||||
"https://[::1]:443",
|
||||
"https:// a.example",
|
||||
] {
|
||||
assert!(parse_url(url).is_err(), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert!(valid_id(OWNER));
|
||||
assert!(valid_id(CHANNEL));
|
||||
for id in [
|
||||
"",
|
||||
"abc",
|
||||
"abcdefghijklmnopqrstuvwxyZ",
|
||||
"abcdefghijklmnopqrstuvwxy-",
|
||||
"abcdefghijklmnopqrstuvwxyza",
|
||||
] {
|
||||
assert!(!valid_id(id), "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_are_named() {
|
||||
let token = "credential = \"mattermost-token\"";
|
||||
let cases: Vec<(String, &str)> = vec![
|
||||
(
|
||||
minimal().replace(
|
||||
"https://straylight.scylla-hammerhead.ts.net",
|
||||
"https://x.example/path",
|
||||
),
|
||||
"url",
|
||||
),
|
||||
(
|
||||
minimal()
|
||||
.replace(
|
||||
"[secrets.mattermost_token]",
|
||||
"[mattermost2]\n[secrets.other]",
|
||||
)
|
||||
.replace("[mattermost2]\n", ""),
|
||||
"mattermost_token",
|
||||
),
|
||||
(
|
||||
minimal().replace(token, "credential = \"a b\""),
|
||||
"credential",
|
||||
),
|
||||
(minimal().replace(token, "env = \"lower\""), "env"),
|
||||
(
|
||||
minimal().replace(token, "file = \"relative/token\""),
|
||||
"absolute",
|
||||
),
|
||||
(
|
||||
minimal().replace(token, "credential = \"x\"\nenv = \"Y\""),
|
||||
"exactly one",
|
||||
),
|
||||
(minimal().replace(token, ""), "exactly one"),
|
||||
(
|
||||
minimal().replace(&format!("[\"{OWNER}\"]"), "[]"),
|
||||
"users is empty",
|
||||
),
|
||||
(minimal().replace(OWNER, "tooshort"), "not a Mattermost id"),
|
||||
(
|
||||
format!("{}channels = [\"NOTANID\"]\n", minimal()),
|
||||
"not a Mattermost id",
|
||||
),
|
||||
(
|
||||
minimal().replace("[mattermost]\n", "[mattermost]\nca_file = \"ca.pem\"\n"),
|
||||
"ca_file",
|
||||
),
|
||||
(format!("{}[limits]\nqueue = 0\n", minimal()), "queue"),
|
||||
(
|
||||
format!("{}[limits]\ndead_after_ms = 0\n", minimal()),
|
||||
"dead_after_ms",
|
||||
),
|
||||
];
|
||||
for (text, word) in cases {
|
||||
let why = invalid(&text);
|
||||
assert!(why.contains(word), "{word}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_and_missing_tables_are_parse_errors() {
|
||||
for text in [
|
||||
format!("{}[allow2]\n", minimal()),
|
||||
minimal().replace("[mattermost]\n", "[mattermost]\nproxy = \"x\"\n"),
|
||||
minimal().replace(
|
||||
"credential = \"mattermost-token\"",
|
||||
"credential = \"t\"\nkeyring = \"x\"",
|
||||
),
|
||||
minimal().replace(&format!("[allow]\nusers = [\"{OWNER}\"]\n"), ""),
|
||||
format!("{}[limits]\nqueue = -1\n", minimal()),
|
||||
] {
|
||||
assert!(matches!(load(&text), Err(ConfigError::Parse(..))), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
|
||||
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
|
||||
//! 8). Do not edit.
|
||||
|
||||
#[path = "support/fake_loop.rs"]
|
||||
mod fake_loop;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use fake_loop::{Reply, done, error, event, serve_loop};
|
||||
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Poster, deliver, split_answer};
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::sessions::{Batch, Thread};
|
||||
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
|
||||
use tmp::TempDir;
|
||||
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
const ROOT: &str = "r0000000000000000000000000";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Record {
|
||||
posts: Mutex<Vec<(String, String, String)>>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl Poster for Record {
|
||||
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
|
||||
if self.fail {
|
||||
return Err(MmError::Status(500, "down".to_string()));
|
||||
}
|
||||
self.posts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((channel.to_string(), root.to_string(), text.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn texts(&self) -> Vec<String> {
|
||||
let posts = self.posts.lock().unwrap();
|
||||
assert!(
|
||||
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
|
||||
"every post in the thread"
|
||||
);
|
||||
posts.iter().map(|(_, _, t)| t.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(resume: bool, text: &str) -> Batch {
|
||||
Batch {
|
||||
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
||||
thread: Thread {
|
||||
channel: DM.to_string(),
|
||||
root: ROOT.to_string(),
|
||||
},
|
||||
resume,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
|
||||
let log = Mutex::new(Vec::new());
|
||||
deliver(poster, &dir.path().join("loop.sock"), batch, &|line| {
|
||||
log.lock().unwrap().push(line.to_string())
|
||||
});
|
||||
log.into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_answer_is_posted_and_nothing_else() {
|
||||
let dir = TempDir::new("deliver-answer");
|
||||
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||
vec![
|
||||
event(TurnEvent::Progress {
|
||||
total: 10,
|
||||
cache: 0,
|
||||
processed: 10,
|
||||
}),
|
||||
event(TurnEvent::Reasoning {
|
||||
text: "private thoughts".to_string(),
|
||||
}),
|
||||
event(TurnEvent::ToolCallStarted {
|
||||
name: "read_file".to_string(),
|
||||
}),
|
||||
event(TurnEvent::ToolResult {
|
||||
name: "read_file".to_string(),
|
||||
class: DataClass::Private,
|
||||
truncated: false,
|
||||
}),
|
||||
event(TurnEvent::Content {
|
||||
text: "The ans".to_string(),
|
||||
}),
|
||||
done("The answer."),
|
||||
]
|
||||
});
|
||||
let poster = Record::default();
|
||||
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
|
||||
assert_eq!(poster.texts(), ["The answer."]);
|
||||
assert!(log.is_empty(), "{log:?}");
|
||||
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(
|
||||
(turn.session.as_str(), turn.content.as_str(), turn.resume),
|
||||
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_approval_is_announced_once_before_the_answer() {
|
||||
let dir = TempDir::new("deliver-approval");
|
||||
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
|
||||
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
|
||||
vec![
|
||||
event(TurnEvent::ApprovalPending {
|
||||
approval: 42,
|
||||
tool: "shell".to_string(),
|
||||
expires,
|
||||
}),
|
||||
done("done"),
|
||||
]
|
||||
});
|
||||
let poster = Record::default();
|
||||
run(&dir, &poster, &batch(false, "go"));
|
||||
assert_eq!(
|
||||
poster.texts(),
|
||||
[
|
||||
"waiting for approval 42: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)",
|
||||
"done"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_posted_with_their_code() {
|
||||
let dir = TempDir::new("deliver-error");
|
||||
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||
vec![error(
|
||||
ErrorCode::Inference,
|
||||
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
|
||||
)]
|
||||
});
|
||||
let poster = Record::default();
|
||||
run(&dir, &poster, &batch(false, "go"));
|
||||
assert_eq!(
|
||||
poster.texts(),
|
||||
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
|
||||
let dir = TempDir::new("deliver-unknown");
|
||||
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
|
||||
if n == 0 {
|
||||
vec![error(ErrorCode::NoSuchSession, "no such session")]
|
||||
} else {
|
||||
vec![done("hello")]
|
||||
}
|
||||
});
|
||||
let poster = Record::default();
|
||||
run(&dir, &poster, &batch(true, "hi"));
|
||||
assert_eq!(poster.texts(), ["hello"]);
|
||||
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(
|
||||
(first.resume, second.resume, second.content.as_str()),
|
||||
(true, false, "hi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_session_is_not_retried() {
|
||||
let dir = TempDir::new("deliver-noretry");
|
||||
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||
vec![error(ErrorCode::NoSuchSession, "odd")]
|
||||
});
|
||||
let poster = Record::default();
|
||||
run(&dir, &poster, &batch(false, "hi"));
|
||||
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
|
||||
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
|
||||
assert!(
|
||||
turns.recv_timeout(Duration::from_millis(200)).is_err(),
|
||||
"one turn only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_that_is_not_there_or_goes_away() {
|
||||
let dir = TempDir::new("deliver-down");
|
||||
let poster = Record::default();
|
||||
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||
assert_eq!(poster.texts(), [LOOP_DOWN]);
|
||||
assert_eq!(log.len(), 1, "{log:?}");
|
||||
assert!(
|
||||
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
|
||||
"{log:?}"
|
||||
);
|
||||
|
||||
for (n, replies) in [
|
||||
vec![
|
||||
event(TurnEvent::Content {
|
||||
text: "x".to_string(),
|
||||
}),
|
||||
Reply::Close,
|
||||
],
|
||||
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
|
||||
vec![
|
||||
Reply::Frame(proto::Envelope {
|
||||
v: 1,
|
||||
id: 2,
|
||||
r#final: true,
|
||||
msg: proto::Message::Ok(proto::Empty {}),
|
||||
}),
|
||||
done("late"),
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let dir = TempDir::new(&format!("deliver-early-{n}"));
|
||||
let replies = Mutex::new(Some(replies));
|
||||
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
|
||||
replies.lock().unwrap().take().unwrap_or_default()
|
||||
});
|
||||
let poster = Record::default();
|
||||
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
|
||||
assert_eq!(log.len(), 1, "case {n}: {log:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_post_that_fails_is_logged() {
|
||||
let dir = TempDir::new("deliver-postfail");
|
||||
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
|
||||
let poster = Record {
|
||||
fail: true,
|
||||
..Record::default()
|
||||
};
|
||||
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||
assert_eq!(
|
||||
log,
|
||||
[format!(
|
||||
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_answers_are_split_at_newlines() {
|
||||
let short = "a".repeat(MAX_POST);
|
||||
assert_eq!(split_answer(&short), [short.as_str()]);
|
||||
let over = "a".repeat(MAX_POST + 1);
|
||||
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
|
||||
let lines = format!(
|
||||
"{}\n{}\n{}",
|
||||
"a".repeat(10_000),
|
||||
"b".repeat(5_000),
|
||||
"c".repeat(2_000)
|
||||
);
|
||||
assert_eq!(
|
||||
split_answer(&lines),
|
||||
[
|
||||
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
|
||||
"c".repeat(2_000)
|
||||
]
|
||||
);
|
||||
let wide = "é".repeat(MAX_POST + 5);
|
||||
let parts = split_answer(&wide);
|
||||
assert_eq!(
|
||||
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
|
||||
[MAX_POST, 5],
|
||||
"characters, not bytes"
|
||||
);
|
||||
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
|
||||
let parts = split_answer(&leading);
|
||||
assert!(
|
||||
parts
|
||||
.iter()
|
||||
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
|
||||
"{:?}",
|
||||
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
|
||||
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
|
||||
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_answer_is_posted_in_order() {
|
||||
let dir = TempDir::new("deliver-long");
|
||||
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
|
||||
let sent = answer.clone();
|
||||
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
|
||||
let poster = Record::default();
|
||||
run(&dir, &poster, &batch(false, "hi"));
|
||||
assert_eq!(
|
||||
poster.texts(),
|
||||
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# TEST-ONLY TLS fixtures
|
||||
|
||||
Generated once with `openssl` on 2026-09-23 for `gatewayd`'s TLS tests. Every key here is public
|
||||
and must never be trusted anywhere but these tests. The two CA private keys were deleted after
|
||||
signing; the server keys are kept because the test servers need them.
|
||||
|
||||
- `test-ca.pem`: the CA the tests trust (through `ca_file`).
|
||||
- `server.pem`/`server.key`: `localhost` and `127.0.0.1`, signed by `test-ca`.
|
||||
- `wrong-name.pem`/`wrong-name.key`: `wrong.example` only, signed by `test-ca` (a name mismatch).
|
||||
- `other-ca.pem`, `other-server.pem`/`other-server.key`: a CA the tests do not trust.
|
||||
|
||||
Valid for 100 years from 2026-09-23.
|
||||
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBszCCAVmgAwIBAgIUZK7y645vuYezo+uB8S2ktvXjLBUwCgYIKoZIzj0EAwIw
|
||||
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
|
||||
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAmMSQwIgYDVQQDDBtCb3htYWtlciBU
|
||||
RVNULU9OTFkgb3RoZXIgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQV1sRm
|
||||
ePvJps71wo1/QVUi8Y0Ra4kFhJ0WI7VLIqeINPpaQtBKHUH+SWrjR4mromEtJ8ZR
|
||||
d3frK7jBFmI+AVmLo2MwYTAdBgNVHQ4EFgQUUBg/R1eSkXP7swTjnoEFSvcIGcww
|
||||
HwYDVR0jBBgwFoAUUBg/R1eSkXP7swTjnoEFSvcIGcwwDwYDVR0TAQH/BAUwAwEB
|
||||
/zAOBgNVHQ8BAf8EBAMCAgQwCgYIKoZIzj0EAwIDSAAwRQIhAIKFnNomDrIwpeOG
|
||||
wdsm8NfXWydx7Mp2/ujRCXCqMyrAAiAo2hoprQhU3uRmyrTtokBAqE5kWFSKOa6K
|
||||
BlnVrcFSvw==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg940CxyChSA5oYsx2
|
||||
W6tmkg66INWcYxOOfUcqkvi9TBehRANCAAQBOwVQrucb42OCoWScE/Grn6DnmMBk
|
||||
6yceR+ZNU9wvYwMKBovg6sErdvjACNlYIsAkjjRuO7xYQbrxJ4ixoY3O
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIB2zCCAYCgAwIBAgIUWxsnA8gxRlvuY1SrXg+eQxRDq7cwCgYIKoZIzj0EAwIw
|
||||
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
|
||||
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAuMSwwKgYDVQQDDCNsb2NhbGhvc3Qg
|
||||
ZnJvbSBvdGhlciBDQSAoVEVTVCBPTkxZKTBZMBMGByqGSM49AgEGCCqGSM49AwEH
|
||||
A0IABAE7BVCu5xvjY4KhZJwT8aufoOeYwGTrJx5H5k1T3C9jAwoGi+DqwSt2+MAI
|
||||
2VgiwCSONG47vFhBuvEniLGhjc6jgYEwfzAaBgNVHREEEzARgglsb2NhbGhvc3SH
|
||||
BH8AAAEwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4E
|
||||
FgQUdpTjXgMrZvKV54i+QnheUhd17IswHwYDVR0jBBgwFoAUUBg/R1eSkXP7swTj
|
||||
noEFSvcIGcwwCgYIKoZIzj0EAwIDSQAwRgIhAJqpsTc14FSZpyWvmn6G0Ar2bxLz
|
||||
CYQNanzxCPLMDGTCAiEA/F1wQjxrCikZAfuQKBKL5cc2MHf2dsSjZq2Sg5OC6z0=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgtj+G5HUdct3YHcZ2
|
||||
rvTnI3blyTjkfEiwVwGTqOINYZChRANCAAQcwnbJi8KAjVQEQd/mIXFCSDGNcy9V
|
||||
XRx5uZ+wqnAUqbmsj+LHl6q9KM1Y3bowFBIHQOjpBWvy8JA0oPRJPLWM
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIB4zCCAYigAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9MwCgYIKoZIzj0EAwIw
|
||||
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
|
||||
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
|
||||
IDEeMBwGA1UEAwwVbG9jYWxob3N0IChURVNUIE9OTFkpMFkwEwYHKoZIzj0CAQYI
|
||||
KoZIzj0DAQcDQgAEHMJ2yYvCgI1UBEHf5iFxQkgxjXMvVV0cebmfsKpwFKm5rI/i
|
||||
x5eqvSjNWN26MBQSB0Do6QVr8vCQNKD0STy1jKOBgTB/MBoGA1UdEQQTMBGCCWxv
|
||||
Y2FsaG9zdIcEfwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMB
|
||||
MB0GA1UdDgQWBBRtebrcLsFuJloYqBEhrWKh4ghINTAfBgNVHSMEGDAWgBRZLO26
|
||||
Eow46wSsnj/mQBJ5Hi7VZDAKBggqhkjOPQQDAgNJADBGAiEAn215O/7cosHkI5n4
|
||||
7Kuq+30BXfrqBHnZ6FznHQIgIjsCIQDW8om0qRjIo5dXNIY4DLj757+KaleqaQdE
|
||||
oFc079H45g==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIB3zCCAYWgAwIBAgIURpYQTJ2pH+M6c7wb2aMwpr96TQUwCgYIKoZIzj0EAwIw
|
||||
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
|
||||
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
|
||||
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
|
||||
dXRzaWRlIHRlc3RzKTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMeSQ30pi+FZ
|
||||
85pHjd7+q6bo30eJGcdwmiK2MwlASDejQb0nA4cOWqPLGdlNO4o5679DwiigSUnv
|
||||
yh/V1yJ4KyqjYzBhMB0GA1UdDgQWBBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAfBgNV
|
||||
HSMEGDAWgBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAPBgNVHRMBAf8EBTADAQH/MA4G
|
||||
A1UdDwEB/wQEAwICBDAKBggqhkjOPQQDAgNIADBFAiA+LzwUA1QvGOcDNxMbnbb8
|
||||
ycfuH+i16pebeH3rcJIwDAIhALgKBj1r2ItuB/Rag8Y0sYs9rx5Arlikzg2VGWoT
|
||||
CYbm
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgflra3VFKl15oCyVi
|
||||
0KDJ52JphSZfIFDqAmFIUdVow0ShRANCAAQLaVnu5yblt9VdhunVTXzxk4k1ZIAv
|
||||
qs0WEHCiNRfR+Wex5GpMfRCDcHH6fFlqyq5YpFV0/ripVSlt3RnH9Ok5
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIB4jCCAYmgAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9QwCgYIKoZIzj0EAwIw
|
||||
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
|
||||
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
|
||||
JDEiMCAGA1UEAwwZd3JvbmcuZXhhbXBsZSAoVEVTVCBPTkxZKTBZMBMGByqGSM49
|
||||
AgEGCCqGSM49AwEHA0IABAtpWe7nJuW31V2G6dVNfPGTiTVkgC+qzRYQcKI1F9H5
|
||||
Z7Hkakx9EINwcfp8WWrKrlikVXT+uKlVKW3dGcf06TmjfzB9MBgGA1UdEQQRMA+C
|
||||
DXdyb25nLmV4YW1wbGUwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcD
|
||||
ATAdBgNVHQ4EFgQU7JXvioL6xNp1Xd8wEN1vHw32ZC4wHwYDVR0jBBgwFoAUWSzt
|
||||
uhKMOOsErJ4/5kASeR4u1WQwCgYIKoZIzj0EAwIDRwAwRAIgJ+BxEK1QVQUeI/PM
|
||||
Ap1A7fHECE5GTgKazmJ79DiRBa4CIEEAw9AxKBjNn5gXcQWe/zSs+cGwD6jAxdAe
|
||||
hbIC76Kx
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,207 @@
|
||||
//! The HTTP client over an in-memory stream: what it writes, how it reads each kind of body, its
|
||||
//! caps, and the wait a 429 asks for (M4a spec, section 5). Do not edit.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use gatewayd::http::{Head, HttpError, MAX_BODY, MAX_HEAD, rate_limit_wait, read_head, request};
|
||||
|
||||
/// Reads from `input`, records what is written.
|
||||
struct Duplex {
|
||||
input: Cursor<Vec<u8>>,
|
||||
output: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
fn new(input: &[u8]) -> Duplex {
|
||||
Duplex {
|
||||
input: Cursor::new(input.to_vec()),
|
||||
output: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.input.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Duplex {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.output.write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get(response: &[u8]) -> Result<(u16, Vec<u8>), HttpError> {
|
||||
let mut d = Duplex::new(response);
|
||||
let r = request(&mut d, "GET", "a.example", "/api/v4/users/me", &[], None)?;
|
||||
Ok((r.head.status, r.body))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_request_is_exactly_this() {
|
||||
let mut d = Duplex::new(b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}");
|
||||
let r = request(
|
||||
&mut d,
|
||||
"POST",
|
||||
"straylight.example",
|
||||
"/api/v4/posts",
|
||||
&[
|
||||
("Authorization", "Bearer t"),
|
||||
("Content-Type", "application/json"),
|
||||
],
|
||||
Some(b"{\"message\":\"hi\"}"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(d.output).unwrap(),
|
||||
"POST /api/v4/posts HTTP/1.1\r\nHost: straylight.example\r\nAuthorization: Bearer t\r\n\
|
||||
Content-Type: application/json\r\nContent-Length: 16\r\nConnection: close\r\n\r\n{\"message\":\"hi\"}"
|
||||
);
|
||||
assert_eq!((r.head.status, r.body), (201, b"{}".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_by_length_chunks_or_close() {
|
||||
assert_eq!(
|
||||
get(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").unwrap(),
|
||||
(200, b"hello".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
get(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;x=y\r\n world\r\n0\r\nTrailer: z\r\n\r\n").unwrap(),
|
||||
(200, b"hello world".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
get(b"HTTP/1.1 200 OK\r\ntransfer-encoding: CHUNKED\r\n\r\nA\r\n0123456789\r\n0\r\n\r\n")
|
||||
.unwrap()
|
||||
.1,
|
||||
b"0123456789".to_vec()
|
||||
);
|
||||
assert_eq!(
|
||||
get(b"HTTP/1.0 200 OK\r\n\r\nuntil the end").unwrap(),
|
||||
(200, b"until the end".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
get(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n").unwrap(),
|
||||
(204, Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_are_found_whatever_their_case_and_trimmed() {
|
||||
let mut d = Duplex::new(
|
||||
b"HTTP/1.1 101 Switching Protocols\r\nUPGRADE: websocket \r\nX-A: 1\r\n\r\nFRAMES",
|
||||
);
|
||||
let head = read_head(&mut d).unwrap();
|
||||
assert_eq!(head.status, 101);
|
||||
assert_eq!(head.header("upgrade"), Some("websocket"));
|
||||
assert_eq!(head.header("x-a"), Some("1"));
|
||||
assert_eq!(head.header("missing"), None);
|
||||
let mut rest = String::new();
|
||||
d.read_to_string(&mut rest).unwrap();
|
||||
assert_eq!(
|
||||
rest, "FRAMES",
|
||||
"read_head reads nothing past the blank line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_responses_are_errors_not_panics() {
|
||||
for bad in [
|
||||
&b""[..],
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n",
|
||||
b"HTTP/2 200 OK\r\n\r\n",
|
||||
b"HTTP/1.1 2000 OK\r\n\r\n",
|
||||
b"HTTP/1.1 abc OK\r\n\r\n",
|
||||
b"HTTP/1.1 99 OK\r\n\r\n",
|
||||
b"HTTP/1.1 200 OK\r\nno colon here\r\n\r\n",
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: five\r\n\r\n",
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort",
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n",
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhelloXX0\r\n\r\n",
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel",
|
||||
b"HTTP/1.1 200 OK\r\n\xff\xfe: x\r\n\r\n",
|
||||
] {
|
||||
assert!(get(bad).is_err(), "{:?}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_are_checked_before_reading_or_allocating() {
|
||||
let mut long_head = b"HTTP/1.1 200 OK\r\nX: ".to_vec();
|
||||
long_head.extend(std::iter::repeat_n(b'a', MAX_HEAD));
|
||||
long_head.extend(b"\r\n\r\n");
|
||||
assert!(matches!(get(&long_head), Err(HttpError::TooLarge("head"))));
|
||||
let huge = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
|
||||
MAX_BODY + 1
|
||||
);
|
||||
assert!(
|
||||
matches!(get(huge.as_bytes()), Err(HttpError::TooLarge("body"))),
|
||||
"refused from the header alone"
|
||||
);
|
||||
let huge_chunk = format!(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n",
|
||||
MAX_BODY + 1
|
||||
);
|
||||
assert!(matches!(
|
||||
get(huge_chunk.as_bytes()),
|
||||
Err(HttpError::TooLarge("body"))
|
||||
));
|
||||
let overflow = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffffffff\r\n";
|
||||
assert!(get(overflow.as_bytes()).is_err());
|
||||
let mut to_close = b"HTTP/1.0 200 OK\r\n\r\n".to_vec();
|
||||
to_close.extend(std::iter::repeat_n(b'b', MAX_BODY + 1));
|
||||
assert!(matches!(get(&to_close), Err(HttpError::TooLarge("body"))));
|
||||
}
|
||||
|
||||
fn head_with(reset: Option<&str>) -> Head {
|
||||
let mut headers = vec![("X-Ratelimit-Limit".to_string(), "10".to_string())];
|
||||
if let Some(r) = reset {
|
||||
headers.push(("X-Ratelimit-Reset".to_string(), r.to_string()));
|
||||
}
|
||||
Head {
|
||||
status: 429,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limit_is_waited_out_within_bounds() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("3")), now),
|
||||
Duration::from_secs(3)
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("1800000005")), now),
|
||||
Duration::from_secs(5),
|
||||
"a Unix time"
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("1799999999")), now),
|
||||
Duration::from_secs(1),
|
||||
"already past"
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("0")), now),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("999999")), now),
|
||||
Duration::from_secs(60),
|
||||
"capped"
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(None), now),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_wait(&head_with(Some("soon")), now),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The `gatewayd` program: its usage, what stops it at start, the warning for a secret in a file,
|
||||
//! and that the token never reaches its output (M4a spec, sections 3, 4 and 10). Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tmp::TempDir;
|
||||
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const TOKEN: &str = "tok-3f9a1c7e5b2d4f6a8c0e";
|
||||
|
||||
fn gatewayd() -> Command {
|
||||
let mut c = Command::new(env!("CARGO_BIN_EXE_gatewayd"));
|
||||
c.env_remove("GW_TEST_TOKEN")
|
||||
.env_remove("CREDENTIALS_DIRECTORY");
|
||||
c
|
||||
}
|
||||
|
||||
fn closed_url() -> String {
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
format!("http://127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
fn write_config(dir: &TempDir, secret: &str) -> std::path::PathBuf {
|
||||
let text = format!(
|
||||
"[mattermost]\nurl = \"{}\"\n[secrets.mattermost_token]\n{secret}\n[allow]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n",
|
||||
closed_url(),
|
||||
dir.path().join("home").display()
|
||||
);
|
||||
dir.write("gatewayd.toml", &text)
|
||||
}
|
||||
|
||||
/// Run until `want` appears on standard error or 5 s pass, then kill it; all it printed.
|
||||
fn stderr_until(mut cmd: Command, want: &str) -> String {
|
||||
let mut child = cmd
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut reader = BufReader::new(child.stderr.take().unwrap());
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
let mut all = String::new();
|
||||
while Instant::now() < until && !all.contains(want) {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
break;
|
||||
}
|
||||
all.push_str(&line);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["serve"][..],
|
||||
&["serve", "--config"][..],
|
||||
&["run", "--config", "x"][..],
|
||||
] {
|
||||
let out = gatewayd().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
"usage: gatewayd serve --config <path>\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_config_stops_at_start() {
|
||||
let dir = TempDir::new("main-config");
|
||||
let missing = dir.path().join("nope.toml");
|
||||
let bad = dir.write("bad.toml", "[mattermost]\nurl = \"ftp://x\"\n");
|
||||
for path in [missing, bad] {
|
||||
let out = gatewayd()
|
||||
.arg("serve")
|
||||
.arg("--config")
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "{err}");
|
||||
assert!(
|
||||
err.starts_with(&format!("gatewayd: {}: ", path.display())),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
err.ends_with("\nsee docs/runbook.md#gatewayd-start-failed\n"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_secret_stops_at_start() {
|
||||
let dir = TempDir::new("main-secret");
|
||||
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
|
||||
let out = gatewayd()
|
||||
.arg("serve")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.output()
|
||||
.unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "{err}");
|
||||
assert!(
|
||||
err.starts_with("gatewayd: secret mattermost_token: "),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
err.ends_with("\nsee docs/runbook.md#secret-unavailable\n"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
!dir.path().join("home").exists(),
|
||||
"nothing is made before the secret is read"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_in_a_file_warns_and_the_token_is_never_printed() {
|
||||
let dir = TempDir::new("main-file");
|
||||
let secret = dir.write("token", &format!("{TOKEN}\n"));
|
||||
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let config = write_config(&dir, &format!("file = \"{}\"", secret.display()));
|
||||
let mut cmd = gatewayd();
|
||||
cmd.arg("serve").arg("--config").arg(&config);
|
||||
let err = stderr_until(cmd, "trying again");
|
||||
let warning = format!(
|
||||
"gatewayd: warning: secret mattermost_token is read in plaintext from {}; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)\n",
|
||||
secret.display()
|
||||
);
|
||||
assert!(err.starts_with(&warning), "{err}");
|
||||
assert!(
|
||||
err.contains("gatewayd: cannot reach http://127.0.0.1:"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(!err.contains(TOKEN), "{err}");
|
||||
let mode = std::fs::metadata(dir.path().join("home/gateway"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_from_the_environment_has_no_warning() {
|
||||
let dir = TempDir::new("main-env");
|
||||
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
|
||||
let mut cmd = gatewayd();
|
||||
cmd.arg("serve")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.env("GW_TEST_TOKEN", TOKEN);
|
||||
let err = stderr_until(cmd, "trying again");
|
||||
assert!(err.starts_with("gatewayd: cannot reach "), "{err}");
|
||||
assert!(!err.contains("warning") && !err.contains(TOKEN), "{err}");
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Mattermost's JSON: events as the server sends them (v11.11.0 shapes), the typing request, and
|
||||
//! the `posts?since` list, whose edited, deleted and root-only posts must not be answered (M4a spec,
|
||||
//! sections 7 and 9). Do not edit.
|
||||
|
||||
use gatewayd::mm::{Event, MmError, SINCE_LIMIT, parse_event, since_list, typing};
|
||||
use serde_json::json;
|
||||
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
|
||||
fn post(id: &str, create_at: i64) -> serde_json::Value {
|
||||
json!({
|
||||
"id": id, "create_at": create_at, "update_at": create_at, "edit_at": 0, "delete_at": 0,
|
||||
"is_pinned": false, "user_id": KYLE, "channel_id": DM, "root_id": "", "original_id": "",
|
||||
"message": "hello", "type": "", "props": {"from_bot": "true"}, "hashtags": "",
|
||||
"pending_post_id": "", "reply_count": 0, "metadata": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn id(n: u32) -> String {
|
||||
format!("p{n:025}")
|
||||
}
|
||||
|
||||
fn posted(post: &serde_json::Value, channel_type: &str) -> String {
|
||||
json!({
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_display_name": "@kyle", "channel_name": "x__y", "channel_type": channel_type,
|
||||
"post": post.to_string(), "sender_name": "@kyle", "set_online": true, "team_id": ""
|
||||
},
|
||||
"broadcast": {"omit_users": null, "user_id": "", "channel_id": DM, "team_id": ""},
|
||||
"seq": 3
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_replies_and_other_events() {
|
||||
let hello =
|
||||
json!({"event": "hello", "data": {"server_version": "11.11.0"}, "broadcast": {}, "seq": 0});
|
||||
assert_eq!(parse_event(&hello.to_string()).unwrap(), Event::Hello);
|
||||
assert_eq!(
|
||||
parse_event(r#"{"status":"OK","seq_reply":1}"#).unwrap(),
|
||||
Event::Other(String::new())
|
||||
);
|
||||
let typing = json!({"event": "typing", "data": {"parent_id": ""}, "broadcast": {}, "seq": 4});
|
||||
assert_eq!(
|
||||
parse_event(&typing.to_string()).unwrap(),
|
||||
Event::Other("typing".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_posted_event_carries_the_post_as_a_string() {
|
||||
let mut p = post(&id(1), 1_758_650_000_123);
|
||||
p["root_id"] = json!(id(0));
|
||||
p["message"] = json!("line one\nline two");
|
||||
let Event::Posted { post, channel_type } = parse_event(&posted(&p, "D")).unwrap() else {
|
||||
panic!("not a post")
|
||||
};
|
||||
assert_eq!(channel_type, "D");
|
||||
assert_eq!(
|
||||
(post.id.as_str(), post.root_id.as_str()),
|
||||
(id(1).as_str(), id(0).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
(post.user_id.as_str(), post.channel_id.as_str()),
|
||||
(KYLE, DM)
|
||||
);
|
||||
assert_eq!(
|
||||
(post.message.as_str(), post.create_at, post.kind.as_str()),
|
||||
("line one\nline two", 1_758_650_000_123, "")
|
||||
);
|
||||
let mut s = self::post(&id(2), 5);
|
||||
s["type"] = json!("system_join_channel");
|
||||
let Event::Posted { post, .. } = parse_event(&posted(&s, "O")).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(post.kind, "system_join_channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_posts_are_errors_not_panics() {
|
||||
let p = post(&id(1), 5);
|
||||
let as_object =
|
||||
json!({"event": "posted", "data": {"post": p, "channel_type": "D"}}).to_string();
|
||||
let mut cases = vec![
|
||||
as_object,
|
||||
"not json".to_string(),
|
||||
"[1,2]".to_string(),
|
||||
"{\"event\":5}".to_string(),
|
||||
];
|
||||
for (field, value) in [
|
||||
("id", json!("../../etc")),
|
||||
("user_id", json!("")),
|
||||
("channel_id", json!("A0000000000000000000000000")),
|
||||
("root_id", json!("short")),
|
||||
("create_at", json!("soon")),
|
||||
] {
|
||||
let mut bad = post(&id(1), 5);
|
||||
bad[field] = value;
|
||||
cases.push(posted(&bad, "D"));
|
||||
}
|
||||
let mut missing = post(&id(1), 5);
|
||||
missing.as_object_mut().unwrap().remove("create_at");
|
||||
cases.push(posted(&missing, "D"));
|
||||
for case in cases {
|
||||
assert!(
|
||||
matches!(parse_event(&case), Err(MmError::Json(_))),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_typing_request() {
|
||||
let got: serde_json::Value = serde_json::from_str(&typing(7, DM, &id(0))).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
json!({"action": "user_typing", "seq": 7, "data": {"channel_id": DM, "parent_id": id(0)}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_keeps_new_posts_in_order_and_nothing_else() {
|
||||
let since = 1000;
|
||||
let mut edited = post(&id(1), 900);
|
||||
edited["update_at"] = json!(1500);
|
||||
let mut deleted = post(&id(2), 1100);
|
||||
deleted["delete_at"] = json!(1200);
|
||||
let root_only = post(&id(3), 10);
|
||||
let later = post(&id(4), 1300);
|
||||
let earlier = post(&id(5), 1200);
|
||||
let at_since = post(&id(6), 1000);
|
||||
let mut posts = serde_json::Map::new();
|
||||
for p in [&edited, &deleted, &root_only, &later, &earlier, &at_since] {
|
||||
posts.insert(p["id"].as_str().unwrap().to_string(), p.clone());
|
||||
}
|
||||
let order = json!([id(4), id(2), id(1), id(5), id(9), id(6)]);
|
||||
let body = json!({"order": order, "posts": posts, "next_post_id": "", "prev_post_id": "", "has_next": false});
|
||||
let got = since_list(body.to_string().as_bytes(), since).unwrap();
|
||||
let ids: Vec<&str> = got.posts.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, [id(5), id(4)]);
|
||||
assert!(!got.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_since_answer_says_so() {
|
||||
let mut posts = serde_json::Map::new();
|
||||
let mut order = Vec::new();
|
||||
for n in 0..SINCE_LIMIT {
|
||||
let n = u32::try_from(n).unwrap();
|
||||
posts.insert(id(n), post(&id(n), 2000 + i64::from(n)));
|
||||
order.push(id(n));
|
||||
}
|
||||
let body = json!({"order": order, "posts": posts});
|
||||
let got = since_list(body.to_string().as_bytes(), 1000).unwrap();
|
||||
assert!(got.full);
|
||||
assert_eq!(got.posts.len(), SINCE_LIMIT);
|
||||
let empty = since_list(br#"{"order":[],"posts":{}}"#, 0).unwrap();
|
||||
assert!(empty.posts.is_empty() && !empty.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_error_body_is_quoted_in_messages() {
|
||||
let e = MmError::Status(500, "boom\ngatewayd: a forged line".to_string());
|
||||
let text = e.to_string();
|
||||
assert!(!text.contains('\n'), "{text}");
|
||||
assert_eq!(
|
||||
MmError::Auth(401).to_string(),
|
||||
"Mattermost refused the token (401)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Mattermost's REST calls against a scripted server: the requests on the wire, and what a 401,
|
||||
//! 403, 429, 5xx or 404 does (M4a spec, sections 5 and 9). Do not edit.
|
||||
|
||||
#[path = "support/http_server.rs"]
|
||||
mod http_server;
|
||||
#[path = "support/tls_server.rs"]
|
||||
mod tls_server;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gatewayd::config::ServerUrl;
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::mm::rest::Client;
|
||||
use gatewayd::net::Connector;
|
||||
use gatewayd::secrets::Secret;
|
||||
use http_server::{Request, reply, serve_http};
|
||||
use serde_json::json;
|
||||
use tls_server::{fixture, server_config};
|
||||
|
||||
const BOT: &str = "b0000000000000000000000000";
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
const POST: &str = "p0000000000000000000000001";
|
||||
|
||||
fn client(addr: SocketAddr, tls: bool) -> Client {
|
||||
let url = ServerUrl {
|
||||
tls,
|
||||
host: "localhost".to_string(),
|
||||
port: addr.port(),
|
||||
};
|
||||
let ca = tls.then(|| fixture("test-ca.pem"));
|
||||
let connector = Connector::new(url, ca.as_deref()).unwrap();
|
||||
Client::new(
|
||||
connector,
|
||||
Secret::new("TOKEN".to_string()),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
}
|
||||
|
||||
fn me_body() -> String {
|
||||
json!({"id": BOT, "username": "boxmaker-straylight", "roles": "system_user", "is_bot": true})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn requests(rx: &Receiver<Request>) -> Vec<Request> {
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn me_plain_and_over_tls() {
|
||||
for tls in [false, true] {
|
||||
let config = tls.then(|| server_config("server"));
|
||||
let (addr, rx) = serve_http(config, |_, _| reply(200, "", &me_body()));
|
||||
let me = client(addr, tls).me().unwrap();
|
||||
assert_eq!(
|
||||
(me.id.as_str(), me.username.as_str()),
|
||||
(BOT, "boxmaker-straylight")
|
||||
);
|
||||
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(
|
||||
(r.method.as_str(), r.path.as_str()),
|
||||
("GET", "/api/v4/users/me")
|
||||
);
|
||||
assert!(
|
||||
r.head.contains("Authorization: Bearer TOKEN\r\n"),
|
||||
"{}",
|
||||
r.head
|
||||
);
|
||||
assert!(
|
||||
r.head
|
||||
.contains(&format!("Host: localhost:{}\r\n", addr.port())),
|
||||
"{}",
|
||||
r.head
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post_sends_the_thread_and_reads_the_post_back() {
|
||||
let (addr, rx) = serve_http(None, |_, r| {
|
||||
let mut p = r.json();
|
||||
p["id"] = json!(POST);
|
||||
p["user_id"] = json!(BOT);
|
||||
p["create_at"] = json!(5);
|
||||
reply(201, "", &p.to_string())
|
||||
});
|
||||
let post = client(addr, false)
|
||||
.create_post(DM, KYLE, "an answer\nin two lines")
|
||||
.unwrap();
|
||||
assert_eq!((post.id.as_str(), post.root_id.as_str()), (POST, KYLE));
|
||||
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(
|
||||
(r.method.as_str(), r.path.as_str()),
|
||||
("POST", "/api/v4/posts")
|
||||
);
|
||||
assert!(
|
||||
r.head.contains("Content-Type: application/json\r\n"),
|
||||
"{}",
|
||||
r.head
|
||||
);
|
||||
assert_eq!(
|
||||
r.json(),
|
||||
json!({"channel_id": DM, "root_id": KYLE, "message": "an answer\nin two lines"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posts_since_and_the_direct_channel() {
|
||||
let (addr, rx) = serve_http(None, |_, r| {
|
||||
if r.path.contains("/posts?since=") {
|
||||
reply(200, "", r#"{"order":[],"posts":{}}"#)
|
||||
} else {
|
||||
reply(201, "", &json!({"id": DM, "type": "D"}).to_string())
|
||||
}
|
||||
});
|
||||
let c = client(addr, false);
|
||||
assert!(
|
||||
c.posts_since(DM, 1_758_650_000_000)
|
||||
.unwrap()
|
||||
.posts
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(c.direct_channel(BOT, KYLE).unwrap(), DM);
|
||||
let rs = requests(&rx);
|
||||
assert_eq!(
|
||||
rs[0].path,
|
||||
format!("/api/v4/channels/{DM}/posts?since=1758650000000")
|
||||
);
|
||||
assert_eq!(
|
||||
(rs[1].method.as_str(), rs[1].path.as_str()),
|
||||
("POST", "/api/v4/channels/direct")
|
||||
);
|
||||
assert_eq!(rs[1].json(), json!([BOT, KYLE]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_channel_that_is_not_an_id_is_never_sent() {
|
||||
let (addr, rx) = serve_http(None, |_, _| reply(200, "", r#"{"order":[],"posts":{}}"#));
|
||||
let err = client(addr, false)
|
||||
.posts_since("../users/me?x=", 0)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MmError::Json(_)), "{err}");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert!(requests(&rx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_token_stops_at_once() {
|
||||
for status in [401, 403] {
|
||||
let (addr, rx) = serve_http(None, move |_, _| {
|
||||
reply(
|
||||
status,
|
||||
"",
|
||||
r#"{"id":"api.context.session_expired.app_error"}"#,
|
||||
)
|
||||
});
|
||||
let err = client(addr, false).me().unwrap_err();
|
||||
assert!(matches!(err, MmError::Auth(s) if s == status), "{err}");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(requests(&rx).len(), 1, "no retry on {status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_error_is_tried_twice_more() {
|
||||
let (addr, rx) = serve_http(None, |n, _| {
|
||||
if n < 2 {
|
||||
reply(503, "", "{}")
|
||||
} else {
|
||||
reply(200, "", &me_body())
|
||||
}
|
||||
});
|
||||
assert!(client(addr, false).me().is_ok());
|
||||
assert_eq!(requests(&rx).len(), 3);
|
||||
|
||||
let (addr, rx) = serve_http(None, |_, _| reply(502, "", "{}"));
|
||||
let err = client(addr, false).me().unwrap_err();
|
||||
assert!(matches!(err, MmError::Status(502, _)), "{err}");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(requests(&rx).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limit_is_waited_out() {
|
||||
let (addr, rx) = serve_http(None, |n, _| {
|
||||
if n == 0 {
|
||||
reply(429, "X-Ratelimit-Reset: 1\r\n", "{}")
|
||||
} else {
|
||||
reply(200, "", &me_body())
|
||||
}
|
||||
});
|
||||
let started = Instant::now();
|
||||
assert!(client(addr, false).me().is_ok());
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(900),
|
||||
"{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(requests(&rx).len(), 2);
|
||||
|
||||
let (addr, _rx) = serve_http(None, |_, _| reply(429, "X-Ratelimit-Reset: 1\r\n", "{}"));
|
||||
assert!(matches!(
|
||||
client(addr, false).me(),
|
||||
Err(MmError::RateLimited(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_statuses_keep_the_start_of_the_body() {
|
||||
let long = format!("{{\"message\":\"{}\"}}", "x".repeat(1000));
|
||||
let (addr, _rx) = serve_http(None, move |_, _| reply(404, "", &long));
|
||||
let Err(MmError::Status(404, body)) = client(addr, false).me() else {
|
||||
panic!("not a 404")
|
||||
};
|
||||
assert_eq!(body.chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_that_are_not_what_we_expect() {
|
||||
for body in [
|
||||
"not json",
|
||||
r#"{"id":"short","username":"x"}"#,
|
||||
r#"{"id":"b0000000000000000000000000","username":""}"#,
|
||||
] {
|
||||
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", body));
|
||||
assert!(
|
||||
matches!(client(addr, false).me(), Err(MmError::Json(_))),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_listening_is_a_network_error() {
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap();
|
||||
assert!(matches!(client(port, false).me(), Err(MmError::Net(_))));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Connections, plain and TLS, against local servers with TEST-ONLY certificates (M4a spec,
|
||||
//! section 5): the right CA is trusted through `ca_file`; an unknown CA and a wrong name are
|
||||
//! refused, so verification is on. Do not edit.
|
||||
|
||||
#[path = "support/tls_server.rs"]
|
||||
mod tls_server;
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use gatewayd::config::ServerUrl;
|
||||
use gatewayd::net::{Connector, NetError, Stream};
|
||||
use tls_server::{echo_line, fixture, serve, server_config};
|
||||
|
||||
const T: Duration = Duration::from_secs(5);
|
||||
|
||||
fn url(tls: bool, host: &str, port: u16) -> ServerUrl {
|
||||
ServerUrl {
|
||||
tls,
|
||||
host: host.to_string(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
fn round_trip(mut stream: Stream) -> String {
|
||||
stream.write_all(b"hello over the wire\n").unwrap();
|
||||
stream.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).unwrap();
|
||||
line
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_tcp() {
|
||||
let addr = serve(None, echo_line);
|
||||
let c = Connector::new(url(false, "127.0.0.1", addr.port()), None).unwrap();
|
||||
let stream = c.connect(T).unwrap();
|
||||
assert!(matches!(stream, Stream::Plain(_)));
|
||||
assert_eq!(round_trip(stream), "hello over the wire\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_to_a_server_signed_by_the_ca_file() {
|
||||
let addr = serve(Some(server_config("server")), echo_line);
|
||||
for host in ["localhost", "127.0.0.1"] {
|
||||
let c =
|
||||
Connector::new(url(true, host, addr.port()), Some(&fixture("test-ca.pem"))).unwrap();
|
||||
let stream = c.connect(T).unwrap_or_else(|e| panic!("{host}: {e}"));
|
||||
assert!(matches!(stream, Stream::Tls(_)));
|
||||
assert_eq!(round_trip(stream), "hello over the wire\n", "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_ca_is_refused_at_connect() {
|
||||
let addr = serve(Some(server_config("other-server")), echo_line);
|
||||
let c = Connector::new(
|
||||
url(true, "localhost", addr.port()),
|
||||
Some(&fixture("test-ca.pem")),
|
||||
)
|
||||
.unwrap();
|
||||
match c.connect(T) {
|
||||
Err(NetError::Tls(why)) => assert!(why.to_lowercase().contains("certificate"), "{why}"),
|
||||
Err(e) => panic!("expected a TLS error, got {e}"),
|
||||
Ok(_) => panic!("a server signed by an unknown CA was accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_name_is_refused_at_connect() {
|
||||
let addr = serve(Some(server_config("wrong-name")), echo_line);
|
||||
let c = Connector::new(
|
||||
url(true, "localhost", addr.port()),
|
||||
Some(&fixture("test-ca.pem")),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(c.connect(T), Err(NetError::Tls(_))),
|
||||
"a certificate for wrong.example was accepted for localhost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_to_a_plain_server_fails_and_does_not_hang() {
|
||||
let addr = serve(None, |_conn| std::thread::sleep(Duration::from_secs(30)));
|
||||
let c = Connector::new(
|
||||
url(true, "localhost", addr.port()),
|
||||
Some(&fixture("test-ca.pem")),
|
||||
)
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
assert!(c.connect(Duration::from_millis(300)).is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_ca_file_is_an_error_before_any_connection() {
|
||||
let missing = fixture("no-such.pem");
|
||||
assert!(matches!(
|
||||
Connector::new(url(true, "localhost", 1), Some(&missing)),
|
||||
Err(NetError::Roots(_))
|
||||
));
|
||||
let not_pem = fixture("README.md");
|
||||
assert!(matches!(
|
||||
Connector::new(url(true, "localhost", 1), Some(¬_pem)),
|
||||
Err(NetError::Roots(_))
|
||||
));
|
||||
// A plain server needs no certificates at all.
|
||||
assert!(Connector::new(url(false, "localhost", 1), Some(&missing)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_listening_is_a_connect_error() {
|
||||
let port = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let c = Connector::new(url(false, "127.0.0.1", port), None).unwrap();
|
||||
assert!(matches!(c.connect(T), Err(NetError::Connect(_))));
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
|
||||
//! changes the process's environment. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::SecretSource;
|
||||
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "s3cret-t0ken-value";
|
||||
|
||||
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let map: HashMap<String, OsString> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), OsString::from(v)))
|
||||
.collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
|
||||
let path = dir.write(name, text);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
|
||||
let e = load("mattermost_token", source, env).expect_err(word);
|
||||
let text = e.to_string();
|
||||
assert!(text.contains(word), "{word}: {text}");
|
||||
assert!(text.starts_with("secret mattermost_token: "), "{text}");
|
||||
assert!(text.ends_with(RUNBOOK), "{text}");
|
||||
assert!(
|
||||
!text.contains(TOKEN),
|
||||
"a refusal never shows the value: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_systemd_credential() {
|
||||
let dir = TempDir::new("cred");
|
||||
dir.write("creds/mattermost-token", &format!("{TOKEN}\n"));
|
||||
let creds = dir.path().join("creds");
|
||||
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Credential("mattermost-token".into()),
|
||||
&env,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
got.secret.expose(),
|
||||
TOKEN,
|
||||
"one trailing newline is removed"
|
||||
);
|
||||
assert_eq!(got.warning, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_outside_systemd_or_missing_is_refused() {
|
||||
let src = SecretSource::Credential("mattermost-token".into());
|
||||
refused(&src, &env_of(&[]), "CREDENTIALS_DIRECTORY is not set");
|
||||
let dir = TempDir::new("cred-missing");
|
||||
refused(
|
||||
&src,
|
||||
&env_of(&[("CREDENTIALS_DIRECTORY", dir.path().to_str().unwrap())]),
|
||||
"cannot read the credential",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_variable() {
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got.secret.expose(), TOKEN);
|
||||
assert_eq!(got.warning, None, "only a file warns");
|
||||
refused(&SecretSource::Env("MM".into()), &env_of(&[]), "is not set");
|
||||
refused(
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", "")]),
|
||||
"empty",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_only_file_is_read_with_a_warning() {
|
||||
let dir = TempDir::new("file");
|
||||
for mode in [0o600, 0o400] {
|
||||
let path = owner_file(
|
||||
&dir,
|
||||
&format!("token-{mode:o}"),
|
||||
&format!("{TOKEN}\n"),
|
||||
mode,
|
||||
);
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::File(path.clone()),
|
||||
&env_of(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got.secret.expose(), TOKEN);
|
||||
let warning = got.warning.expect("a file secret warns");
|
||||
assert!(
|
||||
warning.starts_with(
|
||||
"gatewayd: warning: secret mattermost_token is read in plaintext from "
|
||||
),
|
||||
"{warning}"
|
||||
);
|
||||
assert!(warning.contains(path.to_str().unwrap()), "{warning}");
|
||||
assert!(warning.contains(RUNBOOK_FILE), "{warning}");
|
||||
assert!(!warning.contains(TOKEN));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
|
||||
let dir = TempDir::new("file-bad");
|
||||
for (mode, _) in [
|
||||
(0o640, "group"),
|
||||
(0o604, "other"),
|
||||
(0o644, "both"),
|
||||
(0o660, "group write"),
|
||||
] {
|
||||
let path = owner_file(&dir, &format!("t-{mode:o}"), TOKEN, mode);
|
||||
refused(
|
||||
&SecretSource::File(path),
|
||||
&env_of(&[]),
|
||||
"only the owner may read it",
|
||||
);
|
||||
}
|
||||
let target = owner_file(&dir, "real", TOKEN, 0o600);
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
refused(&SecretSource::File(link), &env_of(&[]), "symbolic link");
|
||||
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||
refused(
|
||||
&SecretSource::File(dir.path().join("adir")),
|
||||
&env_of(&[]),
|
||||
"not a regular file",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(dir.path().join("missing")),
|
||||
&env_of(&[]),
|
||||
"cannot read",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(PathBuf::from("relative/token")),
|
||||
&env_of(&[]),
|
||||
"absolute",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_non_utf8_values_are_refused() {
|
||||
let dir = TempDir::new("value");
|
||||
refused(
|
||||
&SecretSource::File(owner_file(&dir, "empty", "", 0o600)),
|
||||
&env_of(&[]),
|
||||
"empty",
|
||||
);
|
||||
refused(
|
||||
&SecretSource::File(owner_file(&dir, "nl", "\n", 0o600)),
|
||||
&env_of(&[]),
|
||||
"empty",
|
||||
);
|
||||
let path = dir.path().join("bin");
|
||||
std::fs::write(&path, [0xff, 0xfe]).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
refused(&SecretSource::File(path), &env_of(&[]), "not UTF-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
|
||||
let dir = TempDir::new("trim");
|
||||
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
|
||||
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
|
||||
assert_eq!(got.secret.expose(), " a b \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_prints_nothing_of_itself() {
|
||||
let got = load(
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
)
|
||||
.unwrap();
|
||||
let shown = format!("{:?} {:?}", got.secret, got);
|
||||
assert!(!shown.contains(TOKEN), "{shown}");
|
||||
assert!(shown.contains("Secret(…)"), "{shown}");
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
|
||||
//! and how (M4a spec, sections 7 and 8). Do not edit.
|
||||
|
||||
#[path = "support/fake_loop.rs"]
|
||||
mod fake_loop;
|
||||
#[path = "support/fake_mm.rs"]
|
||||
mod fake_mm;
|
||||
#[path = "support/gateway.rs"]
|
||||
mod gateway;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use fake_loop::{done, serve_loop};
|
||||
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
|
||||
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
|
||||
use gatewayd::serve::Stop;
|
||||
use gatewayd::sessions::{BUSY, M4B_COMMAND};
|
||||
use tmp::TempDir;
|
||||
|
||||
#[test]
|
||||
fn a_direct_message_is_answered_in_its_thread_while_typing() {
|
||||
let home = TempDir::new("serve-dm");
|
||||
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
|
||||
let turn = turns.recv_timeout(WAIT).unwrap();
|
||||
assert_eq!(
|
||||
(turn.session.as_str(), turn.content.as_str(), turn.resume),
|
||||
(format!("mm-{p1}").as_str(), "hello there", false)
|
||||
);
|
||||
let typing = ws.typing_within(Duration::from_millis(500));
|
||||
assert!(typing.len() >= 2, "{typing:?}");
|
||||
assert!(
|
||||
typing.iter().all(|(c, p)| c == DM && *p == p1),
|
||||
"{typing:?}"
|
||||
);
|
||||
let posts = mm.wait_posts(1, WAIT);
|
||||
assert_eq!(
|
||||
posts,
|
||||
[(
|
||||
DM.to_string(),
|
||||
p1.clone(),
|
||||
"answer to hello there".to_string()
|
||||
)]
|
||||
);
|
||||
// Typing sent just before the answer may still be on its way; after that, it stops.
|
||||
ws.typing_within(Duration::from_millis(300));
|
||||
assert!(
|
||||
ws.typing_within(Duration::from_millis(400)).is_empty(),
|
||||
"typing stops after the answer"
|
||||
);
|
||||
let log = running.log();
|
||||
assert!(
|
||||
log.iter()
|
||||
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
|
||||
"{log:?}"
|
||||
);
|
||||
assert!(matches!(running.finish(), Stop::Asked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyone_else_gets_nothing_at_all() {
|
||||
let home = TempDir::new("serve-stranger");
|
||||
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
|
||||
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
|
||||
own["user_id"] = serde_json::json!(BOT);
|
||||
ws.posted(&own, "D");
|
||||
let log = running.wait_log("not allowed");
|
||||
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
|
||||
assert!(mm.posts().is_empty());
|
||||
assert!(
|
||||
log.contains(&format!(
|
||||
"gatewayd: ignored post {p1} from {EVE}: not allowed"
|
||||
)),
|
||||
"{log:?}"
|
||||
);
|
||||
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_during_a_turn_go_together_in_the_next() {
|
||||
let home = TempDir::new("serve-burst");
|
||||
loop_dir(&home);
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let release = Mutex::new(release_rx);
|
||||
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
|
||||
if n == 0 {
|
||||
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||
}
|
||||
vec![done(&format!("answer to {}", turn.content))]
|
||||
});
|
||||
let mm = FakeMm::start();
|
||||
let _running = start(config(&home, &mm.url(), ""));
|
||||
let mut ws = mm.next_ws(WAIT);
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
|
||||
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
|
||||
let saved = read_state(&home);
|
||||
assert_eq!(
|
||||
saved["in_flight"],
|
||||
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
|
||||
);
|
||||
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
|
||||
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
release_tx.send(()).unwrap();
|
||||
let second = turns.recv_timeout(WAIT).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
second.session.as_str(),
|
||||
second.content.as_str(),
|
||||
second.resume
|
||||
),
|
||||
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
|
||||
);
|
||||
let posts = mm.wait_posts(2, WAIT);
|
||||
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
|
||||
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_queue_says_busy() {
|
||||
let home = TempDir::new("serve-busy");
|
||||
loop_dir(&home);
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let release = Mutex::new(release_rx);
|
||||
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||
vec![done("ok")]
|
||||
});
|
||||
let mm = FakeMm::start();
|
||||
let _running = start(config(&home, &mm.url(), "queue = 1"));
|
||||
let mut ws = mm.next_ws(WAIT);
|
||||
let p1 = id('p', 1);
|
||||
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
|
||||
let n = u32::try_from(n).unwrap();
|
||||
let root = if n == 0 { String::new() } else { p1.clone() };
|
||||
ws.posted(
|
||||
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
|
||||
"D",
|
||||
);
|
||||
}
|
||||
let posts = mm.wait_posts(1, WAIT);
|
||||
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
|
||||
release_tx.send(()).unwrap();
|
||||
release_tx.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_are_answered_only_when_named_or_in_our_thread() {
|
||||
let home = TempDir::new("serve-channel");
|
||||
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(
|
||||
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
|
||||
"O",
|
||||
);
|
||||
ws.posted(
|
||||
&post(
|
||||
&id('p', 8),
|
||||
KYLE,
|
||||
OTHER,
|
||||
"",
|
||||
"@boxmaker-straylight elsewhere",
|
||||
4,
|
||||
),
|
||||
"O",
|
||||
);
|
||||
ws.posted(
|
||||
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
|
||||
"O",
|
||||
);
|
||||
assert_eq!(
|
||||
turns.recv_timeout(WAIT).unwrap().content,
|
||||
"@boxmaker-straylight start"
|
||||
);
|
||||
mm.wait_posts(1, WAIT);
|
||||
ws.posted(
|
||||
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
|
||||
"O",
|
||||
);
|
||||
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
|
||||
let next = turns.recv_timeout(WAIT).unwrap();
|
||||
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
|
||||
let posts = mm.wait_posts(2, WAIT);
|
||||
assert!(
|
||||
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
|
||||
"{posts:?}"
|
||||
);
|
||||
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||
let saved = read_state(&home);
|
||||
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
|
||||
assert_eq!(saved["threads"], serde_json::json!([p1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_answered_without_a_turn() {
|
||||
let home = TempDir::new("serve-command");
|
||||
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
|
||||
assert_eq!(
|
||||
mm.wait_posts(1, WAIT),
|
||||
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
|
||||
);
|
||||
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `gatewayd` end to end across gaps: a restart, a lost connection, an unreachable server, a
|
||||
//! refused token and a damaged state file (M4a spec, section 9). Do not edit.
|
||||
|
||||
#[path = "support/fake_loop.rs"]
|
||||
mod fake_loop;
|
||||
#[path = "support/fake_mm.rs"]
|
||||
mod fake_mm;
|
||||
#[path = "support/gateway.rs"]
|
||||
mod gateway;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use fake_loop::{done, serve_loop};
|
||||
use fake_mm::{DM, FakeMm, KYLE, id, post};
|
||||
use gateway::{SHARED, WAIT, answering, config, loop_dir, start, up};
|
||||
use gatewayd::serve::{INTERRUPTED, Stop};
|
||||
use tmp::TempDir;
|
||||
|
||||
#[test]
|
||||
fn a_restart_reports_the_cut_off_turn_and_catches_up() {
|
||||
let home = TempDir::new("serve-restart");
|
||||
let (cut, seen, new1, new2) = (id('r', 1), id('p', 2), id('p', 3), id('p', 4));
|
||||
let state = serde_json::json!({
|
||||
"channels": {DM: 1000}, "recent": [seen], "threads": [],
|
||||
"in_flight": [{"session": format!("mm-{cut}"), "channel": DM, "root": cut}]
|
||||
});
|
||||
home.write("gateway/state.json", &state.to_string());
|
||||
loop_dir(&home);
|
||||
let turns = answering(&home, Duration::ZERO);
|
||||
let mm = FakeMm::start();
|
||||
mm.set_since(
|
||||
DM,
|
||||
&[
|
||||
post(&new2, KYLE, DM, "", "second", 2000),
|
||||
post(&seen, KYLE, DM, "", "already answered", 1500),
|
||||
post(&new1, KYLE, DM, "", "first", 1800),
|
||||
],
|
||||
);
|
||||
let _running = start(config(&home, &mm.url(), ""));
|
||||
let _ws = mm.next_ws(WAIT);
|
||||
let first = turns.recv_timeout(WAIT).unwrap();
|
||||
let second = turns.recv_timeout(WAIT).unwrap();
|
||||
assert_eq!(
|
||||
(first.content.as_str(), second.content.as_str()),
|
||||
("first", "second")
|
||||
);
|
||||
let posts = mm.wait_posts(3, WAIT);
|
||||
assert_eq!(posts[0], (DM.to_string(), cut, INTERRUPTED.to_string()));
|
||||
assert_eq!(posts.len(), 3, "{posts:?}");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let saved: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved["in_flight"], serde_json::json!([]));
|
||||
assert_eq!(saved["channels"][DM], 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_start_answers_no_history() {
|
||||
let home = TempDir::new("serve-first");
|
||||
loop_dir(&home);
|
||||
let turns = answering(&home, Duration::ZERO);
|
||||
let mm = FakeMm::start();
|
||||
mm.set_since(DM, &[post(&id('p', 1), KYLE, DM, "", "old", 5)]);
|
||||
let running = start(config(&home, &mm.url(), ""));
|
||||
let _ws = mm.next_ws(WAIT);
|
||||
running.wait_log("connected to");
|
||||
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
assert!(
|
||||
!mm.calls().iter().any(|(_, p)| p.contains("since=")),
|
||||
"{:?}",
|
||||
mm.calls()
|
||||
);
|
||||
let saved: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
saved["channels"][DM].as_i64().unwrap() > 1_700_000_000_000,
|
||||
"marked from now"
|
||||
);
|
||||
assert!(saved["channels"][SHARED].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_connection_is_made_again() {
|
||||
let home = TempDir::new("serve-reconnect");
|
||||
let (mm, running, turns, ws) = up(&home, Duration::ZERO);
|
||||
ws.drop_connection();
|
||||
let mut again = mm.next_ws(WAIT);
|
||||
let log = running.wait_log("lost the connection");
|
||||
assert!(
|
||||
log.iter()
|
||||
.any(|l| l.ends_with("see docs/runbook.md#mattermost-unreachable")),
|
||||
"{log:?}"
|
||||
);
|
||||
let p1 = id('p', 1);
|
||||
again.posted(&post(&p1, KYLE, DM, "", "still there?", 5), "D");
|
||||
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "still there?");
|
||||
assert_eq!(mm.wait_posts(1, WAIT).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_token_stops_gatewayd() {
|
||||
let home = TempDir::new("serve-auth");
|
||||
let mm = FakeMm::start();
|
||||
mm.refuse_token();
|
||||
let running = start(config(&home, &mm.url(), ""));
|
||||
let stop = running.join_within();
|
||||
assert!(matches!(stop, Stop::Auth(401)), "{stop}");
|
||||
assert_eq!(
|
||||
stop.to_string(),
|
||||
"gatewayd: Mattermost refused the token (401)\nsee docs/runbook.md#mattermost-auth-failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_server_is_tried_again() {
|
||||
let home = TempDir::new("serve-unreachable");
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
let running = start(config(&home, &url, ""));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let log = running.log();
|
||||
let tries: Vec<&String> = log
|
||||
.iter()
|
||||
.filter(|l| l.starts_with(&format!("gatewayd: cannot reach {url}: ")))
|
||||
.collect();
|
||||
assert!(tries.len() >= 2, "{log:?}");
|
||||
assert!(
|
||||
tries
|
||||
.iter()
|
||||
.all(|l| l
|
||||
.ends_with("; trying again in 0 s\nsee docs/runbook.md#mattermost-unreachable")),
|
||||
"{tries:?}"
|
||||
);
|
||||
assert!(matches!(running.finish(), Stop::Asked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_state_file_stops_at_once() {
|
||||
let home = TempDir::new("serve-damaged");
|
||||
home.write("gateway/state.json", "{not json");
|
||||
let mm = FakeMm::start();
|
||||
let running = start(config(&home, &mm.url(), ""));
|
||||
let stop = running.join_within();
|
||||
assert!(matches!(stop, Stop::State(_)), "{stop}");
|
||||
assert!(
|
||||
stop.to_string()
|
||||
.ends_with("see docs/runbook.md#gateway-state-damaged"),
|
||||
"{stop}"
|
||||
);
|
||||
assert!(mm.calls().is_empty(), "nothing is asked of Mattermost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconnect_does_not_interrupt_a_running_turn() {
|
||||
let home = TempDir::new("serve-reconnect-turn");
|
||||
loop_dir(&home);
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let release = Mutex::new(release_rx);
|
||||
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||
vec![done("the answer")]
|
||||
});
|
||||
let mm = FakeMm::start();
|
||||
let running = start(config(&home, &mm.url(), ""));
|
||||
let mut ws = mm.next_ws(WAIT);
|
||||
let p1 = id('p', 1);
|
||||
ws.posted(&post(&p1, KYLE, DM, "", "a long one", 5), "D");
|
||||
turns.recv_timeout(WAIT).unwrap();
|
||||
ws.drop_connection();
|
||||
let _again = mm.next_ws(WAIT);
|
||||
running.wait_log("lost the connection");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
release_tx.send(()).unwrap();
|
||||
let posts = mm.wait_posts(1, WAIT);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert_eq!(
|
||||
mm.posts(),
|
||||
[(DM.to_string(), p1, "the answer".to_string())],
|
||||
"{posts:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
|
||||
//! section 7, including its table of examples). Do not edit.
|
||||
|
||||
use gatewayd::mm::Post;
|
||||
use gatewayd::sessions::{
|
||||
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
|
||||
UNKNOWN_COMMAND, named,
|
||||
};
|
||||
use proto::SessionId;
|
||||
|
||||
const BOT: &str = "b0000000000000000000000000";
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const EVE: &str = "e0000000000000000000000000";
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
const SHARED: &str = "c0000000000000000000000000";
|
||||
const OTHER: &str = "o0000000000000000000000000";
|
||||
const ROOT: &str = "r0000000000000000000000000";
|
||||
const POST: &str = "p0000000000000000000000000";
|
||||
|
||||
fn router() -> Router {
|
||||
Router::new(
|
||||
BOT,
|
||||
"boxmaker-straylight",
|
||||
&[KYLE.to_string()],
|
||||
&[SHARED.to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
fn post(channel: &str, root: &str, message: &str) -> Post {
|
||||
Post {
|
||||
id: POST.to_string(),
|
||||
user_id: KYLE.to_string(),
|
||||
channel_id: channel.to_string(),
|
||||
root_id: root.to_string(),
|
||||
message: message.to_string(),
|
||||
create_at: 5,
|
||||
delete_at: 0,
|
||||
kind: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn known(root: &str) -> bool {
|
||||
root == ROOT
|
||||
}
|
||||
|
||||
fn unknown(_: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn queued(route: Route) -> Message {
|
||||
match route {
|
||||
Route::Queue(m) => m,
|
||||
other => panic!("not queued: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_examples_in_the_spec() {
|
||||
let r = router();
|
||||
let cases = [
|
||||
("", "@boxmaker-straylight summarise the audit log", true),
|
||||
(ROOT, "and the older files?", true),
|
||||
(ROOT, "@hermes what do you think?", false),
|
||||
("", "@boxmaker-straylight @hermes compare notes", true),
|
||||
("", "@boxmaker-straylightx hello", false),
|
||||
("", "@channel standup in five", false),
|
||||
];
|
||||
for (root, message, yes) in cases {
|
||||
let got = r.route(&post(SHARED, root, message), "O", &known);
|
||||
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
|
||||
if !yes {
|
||||
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming() {
|
||||
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
|
||||
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
|
||||
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
|
||||
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
|
||||
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
|
||||
assert!(named("no names here").is_empty());
|
||||
let r = router();
|
||||
for message in [
|
||||
"hi @BOXMAKER-STRAYLIGHT",
|
||||
"@boxmaker-straylight.",
|
||||
"(@boxmaker-straylight)",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
r.route(&post(SHARED, "", message), "P", &unknown),
|
||||
Route::Queue(_)
|
||||
),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
|
||||
let r = router();
|
||||
for message in ["@here any news?", "@all done", "thanks @channel"] {
|
||||
assert!(
|
||||
matches!(
|
||||
r.route(&post(SHARED, ROOT, message), "O", &known),
|
||||
Route::Queue(_)
|
||||
),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
let got = r.route(
|
||||
&post(
|
||||
SHARED,
|
||||
"q0000000000000000000000000",
|
||||
"a reply in someone else's thread",
|
||||
),
|
||||
"O",
|
||||
&known,
|
||||
);
|
||||
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_and_where() {
|
||||
let r = router();
|
||||
let mut own = post(DM, "", "hello");
|
||||
own.user_id = BOT.to_string();
|
||||
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
|
||||
let mut system = post(DM, "", "joined");
|
||||
system.kind = "system_join_channel".to_string();
|
||||
assert_eq!(
|
||||
r.route(&system, "D", &unknown),
|
||||
Route::Ignore(Ignored::System)
|
||||
);
|
||||
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
|
||||
stranger.user_id = EVE.to_string();
|
||||
assert_eq!(
|
||||
r.route(&stranger, "D", &unknown),
|
||||
Route::Ignore(Ignored::NotAllowed)
|
||||
);
|
||||
let mut stranger_cmd = post(DM, "", "!approve 1");
|
||||
stranger_cmd.user_id = EVE.to_string();
|
||||
assert_eq!(
|
||||
r.route(&stranger_cmd, "D", &unknown),
|
||||
Route::Ignore(Ignored::NotAllowed)
|
||||
);
|
||||
let naming = "@boxmaker-straylight hello";
|
||||
assert_eq!(
|
||||
r.route(&post(OTHER, "", naming), "O", &unknown),
|
||||
Route::Ignore(Ignored::NotForUs)
|
||||
);
|
||||
assert_eq!(
|
||||
r.route(&post(SHARED, "", naming), "X", &unknown),
|
||||
Route::Ignore(Ignored::NotForUs)
|
||||
);
|
||||
assert!(matches!(
|
||||
r.route(&post(SHARED, "", naming), "G", &unknown),
|
||||
Route::Queue(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
r.route(&post(DM, "", "no name needed"), "D", &unknown),
|
||||
Route::Queue(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_and_threads() {
|
||||
let r = router();
|
||||
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
|
||||
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
|
||||
assert_eq!(
|
||||
top.thread,
|
||||
Thread {
|
||||
channel: DM.to_string(),
|
||||
root: POST.to_string()
|
||||
}
|
||||
);
|
||||
assert!(!top.resume && !top.joins_thread);
|
||||
assert_eq!(top.text, "hello");
|
||||
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
|
||||
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
|
||||
assert_eq!(reply.thread.root, ROOT);
|
||||
assert!(reply.resume);
|
||||
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
||||
assert!(channel.joins_thread && !channel.resume);
|
||||
assert_eq!(channel.text, "@boxmaker-straylight hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands() {
|
||||
let r = router();
|
||||
let thread = Thread {
|
||||
channel: DM.to_string(),
|
||||
root: ROOT.to_string(),
|
||||
};
|
||||
let reply = |text: &str| Route::Reply {
|
||||
thread: thread.clone(),
|
||||
text: text.to_string(),
|
||||
};
|
||||
for (message, answer) in [
|
||||
("!approve 42", M4B_COMMAND),
|
||||
("!deny 42 not now", M4B_COMMAND),
|
||||
("!deny", M4B_COMMAND),
|
||||
("!approved", UNKNOWN_COMMAND),
|
||||
("!help", UNKNOWN_COMMAND),
|
||||
("!", UNKNOWN_COMMAND),
|
||||
] {
|
||||
assert_eq!(
|
||||
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||
reply(answer),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
|
||||
"!approve is a word"
|
||||
);
|
||||
assert_eq!(
|
||||
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
|
||||
"!"
|
||||
);
|
||||
assert_eq!(
|
||||
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
|
||||
" !help"
|
||||
);
|
||||
}
|
||||
|
||||
fn message(root: &str, resume: bool, text: &str) -> Message {
|
||||
Message {
|
||||
session: SessionId::new(&format!("mm-{root}")).unwrap(),
|
||||
thread: Thread {
|
||||
channel: DM.to_string(),
|
||||
root: root.to_string(),
|
||||
},
|
||||
resume,
|
||||
text: text.to_string(),
|
||||
joins_thread: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_wait_for_the_running_turn_and_go_together() {
|
||||
let mut q = Queues::new(3);
|
||||
let first = message(ROOT, false, "one");
|
||||
let Pushed::Start(batch) = q.push(first.clone()) else {
|
||||
panic!("not started")
|
||||
};
|
||||
assert_eq!(
|
||||
batch,
|
||||
Batch {
|
||||
session: first.session.clone(),
|
||||
thread: first.thread.clone(),
|
||||
resume: false,
|
||||
text: "one".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
|
||||
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
|
||||
let other = message(POST, false, "elsewhere");
|
||||
assert!(
|
||||
matches!(q.push(other.clone()), Pushed::Start(_)),
|
||||
"another session starts at once"
|
||||
);
|
||||
assert_eq!(q.running(), 2);
|
||||
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
|
||||
roots.sort();
|
||||
assert_eq!(roots, [POST, ROOT]);
|
||||
let next = q.finish(&first.session).unwrap();
|
||||
assert_eq!(
|
||||
(next.text.as_str(), next.resume),
|
||||
("two\n\nthree\nlines", true)
|
||||
);
|
||||
assert_eq!(q.finish(&first.session), None);
|
||||
assert_eq!(q.finish(&other.session), None);
|
||||
assert_eq!(q.running(), 0);
|
||||
assert!(
|
||||
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
|
||||
"idle again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_queue_drops_the_message() {
|
||||
let mut q = Queues::new(2);
|
||||
let m = message(ROOT, false, "run");
|
||||
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
|
||||
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
|
||||
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
|
||||
assert_eq!(
|
||||
q.push(message(ROOT, true, "c")),
|
||||
Pushed::Full(m.thread.clone())
|
||||
);
|
||||
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
|
||||
assert!(!BUSY.is_empty());
|
||||
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The state file: a first start, surviving a restart, its limits, and refusing a damaged file
|
||||
//! instead of guessing (M4a spec, section 9). Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use gatewayd::state::{InFlight, RECENT_KEPT, State, StateError, THREADS_KEPT};
|
||||
use tmp::TempDir;
|
||||
|
||||
const CHAN: &str = "c0000000000000000000000000";
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
|
||||
fn id(n: usize) -> String {
|
||||
format!("p{n:025}")
|
||||
}
|
||||
|
||||
fn turn(n: usize) -> InFlight {
|
||||
InFlight {
|
||||
session: format!("mm-{}", id(n)),
|
||||
channel: DM.to_string(),
|
||||
root: id(n),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_start_then_a_restart() {
|
||||
let dir = TempDir::new("state-restart");
|
||||
let path = dir.path().join("gateway/state.json");
|
||||
let mut s = State::load(&path).unwrap();
|
||||
assert!(!path.exists(), "loading writes nothing");
|
||||
assert_eq!(
|
||||
(s.since(CHAN), s.channels().len(), s.seen(&id(1))),
|
||||
(None, 0, false)
|
||||
);
|
||||
s.handled(&id(1), CHAN, 2000).unwrap();
|
||||
s.handled(&id(2), CHAN, 1500).unwrap();
|
||||
s.mark(DM, 3000).unwrap();
|
||||
s.mark(DM, 9000).unwrap();
|
||||
s.join_thread(&id(1)).unwrap();
|
||||
s.start_turn(turn(1)).unwrap();
|
||||
s.start_turn(turn(2)).unwrap();
|
||||
s.end_turn(&format!("mm-{}", id(2))).unwrap();
|
||||
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
let dir_mode = std::fs::metadata(path.parent().unwrap())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!((mode, dir_mode), (0o600, 0o700));
|
||||
assert!(!dir.path().join("gateway/state.json.tmp").exists());
|
||||
|
||||
let mut again = State::load(&path).unwrap();
|
||||
assert_eq!(again.since(CHAN), Some(2000), "the mark never moves back");
|
||||
assert_eq!(
|
||||
again.since(DM),
|
||||
Some(3000),
|
||||
"mark only sets a channel without one"
|
||||
);
|
||||
assert_eq!(again.channels(), [CHAN, DM]);
|
||||
assert!(again.seen(&id(1)) && again.seen(&id(2)));
|
||||
assert!(again.knows_thread(&id(1)) && !again.knows_thread(&id(2)));
|
||||
assert_eq!(again.take_in_flight().unwrap(), [turn(1)]);
|
||||
assert!(
|
||||
State::load(&path)
|
||||
.unwrap()
|
||||
.take_in_flight()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"taking is saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_newest_posts_and_threads_are_kept() {
|
||||
let dir = TempDir::new("state-limits");
|
||||
// A full file to start from: posts 0.. and threads 0.., at their limits.
|
||||
let recent: Vec<String> = (0..RECENT_KEPT).map(id).collect();
|
||||
let threads: Vec<String> = (0..THREADS_KEPT).map(id).collect();
|
||||
let full =
|
||||
serde_json::json!({"channels": {}, "recent": recent, "threads": threads, "in_flight": []});
|
||||
let path = dir.write("state.json", &full.to_string());
|
||||
let mut s = State::load(&path).unwrap();
|
||||
s.handled(&id(RECENT_KEPT), CHAN, 1).unwrap();
|
||||
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
|
||||
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
|
||||
s.join_thread(&id(THREADS_KEPT)).unwrap();
|
||||
s.join_thread(&id(3)).unwrap();
|
||||
let s = State::load(&path).unwrap();
|
||||
assert!(!s.seen(&id(0)) && !s.seen(&id(1)) && s.seen(&id(2)) && s.seen(&id(RECENT_KEPT + 1)));
|
||||
assert!(!s.knows_thread(&id(0)) && s.knows_thread(&id(1)) && s.knows_thread(&id(THREADS_KEPT)));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(
|
||||
v["recent"].as_array().unwrap().len(),
|
||||
RECENT_KEPT,
|
||||
"a repeat is not stored twice"
|
||||
);
|
||||
assert_eq!(v["threads"].as_array().unwrap().len(), THREADS_KEPT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_stops_with_its_pointer() {
|
||||
let dir = TempDir::new("state-damaged");
|
||||
let good_turn = r#"{"session":"mm-p0000000000000000000000001","channel":"d0000000000000000000000000","root":"p0000000000000000000000001"}"#;
|
||||
let cases = [
|
||||
"".to_string(),
|
||||
"{".to_string(),
|
||||
"[]".to_string(),
|
||||
r#"{"channels":{},"recent":[],"threads":[]}"#.to_string(),
|
||||
r#"{"channels":{},"recent":[],"threads":[],"in_flight":[],"extra":1}"#.to_string(),
|
||||
r#"{"channels":{"../x":1},"recent":[],"threads":[],"in_flight":[]}"#.to_string(),
|
||||
r#"{"channels":{},"recent":["short"],"threads":[],"in_flight":[]}"#.to_string(),
|
||||
r#"{"channels":{},"recent":[],"threads":["P0000000000000000000000000"],"in_flight":[]}"#
|
||||
.to_string(),
|
||||
format!(
|
||||
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
|
||||
good_turn.replace("mm-", "xx-")
|
||||
),
|
||||
format!(
|
||||
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
|
||||
good_turn.replace("\"d0", "\"D0")
|
||||
),
|
||||
];
|
||||
for (n, text) in cases.iter().enumerate() {
|
||||
let path = dir.write(&format!("s{n}.json"), text);
|
||||
match State::load(&path) {
|
||||
Err(e @ StateError::Read(..)) => {
|
||||
let message = e.to_string();
|
||||
assert!(
|
||||
message.starts_with(&path.display().to_string()),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
message.ends_with("\nsee docs/runbook.md#gateway-state-damaged"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("{text}: {e}"),
|
||||
Ok(_) => panic!("accepted: {text}"),
|
||||
}
|
||||
}
|
||||
let ok = dir.write(
|
||||
"ok.json",
|
||||
&format!(
|
||||
r#"{{"channels":{{"{CHAN}":5}},"recent":[],"threads":[],"in_flight":[{good_turn}]}}"#
|
||||
),
|
||||
);
|
||||
assert_eq!(State::load(&ok).unwrap().since(CHAN), Some(5));
|
||||
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||
assert!(matches!(
|
||||
State::load(&dir.path().join("adir")),
|
||||
Err(StateError::Read(..))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_is_an_error_and_keeps_the_old_file() {
|
||||
let dir = TempDir::new("state-readonly");
|
||||
let sub = dir.path().join("gateway");
|
||||
let path = sub.join("state.json");
|
||||
let mut s = State::load(&path).unwrap();
|
||||
s.handled(&id(1), CHAN, 5).unwrap();
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let got = s.handled(&id(2), CHAN, 6);
|
||||
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let e = got.unwrap_err();
|
||||
assert!(matches!(e, StateError::Write(..)), "{e}");
|
||||
assert!(
|
||||
e.to_string()
|
||||
.ends_with("see docs/runbook.md#gateway-state-damaged"),
|
||||
"{e}"
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! A fake `loopd` on a Unix socket: it records each turn and answers with the frames the test's
|
||||
//! function gives for it. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnDone, TurnEvent, Usage, WireError,
|
||||
read_frame, write_frame,
|
||||
};
|
||||
|
||||
const USAGE: Usage = Usage {
|
||||
cache_n: 0,
|
||||
prompt_n: 1,
|
||||
predicted_n: 1,
|
||||
reasoning_tokens: 0,
|
||||
thinking_capped: false,
|
||||
};
|
||||
|
||||
/// What the fake sends back for a turn.
|
||||
pub enum Reply {
|
||||
Frame(Envelope),
|
||||
/// Raw bytes, for broken frames.
|
||||
Bytes(Vec<u8>),
|
||||
/// Stop answering this connection (the caller sees it close).
|
||||
Close,
|
||||
}
|
||||
|
||||
pub fn event(e: TurnEvent) -> Reply {
|
||||
Reply::Frame(Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: false,
|
||||
msg: Message::TurnEvent(e),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn done(content: &str) -> Reply {
|
||||
let msg = Message::TurnDone(TurnDone {
|
||||
content: content.to_string(),
|
||||
usage: USAGE,
|
||||
});
|
||||
Reply::Frame(Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error(code: ErrorCode, detail: &str) -> Reply {
|
||||
let msg = Message::Error(WireError {
|
||||
code,
|
||||
detail: detail.to_string(),
|
||||
});
|
||||
Reply::Frame(Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg,
|
||||
})
|
||||
}
|
||||
|
||||
/// Listen on `socket`; `script(n, &turn)` gives the replies to the n-th turn (from 0).
|
||||
pub fn serve_loop<F>(socket: &Path, script: F) -> mpsc::Receiver<Turn>
|
||||
where
|
||||
F: Fn(usize, &Turn) -> Vec<Reply> + Send + 'static,
|
||||
{
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for (n, stream) in listener.incoming().enumerate() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let Ok(Envelope {
|
||||
msg: Message::Turn(turn),
|
||||
..
|
||||
}) = read_frame(&mut stream)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Reported before the script runs: a script may wait for the test.
|
||||
let _ = tx.send(turn.clone());
|
||||
let replies = script(n, &turn);
|
||||
for reply in replies {
|
||||
let ok = match reply {
|
||||
Reply::Frame(env) => write_frame(&mut stream, &env).is_ok(),
|
||||
Reply::Bytes(b) => stream.write_all(&b).is_ok(),
|
||||
Reply::Close => false,
|
||||
};
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
|
||||
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gatewayd::ws::handshake::accept_for;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const BOT: &str = "b0000000000000000000000000";
|
||||
pub const BOT_NAME: &str = "boxmaker-straylight";
|
||||
pub const KYLE: &str = "k0000000000000000000000000";
|
||||
pub const EVE: &str = "e0000000000000000000000000";
|
||||
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
|
||||
pub const DM: &str = "d0000000000000000000000000";
|
||||
pub const EVE_DM: &str = "f0000000000000000000000000";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// Status for `users/me`: 200 unless a test sets another.
|
||||
me_status: u16,
|
||||
/// The body for `channels/<id>/posts?since=`, by channel.
|
||||
since: HashMap<String, Value>,
|
||||
/// Every post made: channel, root, message.
|
||||
posts: Vec<(String, String, String)>,
|
||||
/// Every REST call: method and path.
|
||||
calls: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct FakeMm {
|
||||
pub addr: SocketAddr,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
sockets: Mutex<mpsc::Receiver<WsPeer>>,
|
||||
}
|
||||
|
||||
/// One WebSocket connection from `gatewayd`.
|
||||
pub struct WsPeer {
|
||||
writer: TcpStream,
|
||||
/// The text of every text frame `gatewayd` sends.
|
||||
pub texts: mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
/// A post as Mattermost sends it.
|
||||
pub fn post(
|
||||
id: &str,
|
||||
user: &str,
|
||||
channel: &str,
|
||||
root: &str,
|
||||
message: &str,
|
||||
create_at: i64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
|
||||
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id(prefix: char, n: u32) -> String {
|
||||
format!("{prefix}{n:025}")
|
||||
}
|
||||
|
||||
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x80 | opcode];
|
||||
match payload.len() {
|
||||
n if n < 126 => out.push(n as u8),
|
||||
n => {
|
||||
out.push(126);
|
||||
out.extend_from_slice(&(n as u16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
impl WsPeer {
|
||||
pub fn event(&mut self, value: &Value) {
|
||||
let _ = self
|
||||
.writer
|
||||
.write_all(&frame(0x1, value.to_string().as_bytes()));
|
||||
}
|
||||
|
||||
pub fn posted(&mut self, post: &Value, channel_type: &str) {
|
||||
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
|
||||
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
|
||||
}
|
||||
|
||||
/// End the connection without a close frame.
|
||||
pub fn drop_connection(self) {
|
||||
let _ = self.writer.shutdown(Shutdown::Both);
|
||||
}
|
||||
|
||||
/// The `user_typing` requests received within `wait`, as (channel, parent).
|
||||
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
|
||||
let until = Instant::now() + wait;
|
||||
let mut got = Vec::new();
|
||||
while let Ok(text) = self
|
||||
.texts
|
||||
.recv_timeout(until.saturating_duration_since(Instant::now()))
|
||||
{
|
||||
let v: Value = serde_json::from_str(&text).unwrap();
|
||||
if v["action"] == "user_typing" {
|
||||
let data = &v["data"];
|
||||
got.push((
|
||||
data["channel_id"].as_str().unwrap().to_string(),
|
||||
data["parent_id"].as_str().unwrap().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
}
|
||||
|
||||
fn read_head(stream: &mut TcpStream) -> Option<String> {
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
if stream.read(&mut byte).ok()? == 0 {
|
||||
return None;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
}
|
||||
String::from_utf8(head).ok()
|
||||
}
|
||||
|
||||
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
|
||||
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
|
||||
let mut exact = |n: usize| -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0u8; n];
|
||||
stream.read_exact(&mut buf).ok().map(|()| buf)
|
||||
};
|
||||
loop {
|
||||
let Some(head) = exact(2) else { return };
|
||||
let len = match head[1] & 0x7F {
|
||||
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
|
||||
127 => return,
|
||||
n => n as usize,
|
||||
};
|
||||
let Some(mask) = exact(4) else { return };
|
||||
let Some(raw) = exact(len) else { return };
|
||||
let payload: Vec<u8> = raw
|
||||
.iter()
|
||||
.zip(mask.iter().cycle())
|
||||
.map(|(b, m)| b ^ m)
|
||||
.collect();
|
||||
if head[0] & 0x0F == 0x1 {
|
||||
let _ = tx.send(String::from_utf8(payload).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeMm {
|
||||
pub fn start() -> FakeMm {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let inner = Arc::new(Mutex::new(Inner {
|
||||
me_status: 200,
|
||||
..Inner::default()
|
||||
}));
|
||||
let (ws_tx, ws_rx) = mpsc::channel();
|
||||
let shared = Arc::clone(&inner);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { continue };
|
||||
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
|
||||
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
|
||||
}
|
||||
});
|
||||
FakeMm {
|
||||
addr,
|
||||
inner,
|
||||
sockets: Mutex::new(ws_rx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.addr.port())
|
||||
}
|
||||
|
||||
pub fn refuse_token(&self) {
|
||||
self.inner.lock().unwrap().me_status = 401;
|
||||
}
|
||||
|
||||
pub fn set_since(&self, channel: &str, posts: &[Value]) {
|
||||
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
|
||||
let map: serde_json::Map<String, Value> = posts
|
||||
.iter()
|
||||
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
|
||||
.collect();
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.since
|
||||
.insert(channel.to_string(), json!({"order": order, "posts": map}));
|
||||
}
|
||||
|
||||
/// The next WebSocket `gatewayd` opens, after its hello.
|
||||
pub fn next_ws(&self, wait: Duration) -> WsPeer {
|
||||
self.sockets
|
||||
.lock()
|
||||
.unwrap()
|
||||
.recv_timeout(wait)
|
||||
.expect("no WebSocket connection")
|
||||
}
|
||||
|
||||
pub fn posts(&self) -> Vec<(String, String, String)> {
|
||||
self.inner.lock().unwrap().posts.clone()
|
||||
}
|
||||
|
||||
/// Wait until at least `n` posts were made, for at most `wait`.
|
||||
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
|
||||
let until = Instant::now() + wait;
|
||||
while self.posts().len() < n && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.posts()
|
||||
}
|
||||
|
||||
pub fn calls(&self) -> Vec<(String, String)> {
|
||||
self.inner.lock().unwrap().calls.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
|
||||
let Some(head) = read_head(&mut stream) else {
|
||||
return;
|
||||
};
|
||||
let mut words = head.split_whitespace();
|
||||
let (method, path) = (
|
||||
words.next().unwrap_or("").to_string(),
|
||||
words.next().unwrap_or("").to_string(),
|
||||
);
|
||||
if path == "/api/v4/websocket" {
|
||||
let key = head
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let reply = format!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
|
||||
accept_for(&key)
|
||||
);
|
||||
let _ = stream.write_all(reply.as_bytes());
|
||||
let _ = stream.write_all(&frame(
|
||||
0x1,
|
||||
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
|
||||
));
|
||||
let (tx, texts) = mpsc::channel();
|
||||
let reader = stream.try_clone().unwrap();
|
||||
std::thread::spawn(move || read_frames(reader, tx));
|
||||
let _ = ws_tx.send(WsPeer {
|
||||
writer: stream,
|
||||
texts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0u8; length];
|
||||
let _ = stream.read_exact(&mut body);
|
||||
let (status, answer) = rest(inner, &method, &path, &body);
|
||||
let text = answer.to_string();
|
||||
let reply = format!(
|
||||
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
|
||||
text.len()
|
||||
);
|
||||
let _ = stream.write_all(reply.as_bytes());
|
||||
}
|
||||
|
||||
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
|
||||
let mut inner = inner.lock().unwrap();
|
||||
inner.calls.push((method.to_string(), path.to_string()));
|
||||
match (method, path) {
|
||||
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
|
||||
(200, json!({"id": BOT, "username": BOT_NAME}))
|
||||
}
|
||||
("GET", "/api/v4/users/me") => (
|
||||
inner.me_status,
|
||||
json!({"id": "api.context.session_expired.app_error"}),
|
||||
),
|
||||
("POST", "/api/v4/channels/direct") => {
|
||||
let users: Vec<String> = serde_json::from_slice(body).unwrap();
|
||||
let channel = if users.iter().any(|u| u == KYLE) {
|
||||
DM
|
||||
} else {
|
||||
EVE_DM
|
||||
};
|
||||
(201, json!({"id": channel, "type": "D"}))
|
||||
}
|
||||
("POST", "/api/v4/posts") => {
|
||||
let p: Value = serde_json::from_slice(body).unwrap();
|
||||
let n = u32::try_from(inner.posts.len()).unwrap();
|
||||
let (channel, root, message) = (
|
||||
p["channel_id"].as_str().unwrap(),
|
||||
p["root_id"].as_str().unwrap(),
|
||||
p["message"].as_str().unwrap(),
|
||||
);
|
||||
inner
|
||||
.posts
|
||||
.push((channel.to_string(), root.to_string(), message.to_string()));
|
||||
(201, post(&id('x', n), BOT, channel, root, message, 1))
|
||||
}
|
||||
("GET", p) if p.contains("/posts?since=") => {
|
||||
let channel = p
|
||||
.trim_start_matches("/api/v4/channels/")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
(
|
||||
200,
|
||||
inner
|
||||
.since
|
||||
.get(channel)
|
||||
.cloned()
|
||||
.unwrap_or(json!({"order": [], "posts": {}})),
|
||||
)
|
||||
}
|
||||
_ => (404, json!({"message": "not found"})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
|
||||
//! Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use gatewayd::config::Config;
|
||||
use gatewayd::secrets::Secret;
|
||||
use gatewayd::serve::{Stop, Tuning, run};
|
||||
use proto::Turn;
|
||||
|
||||
use crate::fake_loop::{done, serve_loop};
|
||||
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
|
||||
use crate::tmp::TempDir;
|
||||
|
||||
pub const SHARED: &str = "c0000000000000000000000000";
|
||||
pub const OTHER: &str = "o0000000000000000000000000";
|
||||
pub const WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub struct Running {
|
||||
pub stop: Arc<AtomicBool>,
|
||||
pub log: Arc<Mutex<Vec<String>>>,
|
||||
pub handle: Option<JoinHandle<Stop>>,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
pub fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn wait_log(&self, part: &str) -> Vec<String> {
|
||||
let until = std::time::Instant::now() + WAIT;
|
||||
while !self.log().iter().any(|l| l.contains(part)) {
|
||||
assert!(
|
||||
std::time::Instant::now() < until,
|
||||
"no log line with {part:?}: {:?}",
|
||||
self.log()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.log()
|
||||
}
|
||||
|
||||
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
|
||||
pub fn join_within(mut self) -> Stop {
|
||||
let handle = self.handle.take().unwrap();
|
||||
let until = std::time::Instant::now() + WAIT;
|
||||
while !handle.is_finished() && std::time::Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
let stop = handle.join().unwrap();
|
||||
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
|
||||
stop
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Stop {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
self.handle.take().unwrap().join().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
|
||||
let text = format!(
|
||||
r#"
|
||||
[mattermost]
|
||||
url = "{url}"
|
||||
[secrets.mattermost_token]
|
||||
env = "NOT_READ_BY_RUN"
|
||||
[allow]
|
||||
users = ["{KYLE}"]
|
||||
channels = ["{SHARED}"]
|
||||
[paths]
|
||||
home = "{}"
|
||||
[limits]
|
||||
typing_every_ms = 100
|
||||
{extra}
|
||||
"#,
|
||||
home.path().display()
|
||||
);
|
||||
Config::parse(&text).unwrap()
|
||||
}
|
||||
|
||||
pub fn start(config: Config) -> Running {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let log = Arc::new(Mutex::new(Vec::new()));
|
||||
let tuning = Tuning {
|
||||
backoff: vec![Duration::from_millis(50)],
|
||||
poll: Duration::from_millis(20),
|
||||
rest_timeout: WAIT,
|
||||
};
|
||||
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
|
||||
let handle = std::thread::spawn(move || {
|
||||
let sink: gatewayd::serve::Log =
|
||||
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
|
||||
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
|
||||
});
|
||||
Running {
|
||||
stop,
|
||||
log,
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
|
||||
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
|
||||
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
|
||||
std::thread::sleep(delay);
|
||||
vec![done(&format!("answer to {}", turn.content))]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_state(home: &TempDir) -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
|
||||
serde_json::from_str(&text).unwrap()
|
||||
}
|
||||
|
||||
pub fn loop_dir(home: &TempDir) {
|
||||
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
|
||||
}
|
||||
|
||||
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
|
||||
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
|
||||
loop_dir(home);
|
||||
let turns = answering(home, delay);
|
||||
let mm = FakeMm::start();
|
||||
let running = start(config(home, &mm.url(), ""));
|
||||
let ws = mm.next_ws(WAIT);
|
||||
running.wait_log("gatewayd: connected to ");
|
||||
(mm, running, turns, ws)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! A scripted HTTP server for tests: it records each request and answers with what the test's
|
||||
//! function returns for it. Built on `tls_server`. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use rustls::ServerConfig;
|
||||
|
||||
use crate::tls_server::serve;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
/// The whole head, for tests that look for a header.
|
||||
pub head: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn json(&self) -> serde_json::Value {
|
||||
serde_json::from_slice(&self.body).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A response with a JSON body.
|
||||
pub fn reply(status: u16, extra_headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn read_request(conn: &mut dyn Read) -> Option<Request> {
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
if conn.read(&mut byte).ok()? == 0 {
|
||||
return None;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
}
|
||||
let head = String::from_utf8(head).ok()?;
|
||||
let mut first = head.split_whitespace();
|
||||
let method = first.next()?.to_string();
|
||||
let path = first.next()?.to_string();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse().ok())
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0u8; length];
|
||||
conn.read_exact(&mut body).ok()?;
|
||||
Some(Request {
|
||||
method,
|
||||
path,
|
||||
head,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve requests: `answer(n, &request)` gives the response to the n-th request (from 0). Every
|
||||
/// request is sent on the returned channel.
|
||||
pub fn serve_http<F>(
|
||||
tls: Option<Arc<ServerConfig>>,
|
||||
answer: F,
|
||||
) -> (SocketAddr, mpsc::Receiver<Request>)
|
||||
where
|
||||
F: Fn(usize, &Request) -> String + Send + Sync + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let count = AtomicUsize::new(0);
|
||||
let addr = serve(tls, move |mut conn| {
|
||||
let Some(request) = read_request(&mut conn) else {
|
||||
return;
|
||||
};
|
||||
let n = count.fetch_add(1, Ordering::SeqCst);
|
||||
let response = answer(n, &request);
|
||||
let _ = tx.lock().unwrap().send(request);
|
||||
let _ = conn.write_all(response.as_bytes());
|
||||
let _ = conn.flush();
|
||||
});
|
||||
(addr, rx)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Small TCP and TLS servers for tests, using the TEST-ONLY certificates in `fixtures/tls/`.
|
||||
//! Each serves connections on its own thread with a function of the connection. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use rustls::{ServerConfig, ServerConnection, StreamOwned};
|
||||
|
||||
pub fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/tls")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
/// A server certificate (`server`, `wrong-name`, `other-server`) and its key.
|
||||
pub fn server_config(which: &str) -> Arc<ServerConfig> {
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
CertificateDer::pem_file_iter(fixture(&format!("{which}.pem")))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(fixture(&format!("{which}.key"))).unwrap();
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let config = ServerConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.unwrap();
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
/// Anything a test server can serve: plain TCP, or TLS over it.
|
||||
pub trait Conn: Read + Write + Send {}
|
||||
impl<T: Read + Write + Send> Conn for T {}
|
||||
|
||||
/// Serve every connection on 127.0.0.1 with `handle`, in plain TCP (`tls` None) or TLS.
|
||||
pub fn serve<F>(tls: Option<Arc<ServerConfig>>, handle: F) -> SocketAddr
|
||||
where
|
||||
F: Fn(Box<dyn Conn>) + Send + Sync + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = Arc::new(handle);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { continue };
|
||||
let handle = Arc::clone(&handle);
|
||||
let tls = tls.clone();
|
||||
std::thread::spawn(move || match tls {
|
||||
None => handle(Box::new(stream)),
|
||||
Some(config) => {
|
||||
let conn = ServerConnection::new(config).unwrap();
|
||||
let tls_stream: StreamOwned<ServerConnection, TcpStream> =
|
||||
StreamOwned::new(conn, stream);
|
||||
handle(Box::new(tls_stream));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// A handler that reads one line and writes it back.
|
||||
pub fn echo_line(mut conn: Box<dyn Conn>) {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while conn.read(&mut byte).map(|n| n == 1).unwrap_or(false) {
|
||||
line.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = conn.write_all(&line);
|
||||
let _ = conn.flush();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Temporary directories for tests. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
pub struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> TempDir {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("gw-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Writes `text` to `name` inside the directory and returns the full path.
|
||||
pub fn write(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A scripted WebSocket server for tests: it accepts one handshake per connection and then lets
|
||||
//! the test send raw frames and read the client's. Built on `tls_server`. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gatewayd::ws::handshake::accept_for;
|
||||
use rustls::ServerConfig;
|
||||
|
||||
use crate::tls_server::{Conn, serve};
|
||||
|
||||
pub struct Peer {
|
||||
pub conn: Box<dyn Conn>,
|
||||
/// The request head the client sent, for tests that check it.
|
||||
pub request: String,
|
||||
}
|
||||
|
||||
/// A frame from the client: opcode, whether it was masked, and the unmasked payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientFrame {
|
||||
pub opcode: u8,
|
||||
pub masked: bool,
|
||||
pub mask: [u8; 4],
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
pub fn send(&mut self, bytes: &[u8]) {
|
||||
let _ = self.conn.write_all(bytes);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
|
||||
/// An unmasked server frame, FIN set.
|
||||
pub fn frame(&mut self, opcode: u8, payload: &[u8]) {
|
||||
let mut out = vec![0x80 | opcode];
|
||||
if payload.len() < 126 {
|
||||
out.push(payload.len() as u8);
|
||||
} else {
|
||||
out.push(126);
|
||||
out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
}
|
||||
out.extend_from_slice(payload);
|
||||
self.send(&out);
|
||||
}
|
||||
|
||||
pub fn text(&mut self, text: &str) {
|
||||
self.frame(0x1, text.as_bytes());
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, n: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0u8; n];
|
||||
self.conn.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// The next frame from the client, or `None` when it has gone.
|
||||
pub fn read_frame(&mut self) -> Option<ClientFrame> {
|
||||
let head = self.read_exact(2)?;
|
||||
let opcode = head[0] & 0x0F;
|
||||
let masked = head[1] & 0x80 != 0;
|
||||
let len = match head[1] & 0x7F {
|
||||
126 => u16::from_be_bytes(self.read_exact(2)?.try_into().ok()?) as usize,
|
||||
127 => u64::from_be_bytes(self.read_exact(8)?.try_into().ok()?) as usize,
|
||||
n => n as usize,
|
||||
};
|
||||
let mask: [u8; 4] = if masked {
|
||||
self.read_exact(4)?.try_into().ok()?
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
let raw = self.read_exact(len)?;
|
||||
let payload = raw
|
||||
.iter()
|
||||
.zip(mask.iter().cycle())
|
||||
.map(|(b, m)| b ^ m)
|
||||
.collect();
|
||||
Some(ClientFrame {
|
||||
opcode,
|
||||
masked,
|
||||
mask,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pause(&self, d: Duration) {
|
||||
std::thread::sleep(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve WebSocket connections: complete the handshake (or answer `refuse_with` instead), then run
|
||||
/// `script` on the connection.
|
||||
pub fn serve_ws<F>(
|
||||
tls: Option<Arc<ServerConfig>>,
|
||||
refuse_with: Option<&'static str>,
|
||||
script: F,
|
||||
) -> SocketAddr
|
||||
where
|
||||
F: Fn(Peer) + Send + Sync + 'static,
|
||||
{
|
||||
serve(tls, move |mut conn| {
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
if conn.read(&mut byte).map(|n| n == 0).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
}
|
||||
let request = String::from_utf8_lossy(&head).into_owned();
|
||||
if let Some(reply) = refuse_with {
|
||||
let _ = conn.write_all(reply.as_bytes());
|
||||
return;
|
||||
}
|
||||
let key = request
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let reply = format!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
|
||||
accept_for(&key)
|
||||
);
|
||||
let _ = conn.write_all(reply.as_bytes());
|
||||
let _ = conn.flush();
|
||||
script(Peer { conn, request });
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! A WebSocket connection against a scripted server, plain and TLS: messages in order, pings both
|
||||
//! ways, a dead peer, closing, and hostile input (M4a spec, section 6). Do not edit.
|
||||
|
||||
#[path = "support/tls_server.rs"]
|
||||
mod tls_server;
|
||||
#[path = "support/ws_server.rs"]
|
||||
mod ws_server;
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gatewayd::config::ServerUrl;
|
||||
use gatewayd::net::Connector;
|
||||
use gatewayd::ws::WsError;
|
||||
use gatewayd::ws::conn::{Timing, Ws, host_header};
|
||||
use tls_server::{fixture, server_config};
|
||||
use ws_server::{ClientFrame, serve_ws};
|
||||
|
||||
const SLOW: Timing = Timing {
|
||||
ping_every: Duration::from_secs(60),
|
||||
dead_after: Duration::from_secs(60),
|
||||
};
|
||||
|
||||
/// Plenty of deterministic "random" bytes: the key, then masks.
|
||||
fn random() -> Box<Cursor<Vec<u8>>> {
|
||||
Box::new(Cursor::new(
|
||||
(0..4096u32).map(|i| (i * 37 % 251) as u8).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn open(port: u16, tls: bool, timing: Timing) -> Result<Ws, WsError> {
|
||||
let url = ServerUrl {
|
||||
tls,
|
||||
host: "localhost".to_string(),
|
||||
port,
|
||||
};
|
||||
let ca = tls.then(|| fixture("test-ca.pem"));
|
||||
let c = Connector::new(url, ca.as_deref()).unwrap();
|
||||
Ws::open(&c, "TOKEN", timing, random())
|
||||
}
|
||||
|
||||
/// What the server saw, within 5 s: a missing frame fails the test instead of hanging it.
|
||||
fn got<T>(rx: &mpsc::Receiver<T>) -> T {
|
||||
rx.recv_timeout(Duration::from_secs(5))
|
||||
.expect("the server saw nothing within 5 s")
|
||||
}
|
||||
|
||||
/// Poll until a text message or an error, for at most 5 s.
|
||||
fn next(ws: &mut Ws) -> Result<String, WsError> {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(t) = ws.poll(Duration::from_millis(200))? {
|
||||
return Ok(t);
|
||||
}
|
||||
assert!(Instant::now() < until, "no message within 5 s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_arrive_in_order_plain_and_over_tls() {
|
||||
for tls in [false, true] {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let config = tls.then(|| server_config("server"));
|
||||
let addr = serve_ws(config, None, move |mut p| {
|
||||
tx.send(p.request.clone()).unwrap();
|
||||
p.text("{\"event\":\"hello\"}");
|
||||
p.send(&[0x01, 0x03, b'o', b'n', b'e']);
|
||||
p.send(&[0x80, 0x04, b'-', b't', b'w', b'o']);
|
||||
p.text("three");
|
||||
p.pause(Duration::from_secs(2));
|
||||
});
|
||||
let mut ws = open(addr.port(), tls, SLOW).unwrap();
|
||||
let request = got(&rx);
|
||||
assert!(
|
||||
request.contains("Authorization: Bearer TOKEN\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.starts_with("GET /api/v4/websocket HTTP/1.1\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains(&format!("Host: localhost:{}\r\n", addr.port())),
|
||||
"{request}"
|
||||
);
|
||||
assert_eq!(next(&mut ws).unwrap(), "{\"event\":\"hello\"}");
|
||||
assert_eq!(next(&mut ws).unwrap(), "one-two");
|
||||
assert_eq!(next(&mut ws).unwrap(), "three");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_is_answered_with_the_same_payload() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let addr = serve_ws(None, None, move |mut p| {
|
||||
p.frame(0x9, b"are you there");
|
||||
tx.send(p.read_frame()).unwrap();
|
||||
p.text("after");
|
||||
p.pause(Duration::from_secs(2));
|
||||
});
|
||||
let mut ws = open(addr.port(), false, SLOW).unwrap();
|
||||
assert_eq!(next(&mut ws).unwrap(), "after");
|
||||
let pong: ClientFrame = got(&rx).expect("a pong");
|
||||
assert_eq!(
|
||||
(pong.opcode, pong.masked, pong.payload.as_slice()),
|
||||
(0xA, true, &b"are you there"[..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn we_ping_on_schedule_and_every_frame_is_masked_differently() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let addr = serve_ws(None, None, move |mut p| {
|
||||
for _ in 0..3 {
|
||||
tx.send(p.read_frame()).unwrap();
|
||||
p.frame(0xA, b"");
|
||||
}
|
||||
p.pause(Duration::from_secs(2));
|
||||
});
|
||||
let timing = Timing {
|
||||
ping_every: Duration::from_millis(100),
|
||||
dead_after: Duration::from_secs(5),
|
||||
};
|
||||
let mut ws = open(addr.port(), false, timing).unwrap();
|
||||
ws.send_text("first").unwrap();
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < Duration::from_millis(350) {
|
||||
let _ = ws.poll(Duration::from_millis(50)).unwrap();
|
||||
}
|
||||
let frames: Vec<ClientFrame> = (0..3).map(|_| got(&rx).unwrap()).collect();
|
||||
assert_eq!(
|
||||
(frames[0].opcode, frames[0].payload.as_slice()),
|
||||
(0x1, &b"first"[..])
|
||||
);
|
||||
assert_eq!(frames[1].opcode, 0x9, "a ping after ping_every");
|
||||
assert_eq!(frames[2].opcode, 0x9);
|
||||
assert!(frames.iter().all(|f| f.masked));
|
||||
assert_ne!(
|
||||
frames[0].mask, frames[1].mask,
|
||||
"a fresh mask for every frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_a_dead_peer() {
|
||||
let addr = serve_ws(None, None, |p| p.pause(Duration::from_secs(10)));
|
||||
let timing = Timing {
|
||||
ping_every: Duration::from_secs(60),
|
||||
dead_after: Duration::from_millis(300),
|
||||
};
|
||||
let mut ws = open(addr.port(), false, timing).unwrap();
|
||||
let started = Instant::now();
|
||||
let err = loop {
|
||||
match ws.poll(Duration::from_millis(100)) {
|
||||
Ok(_) => assert!(
|
||||
started.elapsed() < Duration::from_secs(3),
|
||||
"never declared dead"
|
||||
),
|
||||
Err(e) => break e,
|
||||
}
|
||||
};
|
||||
assert!(matches!(err, WsError::Dead), "{err}");
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_that_trickles_is_alive_and_its_message_arrives() {
|
||||
let addr = serve_ws(None, None, |mut p| {
|
||||
for b in [0x81u8, 0x05, b'd', b'r', b'i', b'p', b's'] {
|
||||
p.send(&[b]);
|
||||
p.pause(Duration::from_millis(100));
|
||||
}
|
||||
p.pause(Duration::from_secs(2));
|
||||
});
|
||||
let timing = Timing {
|
||||
ping_every: Duration::from_secs(60),
|
||||
dead_after: Duration::from_millis(400),
|
||||
};
|
||||
let mut ws = open(addr.port(), false, timing).unwrap();
|
||||
assert_eq!(next(&mut ws).unwrap(), "drips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_frame_is_answered_and_ends_the_connection() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let addr = serve_ws(None, None, move |mut p| {
|
||||
p.frame(0x8, &[0x03, 0xE8]);
|
||||
tx.send(p.read_frame()).unwrap();
|
||||
});
|
||||
let mut ws = open(addr.port(), false, SLOW).unwrap();
|
||||
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
|
||||
let reply = got(&rx).expect("a close in reply");
|
||||
assert_eq!(
|
||||
(reply.opcode, reply.payload.as_slice()),
|
||||
(0x8, &[0x03u8, 0xE8][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_connection_is_closed() {
|
||||
let addr = serve_ws(None, None, drop);
|
||||
let mut ws = open(addr.port(), false, SLOW).unwrap();
|
||||
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_frame_is_an_error_not_a_panic() {
|
||||
let addr = serve_ws(None, None, |mut p| {
|
||||
p.send(&[0x81, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
||||
p.pause(Duration::from_secs(2));
|
||||
});
|
||||
let mut ws = open(addr.port(), false, SLOW).unwrap();
|
||||
assert!(matches!(next(&mut ws), Err(WsError::Protocol(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_handshake() {
|
||||
let addr = serve_ws(
|
||||
None,
|
||||
Some("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"),
|
||||
|_| {},
|
||||
);
|
||||
assert!(matches!(
|
||||
open(addr.port(), false, SLOW),
|
||||
Err(WsError::Handshake(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_header_names_the_port_only_when_it_is_not_the_default() {
|
||||
let url = |tls, port| ServerUrl {
|
||||
tls,
|
||||
host: "chat.example".to_string(),
|
||||
port,
|
||||
};
|
||||
assert_eq!(host_header(&url(true, 443)), "chat.example");
|
||||
assert_eq!(host_header(&url(false, 80)), "chat.example");
|
||||
assert_eq!(host_header(&url(true, 80)), "chat.example:80");
|
||||
assert_eq!(host_header(&url(false, 8065)), "chat.example:8065");
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! WebSocket frames, adversarially (M4a spec, section 6). Everything a server sends is untrusted:
|
||||
//! each hostile frame must end the connection with an error, never a panic, and a length must be
|
||||
//! refused before anything waits for or allocates its payload. A seeded property test compares the
|
||||
//! decoder with a deliberately naive one written here, on valid streams and on random mutations of
|
||||
//! them, fed in random pieces. The seed is printed on failure. Do not edit.
|
||||
|
||||
use gatewayd::ws::WsError;
|
||||
use gatewayd::ws::frame::{
|
||||
CLOSE, CONTINUATION, Decoder, Incoming, MAX_MESSAGE, PING, PONG, TEXT, encode,
|
||||
};
|
||||
|
||||
/// How a server frame's length is written: the shortest form, or a longer one on purpose.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Len {
|
||||
Short,
|
||||
Force16,
|
||||
Force64,
|
||||
}
|
||||
|
||||
/// A frame as a server sends it (unmasked unless `masked`).
|
||||
fn frame(fin: bool, rsv: u8, opcode: u8, masked: bool, payload: &[u8], form: Len) -> Vec<u8> {
|
||||
let mut out = vec![(if fin { 0x80 } else { 0 }) | (rsv << 4) | opcode];
|
||||
let m = if masked { 0x80 } else { 0 };
|
||||
let len = payload.len();
|
||||
match form {
|
||||
Len::Short if len < 126 => out.push(m | len as u8),
|
||||
Len::Short if len <= 0xFFFF => {
|
||||
out.push(m | 126);
|
||||
out.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
}
|
||||
Len::Force16 => {
|
||||
out.push(m | 126);
|
||||
out.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
}
|
||||
_ => {
|
||||
out.push(m | 127);
|
||||
out.extend_from_slice(&(len as u64).to_be_bytes());
|
||||
}
|
||||
}
|
||||
if masked {
|
||||
out.extend_from_slice(&[1, 2, 3, 4]);
|
||||
}
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
fn text(s: &str) -> Vec<u8> {
|
||||
frame(true, 0, TEXT, false, s.as_bytes(), Len::Short)
|
||||
}
|
||||
|
||||
/// Feed `bytes` in pieces of `step` and collect every message, stopping at the first error.
|
||||
fn decode(bytes: &[u8], step: usize) -> (Vec<Incoming>, Option<String>) {
|
||||
let mut d = Decoder::new();
|
||||
let mut got = Vec::new();
|
||||
for piece in bytes.chunks(step.max(1)) {
|
||||
d.feed(piece);
|
||||
loop {
|
||||
match d.next_message() {
|
||||
Ok(Some(m)) => got.push(m),
|
||||
Ok(None) => break,
|
||||
Err(e) => return (got, Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
(got, None)
|
||||
}
|
||||
|
||||
fn fails(bytes: &[u8], why: &str) {
|
||||
for step in [1, 2, 3, 7, bytes.len().max(1)] {
|
||||
let (_, err) = decode(bytes, step);
|
||||
assert!(err.is_some(), "{why} (fed {step} at a time) was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_messages() {
|
||||
let mut bytes = text("hello");
|
||||
bytes.extend(frame(true, 0, PING, false, b"p1", Len::Short));
|
||||
bytes.extend(frame(true, 0, PONG, false, b"", Len::Short));
|
||||
bytes.extend(frame(
|
||||
true,
|
||||
0,
|
||||
CLOSE,
|
||||
false,
|
||||
&[0x03, 0xE8, b'b', b'y', b'e'],
|
||||
Len::Short,
|
||||
));
|
||||
let (got, err) = decode(&bytes, bytes.len());
|
||||
assert_eq!(err, None);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
Incoming::Text("hello".into()),
|
||||
Incoming::Ping(b"p1".to_vec()),
|
||||
Incoming::Pong(Vec::new()),
|
||||
Incoming::Close(Some(1000), "bye".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&frame(true, 0, CLOSE, false, b"", Len::Short), 1).0,
|
||||
[Incoming::Close(None, String::new())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragments_reassemble_with_control_frames_between_and_utf8_split_across_them() {
|
||||
let snow = "snow ☃ man";
|
||||
let bytes_of = snow.as_bytes();
|
||||
let cut = snow.find('☃').unwrap() + 1; // inside the three-byte character
|
||||
let mut bytes = frame(false, 0, TEXT, false, &bytes_of[..cut], Len::Short);
|
||||
bytes.extend(frame(true, 0, PING, false, b"mid", Len::Short));
|
||||
bytes.extend(frame(
|
||||
false,
|
||||
0,
|
||||
CONTINUATION,
|
||||
false,
|
||||
&bytes_of[cut..cut + 1],
|
||||
Len::Short,
|
||||
));
|
||||
bytes.extend(frame(
|
||||
true,
|
||||
0,
|
||||
CONTINUATION,
|
||||
false,
|
||||
&bytes_of[cut + 1..],
|
||||
Len::Short,
|
||||
));
|
||||
for step in 1..=bytes.len() {
|
||||
let (got, err) = decode(&bytes, step);
|
||||
assert_eq!(err, None, "step {step}");
|
||||
assert_eq!(
|
||||
got,
|
||||
[Incoming::Ping(b"mid".to_vec()), Incoming::Text(snow.into())],
|
||||
"step {step}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_in_every_form() {
|
||||
for len in [0usize, 1, 125, 126, 127, 65_535, 65_536, 100_000] {
|
||||
let body = "x".repeat(len);
|
||||
let (got, err) = decode(&text(&body), 4096);
|
||||
assert_eq!(err, None, "{len}");
|
||||
assert_eq!(got, [Incoming::Text(body)], "{len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_end_the_connection() {
|
||||
for rsv in [1, 2, 4] {
|
||||
fails(
|
||||
&frame(true, rsv, TEXT, false, b"x", Len::Short),
|
||||
"a reserved bit",
|
||||
);
|
||||
}
|
||||
fails(
|
||||
&frame(true, 0, TEXT, true, b"x", Len::Short),
|
||||
"a masked frame from the server",
|
||||
);
|
||||
for op in [2u8, 3, 7, 11, 15] {
|
||||
fails(
|
||||
&frame(true, 0, op, false, b"x", Len::Short),
|
||||
"an unknown or binary opcode",
|
||||
);
|
||||
}
|
||||
fails(
|
||||
&frame(true, 0, PING, false, &[0u8; 126], Len::Short),
|
||||
"a control frame over 125 bytes",
|
||||
);
|
||||
fails(
|
||||
&frame(false, 0, PING, false, b"x", Len::Short),
|
||||
"a fragmented control frame",
|
||||
);
|
||||
fails(
|
||||
&frame(true, 0, CONTINUATION, false, b"x", Len::Short),
|
||||
"a continuation with nothing to continue",
|
||||
);
|
||||
let mut inside = frame(false, 0, TEXT, false, b"a", Len::Short);
|
||||
inside.extend(text("b"));
|
||||
fails(&inside, "a new message inside an unfinished one");
|
||||
fails(
|
||||
&frame(true, 0, TEXT, false, b"x", Len::Force16),
|
||||
"a 16-bit length for 1 byte",
|
||||
);
|
||||
fails(
|
||||
&frame(true, 0, TEXT, false, &[b'y'; 200], Len::Force64),
|
||||
"a 64-bit length for 200 bytes",
|
||||
);
|
||||
fails(
|
||||
&frame(true, 0, TEXT, false, &[0xff, 0xfe], Len::Short),
|
||||
"text that is not UTF-8",
|
||||
);
|
||||
fails(
|
||||
&frame(true, 0, CLOSE, false, &[3], Len::Short),
|
||||
"a close frame of one byte",
|
||||
);
|
||||
fails(
|
||||
&frame(true, 0, CLOSE, false, &[3, 232, 0xff], Len::Short),
|
||||
"a close reason that is not UTF-8",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_lengths_are_refused_from_the_header_alone() {
|
||||
// Only the header is fed: the decoder must refuse without waiting for a payload.
|
||||
let top_bit = [0x81u8, 127, 0x80, 0, 0, 0, 0, 0, 0, 1];
|
||||
let mut d = Decoder::new();
|
||||
d.feed(&top_bit);
|
||||
assert!(
|
||||
d.next_message().is_err(),
|
||||
"a 64-bit length with its top bit set"
|
||||
);
|
||||
|
||||
let too_big = (MAX_MESSAGE as u64) + 1;
|
||||
let mut head = vec![0x81u8, 127];
|
||||
head.extend_from_slice(&too_big.to_be_bytes());
|
||||
let mut d = Decoder::new();
|
||||
d.feed(&head);
|
||||
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
|
||||
|
||||
let mut head = vec![0x81u8, 127];
|
||||
head.extend_from_slice(&0x7FFF_FFFF_FFFF_FFFFu64.to_be_bytes());
|
||||
let mut d = Decoder::new();
|
||||
d.feed(&head);
|
||||
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
|
||||
|
||||
// Across fragments: the sum counts.
|
||||
let half = MAX_MESSAGE / 2 + 1;
|
||||
let mut d = Decoder::new();
|
||||
d.feed(&frame(false, 0, TEXT, false, &vec![b'a'; half], Len::Short));
|
||||
assert!(matches!(d.next_message(), Ok(None)));
|
||||
let mut second = vec![0x00u8, 127];
|
||||
second.extend_from_slice(&(half as u64).to_be_bytes());
|
||||
d.feed(&second);
|
||||
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
|
||||
|
||||
// Exactly the limit is fine.
|
||||
let (got, err) = decode(&text(&"z".repeat(MAX_MESSAGE)), 65_536);
|
||||
assert_eq!(err, None);
|
||||
assert_eq!(got.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_frames_are_masked_and_decode_back() {
|
||||
let mask = [0x11, 0x22, 0x33, 0x44];
|
||||
for len in [0usize, 5, 125, 126, 65_535, 65_536] {
|
||||
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
|
||||
let bytes = encode(TEXT, &payload, mask);
|
||||
assert_eq!(bytes[0], 0x80 | TEXT, "FIN and the opcode");
|
||||
assert_ne!(bytes[1] & 0x80, 0, "the mask bit");
|
||||
let (len_field, header) = match bytes[1] & 0x7F {
|
||||
126 => (u16::from_be_bytes([bytes[2], bytes[3]]) as usize, 4),
|
||||
127 => (
|
||||
u64::from_be_bytes(bytes[2..10].try_into().unwrap()) as usize,
|
||||
10,
|
||||
),
|
||||
n => (n as usize, 2),
|
||||
};
|
||||
assert_eq!(len_field, len);
|
||||
let shortest = if len < 126 {
|
||||
2
|
||||
} else if len <= 0xFFFF {
|
||||
4
|
||||
} else {
|
||||
10
|
||||
};
|
||||
assert_eq!(header, shortest, "the shortest length form");
|
||||
assert_eq!(&bytes[header..header + 4], &mask);
|
||||
let unmasked: Vec<u8> = bytes[header + 4..]
|
||||
.iter()
|
||||
.zip(mask.iter().cycle())
|
||||
.map(|(b, m)| b ^ m)
|
||||
.collect();
|
||||
assert_eq!(unmasked, payload);
|
||||
}
|
||||
assert_eq!(encode(PONG, b"p", mask)[0], 0x80 | PONG);
|
||||
}
|
||||
|
||||
// ---------- the property test ----------
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// The naive decoder: the whole buffer at once, the rules written out plainly.
|
||||
fn naive(bytes: &[u8]) -> (Vec<Incoming>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let mut partial: Option<Vec<u8>> = None;
|
||||
while i < bytes.len() {
|
||||
if bytes.len() - i < 2 {
|
||||
return (out, false);
|
||||
}
|
||||
let (b0, b1) = (bytes[i], bytes[i + 1]);
|
||||
let (fin, rsv, op, masked, short) = (
|
||||
b0 >> 7 == 1,
|
||||
(b0 >> 4) & 7,
|
||||
b0 & 15,
|
||||
b1 >> 7 == 1,
|
||||
(b1 & 127) as usize,
|
||||
);
|
||||
if rsv != 0 || masked || ![0, 1, 8, 9, 10].contains(&op) {
|
||||
return (out, true);
|
||||
}
|
||||
let (hl, len) = if short == 126 {
|
||||
if bytes.len() - i < 4 {
|
||||
return (out, false);
|
||||
}
|
||||
let l = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
if l < 126 {
|
||||
return (out, true);
|
||||
}
|
||||
(4, l)
|
||||
} else if short == 127 {
|
||||
if bytes.len() - i < 10 {
|
||||
return (out, false);
|
||||
}
|
||||
let l = u64::from_be_bytes(bytes[i + 2..i + 10].try_into().unwrap());
|
||||
if l >> 63 == 1 || l <= 0xFFFF {
|
||||
return (out, true);
|
||||
}
|
||||
(10, l as usize)
|
||||
} else {
|
||||
(2, short)
|
||||
};
|
||||
let control = op >= 8;
|
||||
if control && (!fin || len > 125) {
|
||||
return (out, true);
|
||||
}
|
||||
if !control {
|
||||
if (op == 1 && partial.is_some()) || (op == 0 && partial.is_none()) {
|
||||
return (out, true);
|
||||
}
|
||||
if partial.as_ref().map_or(0, |p| p.len()) + len > MAX_MESSAGE {
|
||||
return (out, true);
|
||||
}
|
||||
}
|
||||
if bytes.len() - i - hl < len {
|
||||
return (out, false);
|
||||
}
|
||||
let payload = bytes[i + hl..i + hl + len].to_vec();
|
||||
i += hl + len;
|
||||
match op {
|
||||
9 => out.push(Incoming::Ping(payload)),
|
||||
10 => out.push(Incoming::Pong(payload)),
|
||||
8 => {
|
||||
if payload.len() == 1 {
|
||||
return (out, true);
|
||||
}
|
||||
if payload.is_empty() {
|
||||
out.push(Incoming::Close(None, String::new()));
|
||||
} else {
|
||||
match String::from_utf8(payload[2..].to_vec()) {
|
||||
Ok(r) => out.push(Incoming::Close(
|
||||
Some(u16::from_be_bytes([payload[0], payload[1]])),
|
||||
r,
|
||||
)),
|
||||
Err(_) => return (out, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut m = if op == 1 {
|
||||
Vec::new()
|
||||
} else {
|
||||
partial.take().unwrap()
|
||||
};
|
||||
m.extend_from_slice(&payload);
|
||||
if fin {
|
||||
match String::from_utf8(m) {
|
||||
Ok(t) => out.push(Incoming::Text(t)),
|
||||
Err(_) => return (out, true),
|
||||
}
|
||||
} else {
|
||||
partial = Some(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, false)
|
||||
}
|
||||
|
||||
/// A random valid stream: text messages split into random fragments, with control frames between.
|
||||
fn valid_stream(rng: &mut Rng) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let len = [0, 1, 50, 125, 126, 300, 70_000][rng.below(7)];
|
||||
let body: String = (0..len)
|
||||
.map(|k| {
|
||||
if (k + rng.below(3)).is_multiple_of(29) {
|
||||
'é'
|
||||
} else {
|
||||
'a'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let raw = body.as_bytes();
|
||||
let parts = 1 + rng.below(3);
|
||||
let mut cuts: Vec<usize> = (0..parts - 1).map(|_| rng.below(raw.len() + 1)).collect();
|
||||
cuts.sort();
|
||||
let mut start = 0;
|
||||
for (k, cut) in cuts
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(std::iter::once(raw.len()))
|
||||
.enumerate()
|
||||
{
|
||||
let op = if k == 0 { TEXT } else { CONTINUATION };
|
||||
bytes.extend(frame(
|
||||
k == parts - 1,
|
||||
0,
|
||||
op,
|
||||
false,
|
||||
&raw[start..cut],
|
||||
Len::Short,
|
||||
));
|
||||
start = cut;
|
||||
if rng.below(3) == 0 {
|
||||
bytes.extend(frame(
|
||||
true,
|
||||
0,
|
||||
PING,
|
||||
false,
|
||||
&[rng.below(256) as u8; 3],
|
||||
Len::Short,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_streams_agree_with_the_naive_decoder() {
|
||||
for case in 0..300u64 {
|
||||
let seed = 0x9E37_79B9_7F4A_7C15 ^ (case * 7919 + 1);
|
||||
let mut rng = Rng(seed);
|
||||
let mut bytes = valid_stream(&mut rng);
|
||||
if case % 2 == 1 {
|
||||
// Mutate: flip a few random bits, so most streams break somewhere different.
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
let at = rng.below(bytes.len());
|
||||
bytes[at] ^= 1 << rng.below(8);
|
||||
}
|
||||
}
|
||||
let (want, want_err) = naive(&bytes);
|
||||
let step = 1 + rng.below(4096);
|
||||
let (got, got_err) = decode(&bytes, step);
|
||||
assert_eq!(got, want, "seed {seed:#x}, step {step}: messages differ");
|
||||
assert_eq!(
|
||||
got_err.is_some(),
|
||||
want_err,
|
||||
"seed {seed:#x}, step {step}: {got_err:?} vs naive error {want_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Base64 and the WebSocket opening handshake (RFC 4648; RFC 6455, section 4). Do not edit.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use gatewayd::http::Head;
|
||||
use gatewayd::ws::WsError;
|
||||
use gatewayd::ws::handshake::{
|
||||
accept_for, base64, check_response, handshake, new_key, request_text,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn base64_vectors() {
|
||||
for (input, want) in [
|
||||
("", ""),
|
||||
("f", "Zg=="),
|
||||
("fo", "Zm8="),
|
||||
("foo", "Zm9v"),
|
||||
("foob", "Zm9vYg=="),
|
||||
("fooba", "Zm9vYmE="),
|
||||
("foobar", "Zm9vYmFy"),
|
||||
] {
|
||||
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
|
||||
}
|
||||
assert_eq!(base64(&[0xff, 0xfe, 0xfd, 0x00, 0x3f]), "//79AD8=");
|
||||
assert_eq!(
|
||||
base64(&(0u8..=15).collect::<Vec<_>>()),
|
||||
"AAECAwQFBgcICQoLDA0ODw=="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rfc_example_accept() {
|
||||
assert_eq!(
|
||||
accept_for("dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_is_sixteen_random_bytes() {
|
||||
let mut random = Cursor::new((0u8..=15).collect::<Vec<_>>());
|
||||
assert_eq!(new_key(&mut random).unwrap(), "AAECAwQFBgcICQoLDA0ODw==");
|
||||
let mut short = Cursor::new(vec![1u8; 15]);
|
||||
assert!(
|
||||
new_key(&mut short).is_err(),
|
||||
"too few random bytes is an error, not a weak key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_request_is_exactly_this() {
|
||||
assert_eq!(
|
||||
request_text("a.example", "/api/v4/websocket", "KEY==", "TOKEN"),
|
||||
"GET /api/v4/websocket HTTP/1.1\r\nHost: a.example\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: KEY==\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer TOKEN\r\n\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn head(status: u16, headers: &[(&str, &str)]) -> Head {
|
||||
Head {
|
||||
status,
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
const KEY: &str = "dGhlIHNhbXBsZSBub25jZQ==";
|
||||
const ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
|
||||
|
||||
#[test]
|
||||
fn only_a_proper_upgrade_is_accepted() {
|
||||
let good = [
|
||||
("Upgrade", "websocket"),
|
||||
("Connection", "Upgrade"),
|
||||
("Sec-WebSocket-Accept", ACCEPT),
|
||||
];
|
||||
assert!(check_response(&head(101, &good), KEY).is_ok());
|
||||
let loose = [
|
||||
("upgrade", "WebSocket"),
|
||||
("connection", "keep-alive, Upgrade"),
|
||||
("sec-websocket-accept", ACCEPT),
|
||||
];
|
||||
assert!(
|
||||
check_response(&head(101, &loose), KEY).is_ok(),
|
||||
"names and tokens are case-insensitive"
|
||||
);
|
||||
let cases: [(u16, &[(&str, &str)]); 7] = [
|
||||
(200, &good),
|
||||
(401, &good),
|
||||
(
|
||||
101,
|
||||
&[("Connection", "Upgrade"), ("Sec-WebSocket-Accept", ACCEPT)],
|
||||
),
|
||||
(
|
||||
101,
|
||||
&[
|
||||
("Upgrade", "h2c"),
|
||||
("Connection", "Upgrade"),
|
||||
("Sec-WebSocket-Accept", ACCEPT),
|
||||
],
|
||||
),
|
||||
(
|
||||
101,
|
||||
&[("Upgrade", "websocket"), ("Sec-WebSocket-Accept", ACCEPT)],
|
||||
),
|
||||
(101, &[("Upgrade", "websocket"), ("Connection", "Upgrade")]),
|
||||
(
|
||||
101,
|
||||
&[
|
||||
("Upgrade", "websocket"),
|
||||
("Connection", "Upgrade"),
|
||||
("Sec-WebSocket-Accept", "s3pplmbitxaq9kygzzhzrbk+xoo="),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (status, headers) in cases {
|
||||
assert!(
|
||||
matches!(
|
||||
check_response(&head(status, headers), KEY),
|
||||
Err(WsError::Handshake(_))
|
||||
),
|
||||
"{status} {headers:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server side scripted as bytes; records what the client wrote.
|
||||
struct Scripted {
|
||||
input: Cursor<Vec<u8>>,
|
||||
output: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Read for Scripted {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.input.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Scripted {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.output.write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_handshake_leaves_the_first_frame_unread() {
|
||||
let key_bytes: Vec<u8> = (0u8..=15).collect();
|
||||
let key = base64(&key_bytes);
|
||||
let reply = format!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
|
||||
accept_for(&key)
|
||||
);
|
||||
let mut bytes = reply.into_bytes();
|
||||
bytes.extend_from_slice(&[0x81, 0x02, b'h', b'i']);
|
||||
let mut s = Scripted {
|
||||
input: Cursor::new(bytes),
|
||||
output: Vec::new(),
|
||||
};
|
||||
handshake(
|
||||
&mut s,
|
||||
"a.example",
|
||||
"/api/v4/websocket",
|
||||
"TOKEN",
|
||||
&mut Cursor::new(key_bytes),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(s.output).unwrap(),
|
||||
request_text("a.example", "/api/v4/websocket", &key, "TOKEN")
|
||||
);
|
||||
let mut rest = Vec::new();
|
||||
s.input.read_to_end(&mut rest).unwrap();
|
||||
assert_eq!(rest, [0x81, 0x02, b'h', b'i']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_or_broken_handshake_is_a_handshake_error() {
|
||||
for reply in [
|
||||
&b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..],
|
||||
b"HTTP/1.1 101 Switching",
|
||||
b"not http at all\r\n\r\n",
|
||||
b"",
|
||||
] {
|
||||
let mut s = Scripted {
|
||||
input: Cursor::new(reply.to_vec()),
|
||||
output: Vec::new(),
|
||||
};
|
||||
let got = handshake(&mut s, "h", "/p", "t", &mut Cursor::new(vec![7u8; 16]));
|
||||
assert!(
|
||||
matches!(got, Err(WsError::Handshake(_))),
|
||||
"{:?}: {got:?}",
|
||||
String::from_utf8_lossy(reply)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! SHA-1 (FIPS 180-4), used only to check the `Sec-WebSocket-Accept` header of a WebSocket
|
||||
//! handshake (RFC 6455, section 4.2.2). Never use it for anything that needs to resist attack.
|
||||
|
||||
/// The digest of `data`.
|
||||
pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut h = Sha1::new();
|
||||
h.update(data);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// SHA-1 fed in pieces.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sha1 {
|
||||
state: [u32; 5],
|
||||
block: [u8; 64],
|
||||
filled: usize,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl Default for Sha1 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sha1 {
|
||||
pub fn new() -> Sha1 {
|
||||
Sha1 {
|
||||
state: [
|
||||
0x6745_2301,
|
||||
0xEFCD_AB89,
|
||||
0x98BA_DCFE,
|
||||
0x1032_5476,
|
||||
0xC3D2_E1F0,
|
||||
],
|
||||
block: [0; 64],
|
||||
filled: 0,
|
||||
length: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, mut data: &[u8]) {
|
||||
// Add 8 * data.len() to `length` (wrapping; `u64::try_from`, never `as`). Copy bytes into
|
||||
// `block` from `filled` on; each time it is full (64), `compress` it and set `filled` to 0.
|
||||
// Use `split_at` and `get_mut(..)`, no indexing that can go out of bounds.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> [u8; 20] {
|
||||
// Save `length`. Feed 0x80 then zeros so that 56 bytes of the block are filled (56 -
|
||||
// filled, or 120 - filled when filled >= 56), then the saved length as 8 big-endian bytes,
|
||||
// through `update`. `update` adds to `length`: put the saved value back after. Then the
|
||||
// five state words, big-endian.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn compress(&mut self, block: &[u8; 64]) {
|
||||
// FIPS 180-4, section 6.1.2: w[0..16] are the block as big-endian u32s; w[i] = (w[i-3] ^
|
||||
// w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1) for 16..80. Eighty rounds with f and k by
|
||||
// range: 0..=19 (b & c) | (!b & d), 0x5A827999; 20..=39 b ^ c ^ d, 0x6ED9EBA1; 40..=59 (b &
|
||||
// c) | (b & d) | (c & d), 0x8F1BBCDC; 60..=79 b ^ c ^ d, 0xCA62C1D6. All additions
|
||||
// wrapping. Add a..e into state.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! SHA-1 against FIPS 180 and RFC 3174 vectors, and against `sha1sum` for the lengths around the
|
||||
//! 64-byte block where padding changes shape. Every case is also fed in pieces. Do not edit.
|
||||
|
||||
use proto::sha1::{Sha1, sha1};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn check(data: &[u8], want: &str) {
|
||||
assert_eq!(hex(&sha1(data)), want, "whole, {} bytes", data.len());
|
||||
for split in [
|
||||
0,
|
||||
1,
|
||||
data.len() / 2,
|
||||
data.len().saturating_sub(1),
|
||||
data.len(),
|
||||
] {
|
||||
let split = split.min(data.len());
|
||||
let mut h = Sha1::new();
|
||||
h.update(&data[..split]);
|
||||
h.update(&data[split..]);
|
||||
assert_eq!(hex(&h.finish()), want, "split at {split} of {}", data.len());
|
||||
}
|
||||
let mut h = Sha1::new();
|
||||
for b in data {
|
||||
h.update(&[*b]);
|
||||
}
|
||||
assert_eq!(hex(&h.finish()), want, "byte by byte, {} bytes", data.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_vectors() {
|
||||
check(b"", "da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
||||
check(b"abc", "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
check(
|
||||
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
"84983e441c3bd26ebaae4aa1f95129e5e54670f1",
|
||||
);
|
||||
check(
|
||||
b"The quick brown fox jumps over the lazy dog",
|
||||
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_million_a() {
|
||||
let data = vec![b'a'; 1_000_000];
|
||||
assert_eq!(
|
||||
hex(&sha1(&data)),
|
||||
"34aa973cd4c4daa4f61eeb2bdbad27316534016f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_around_the_block_boundary() {
|
||||
// `printf 'a%.0s' $(seq N) | sha1sum`, N = 55, 56, 63, 64, 65, 119, 120.
|
||||
let cases = [
|
||||
(55, "c1c8bbdc22796e28c0e15163d20899b65621d65a"),
|
||||
(56, "c2db330f6083854c99d4b5bfb6e8f29f201be699"),
|
||||
(63, "03f09f5b158a7a8cdad920bddc29b81c18a551f5"),
|
||||
(64, "0098ba824b5c16427bd7a1122a5a442a25ec644d"),
|
||||
(65, "11655326c708d70319be2610e8a57d9a5b959d3b"),
|
||||
(119, "ee971065aaa017e0632a8ca6c77bb3bf8b1dfc56"),
|
||||
(120, "f34c1488385346a55709ba056ddd08280dd4c6d6"),
|
||||
];
|
||||
for (n, want) in cases {
|
||||
check(&vec![b'a'; n], want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_websocket_handshake_example() {
|
||||
// RFC 6455, section 1.3: the key and the GUID give this digest (its base64 is
|
||||
// "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=").
|
||||
let digest = sha1(b"dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
||||
assert_eq!(hex(&digest), "b37a4f2cc0624f1690f64606cf385945b2bec4ea");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# cargo-deny configuration. `make gate` runs bans, licenses and sources offline.
|
||||
# `make audit` runs advisories, which fetches the RustSec database.
|
||||
|
||||
# Only the platforms Boxmaker is built on: straylight and the development machines (Linux), and
|
||||
# the owner's Mac. Dependencies for other targets (Windows) are not judged.
|
||||
[graph]
|
||||
all-features = true
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
[licenses]
|
||||
allow = ["MIT", "Apache-2.0", "Unicode-3.0", "ISC", "BSD-3-Clause"]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
|
||||
[bans]
|
||||
multiple-versions = "deny"
|
||||
wildcards = "deny"
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
@@ -0,0 +1,143 @@
|
||||
|
||||
## gatewayd-start-failed
|
||||
|
||||
**What you see.** `gatewayd` exits 1 at start, before it connects, with one line naming its config
|
||||
file or a directory, then this entry.
|
||||
|
||||
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
|
||||
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
|
||||
characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`,
|
||||
`env` and `file`. Or it could not create `<home>/gateway/`. It will not guess at a configuration
|
||||
that decides who it answers.
|
||||
|
||||
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
|
||||
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare <dir>: …` for the
|
||||
directory (`ls -ld <dir> "$(dirname <dir>)"`).
|
||||
|
||||
**Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the
|
||||
user's profile, or by `GET /api/v4/users/username/<name>`.
|
||||
|
||||
**Check.** `gatewayd serve --config <path>` prints `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## secret-unavailable
|
||||
|
||||
**What you see.** `gatewayd` exits 1 at start with `secret <name>: <reason>`, then this entry. The
|
||||
value is never printed.
|
||||
|
||||
**Why.** `gatewayd` does not start without its Mattermost token, and reads it only from the one
|
||||
place `[secrets.<name>]` names. The reasons: for `credential`, `CREDENTIALS_DIRECTORY` is unset
|
||||
(not started by systemd with a credential) or the file in it is missing; for `env`, the variable is
|
||||
unset or empty; for `file`, the path is not absolute, is a symbolic link, is not a regular file, is
|
||||
not owned by the user `gatewayd` runs as, or has any group or other permission (only 0600 or 0400
|
||||
are accepted). An empty value is refused in every form.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
systemctl --user show -p LoadCredentialEncrypted gatewayd # credential
|
||||
ls -l <path>; id -u # file: owner and mode
|
||||
```
|
||||
|
||||
**Fix.** For a credential: `systemd-creds --user encrypt --name=<credential> - <path>`, type the
|
||||
token, then give the unit `LoadCredentialEncrypted=<credential>:<path>`. For a file:
|
||||
`chmod 600 <path>` and `chown` it to the user `gatewayd` runs as. For an environment variable, set
|
||||
it in the environment `gatewayd` starts in.
|
||||
|
||||
**Check.** `gatewayd` starts and prints `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## secret-in-a-file
|
||||
|
||||
**What you see.** At start: `gatewayd: warning: secret <name> is read in plaintext from <path>; a
|
||||
systemd credential keeps it encrypted at rest`, then this entry. `gatewayd` runs normally.
|
||||
|
||||
**Why.** A file holds the token in plaintext: anyone who can read the disk, or a backup of it, can
|
||||
use it. A credential is encrypted to this machine's TPM and host key. Sometimes a file is right (a
|
||||
development machine, a system without systemd); the warning is there so the choice is deliberate.
|
||||
|
||||
**Confirm.** `[secrets.<name>]` in `gatewayd.toml` has `file = …`.
|
||||
|
||||
**Fix.** To keep the file: nothing; the warning stays. To move to a credential, follow the fix in
|
||||
[secret-unavailable](#secret-unavailable), change the entry to `credential = "<name>"`, restart,
|
||||
then delete the file and regenerate the token if the file was ever copied elsewhere.
|
||||
|
||||
**Check.** The warning is gone at the next start.
|
||||
|
||||
## mattermost-unreachable
|
||||
|
||||
**What you see.** `gatewayd` prints `gatewayd: cannot reach <url>: <reason>; trying again in <n> s`,
|
||||
then this entry, once per attempt: after 1, 2, 5 and 10 seconds, then every 30. Posts to Boxmaker go
|
||||
unanswered meanwhile; they are caught up when the connection returns.
|
||||
|
||||
**Why.** The TCP connection, the TLS handshake or the WebSocket upgrade failed, or the server went
|
||||
silent for `dead_after_ms`. A TLS failure means the certificate did not match the host in `url` or
|
||||
did not chain to the system's roots or `ca_file`; verification cannot be turned off.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
curl -sS <url>/api/v4/system/ping # the server answers
|
||||
tailscale status # for a tailnet url: the tailnet is up
|
||||
openssl s_client -connect <host>:443 -servername <host> </dev/null | head
|
||||
```
|
||||
|
||||
**Fix.** Start Mattermost, or the tailnet. For a certificate error, correct `url` to the name on
|
||||
the certificate, or give the issuing CA in `[mattermost] ca_file`. `gatewayd` keeps trying by
|
||||
itself; no restart is needed.
|
||||
|
||||
**Check.** `gatewayd: connected to <url> as <user>`, then a direct message to Boxmaker is answered.
|
||||
|
||||
## mattermost-auth-failed
|
||||
|
||||
**What you see.** `gatewayd` exits 1 with `gatewayd: Mattermost refused the token (<status>)`, then
|
||||
this entry.
|
||||
|
||||
**Why.** Mattermost answered 401 or 403: the token is wrong, revoked, or belongs to a deactivated
|
||||
user. Retrying with the same token cannot help, so `gatewayd` stops instead.
|
||||
|
||||
**Confirm.** With the token in `$T` (from the same place `gatewayd` reads it; do not paste it into a
|
||||
shared shell history):
|
||||
`curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" <url>/api/v4/users/me`
|
||||
prints 401 or 403.
|
||||
|
||||
**Fix.** In Mattermost, under the bot's or user's access tokens, create a new token and revoke the
|
||||
old one. Store it as [secret-unavailable](#secret-unavailable) describes, then start `gatewayd`.
|
||||
|
||||
**Check.** `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## gateway-state-damaged
|
||||
|
||||
**What you see.** `gatewayd` exits 1 with `<home>/gateway/state.json: <reason>`, then this entry:
|
||||
at start when the file cannot be read, or while running when it cannot be written
|
||||
(`…: cannot write: …`).
|
||||
|
||||
**Why.** The state file records which posts were handled, which threads Boxmaker is in, and which
|
||||
turns were in flight. If it cannot be read or kept up to date, `gatewayd` could answer old posts
|
||||
twice or miss threads, so it does not guess. A missing file is a first start and is fine.
|
||||
|
||||
**Confirm.** `ls -l "$BOXMAKER_HOME/gateway/state.json"` and
|
||||
`python3 -m json.tool "$BOXMAKER_HOME/gateway/state.json" >/dev/null`.
|
||||
|
||||
**Fix.** For a write failure, free space or correct the directory's ownership (`df -h`,
|
||||
`ls -ld "$BOXMAKER_HOME/gateway"`), then start `gatewayd`. If only the file's ownership or mode is
|
||||
wrong, correct it. If the content is damaged, move it
|
||||
aside (`mv state.json state.json.damaged`) and start again. That is a first start: posts sent while
|
||||
`gatewayd` was down are not answered, and threads in channels must name Boxmaker again once.
|
||||
|
||||
**Check.** `gatewayd` starts, and `state.json` is rewritten after the next post.
|
||||
|
||||
## loop-unavailable
|
||||
|
||||
**What you see.** In the Mattermost thread: "Boxmaker's loop is not running
|
||||
(see docs/runbook.md#loop-unavailable)". The messages that were waiting are dropped.
|
||||
|
||||
**Why.** `gatewayd` could not connect to `loop.sock`, or the connection closed before the turn
|
||||
ended. `gatewayd` does not retry: `loopd` may have finished and logged the turn, and sending it
|
||||
again would run it twice.
|
||||
|
||||
**Confirm.** `ls -l "$BOXMAKER_HOME/run/loop/loop.sock"` (or `[loop] socket`), and whether
|
||||
`loopd serve` is running. If it stopped, its last lines say why.
|
||||
|
||||
**Fix.** Start `loopd serve --config <path>`; if it failed, follow the entry its message names.
|
||||
Then send the message again in the thread.
|
||||
|
||||
**Check.** A direct message to Boxmaker is answered.
|
||||
Reference in New Issue
Block a user