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:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
@@ -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)
}
}