diff --git a/Cargo.lock b/Cargo.lock index 4da9157f..7c5c9a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "askama" version = "0.16.1" @@ -174,6 +186,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -187,6 +200,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", @@ -218,6 +233,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -233,6 +254,24 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -297,8 +336,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -347,6 +386,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -356,6 +404,27 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -465,6 +534,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -488,7 +567,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1034,6 +1113,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1156,7 +1246,16 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1171,7 +1270,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1210,6 +1309,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1345,6 +1445,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1452,6 +1558,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1461,6 +1578,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1597,6 +1737,7 @@ name = "thirdshift" version = "0.12.2" dependencies = [ "anyhow", + "argon2", "askama", "axoupdater", "axum", @@ -1604,10 +1745,14 @@ dependencies = [ "futures-util", "include_dir", "libc", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", + "serde_urlencoded", + "sha2", "signal-hook", + "subtle", "tempfile", "tokio", "tokio-stream", @@ -1849,6 +1994,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -1885,6 +2036,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 2885993b..80a69530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ include = ["/src", "/skills", "/askama.toml", "/README.md", "/LICENSE"] [dependencies] anyhow = "1.0.104" +argon2 = "0.5.3" askama = "0.16.1" -axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form"] } # axoupdater needs axoasset's `remote` feature, which always pulls in `image` # (with moxcms and pxfm) and turns on reqwest's `default-tls`, which is rustls # with aws-lc. No flag turns either off, and features unify, so switching @@ -25,9 +26,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] } include_dir = "0.7.4" libc = "0.2.189" -reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "blocking", "json"] } +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "blocking", "json", "form"] } +rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +serde_urlencoded = "0.7.1" +sha2 = "0.10.9" +subtle = "2.6.1" signal-hook = "0.4.4" tempfile = "3.27.0" toml = "1.1.6" diff --git a/console/README.md b/console/README.md index 322c06af..962417d8 100644 --- a/console/README.md +++ b/console/README.md @@ -15,17 +15,44 @@ npm test home folder and a free localhost port, and closes its own server and browser. Node and npm are test tools only; the release build embeds its assets. -To open your machine's Inbox, run `thirdshift console --no-sign-in` and visit -the localhost address printed on stdout. Without `--no-sign-in` the command -refuses to serve until the sign-in Ticket ships. In `~/.thirdshift/config.toml`: +To open your machine's Inbox locally without accounts, run +`thirdshift console --no-sign-in` and visit the localhost address printed on +stdout. To require sign-in, set the allowlist in `~/.thirdshift/config.toml`: ```toml [console] port = 8711 +allowed_emails = ["you@example.com"] other_consoles = ["https://console.example.test"] theme = "focus-auto" # also focus-light or focus-dark ``` +Run `thirdshift console password --email you@example.com` in a terminal to set +the password, then `thirdshift console`. Password entry is hidden and asks +twice. With exactly one allowed address, `--email` can be omitted. An empty +allowlist refuses to serve unless `--no-sign-in` is explicit. Both modes bind +only to 127.0.0.1. Use HTTPS at the machine's reverse proxy for sign-in: cookies +are always Secure, HttpOnly and SameSite=Strict (localhost is suitable for tests). + +Accounts live in `~/.thirdshift/console-accounts.json`, beside the Credentials, +with owner-only permissions. Passwords use argon2id; only hashes of session +tokens are stored. Changes use a file lock and atomic replace, including across +CLI and server processes. Sessions survive restarts and expire 30 days after +last use. Every action, including sign-in and sign-out, requires a CSRF token. +After five wrong passwords, an address gets one attempt per minute until a +successful sign-in or a new password resets the slowdown. + +The Account page signs out the current browser. `thirdshift console sign-out +[--email
] [--json]` ends every session for that address; setting a new password +also ends all its sessions. Removing an address from `allowed_emails` blocks +it and revokes its sessions, while retaining the password for later re-adding. +The server checks current settings on requests and while streams remain open. + +`thirdshift console log [--json]` and the Console log page show the same +account events from `~/.thirdshift/console.log`. They name the account but +never contain passwords, session tokens or page views. Email account creation +and password reset are a later Ticket; ask the owner to use the password command. + The header names this machine and links to those addresses, without fetching them. The Inbox and its `/events` stream pair with `thirdshift status`. One server observer checks records and log lengths every second; the page morphs @@ -78,8 +105,9 @@ The fractional Run/stage boundaries also run in the Rust integration tests. folds a heading or toggles a row's peek; `Enter` opens that row's full Command log. `z` folds the current section. `Escape` closes help, jumps or the peek, one layer at a time, then clears selection. `?` opens the key list. `g` opens -the jump list: `i` Inbox, `a` Lanes, `t` Stages, `h` History, `o` Logs, `s` Security, or a section's displayed -letter; `f` opens Waterfall. Text fields keep their normal keys. The peek starts closed and sits +the jump list: `i` Inbox, `b` Lanes, `t` Stages, `f` Waterfall, `h` History, `o` Logs, `s` Security, +`c` Console log, `a` Account, or a section's displayed +letter. Text fields keep their normal keys. The peek starts closed and sits beside the list on a wide screen or at the bottom of a phone. For an exact older Command, `show` and `logs` also accept the absolute record diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs new file mode 100644 index 00000000..be9c3e4f --- /dev/null +++ b/console/tests/accounts.test.cjs @@ -0,0 +1,69 @@ +const { test, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const { once } = require("node:events"); +const { mkdtemp, mkdir, writeFile, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); +const { createInterface } = require("node:readline"); +const { chromium } = require("playwright"); + +let home, server, exited, browser, origin; +before(async () => { + home = await mkdtemp(join(tmpdir(), "thirdshift-console-accounts-")); + const root = join(home, ".thirdshift"); + await mkdir(root); + await writeFile(join(root, "config.toml"), '[console]\nport = 0\nallowed_emails = ["owner@example.test"]\ntheme = "focus-dark"\n'); + // A fixture created with `console password` for "browser test password". + // The Rust HTTP tests exercise account creation through the real terminal. + await writeFile(join(root, "console-accounts.json"), JSON.stringify({ + "owner@example.test": { + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$Yd89fw8Yx3I9TbhuY+Y/cQ$NhL+Lqg2T9nXrlM8NKBaQ3dpVpRWjRsyYbeNkQS8Q7s", + sessions: {} + } + }), { mode: 0o600 }); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, "../../target/debug/thirdshift"), ["console"], { + env: { ...process.env, HOME: home }, stdio: ["ignore", "pipe", "inherit"] + }); + exited = once(server, "exit"); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, "line", { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test("sign-in and sign-out work on a phone without JavaScript using secure cookies", async t => { + const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 }, colorScheme: "light" }); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(origin); + assert.equal(new URL(page.url()).pathname, "/sign-in"); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.getByLabel("Email").fill("owner@example.test"); + await page.getByLabel("Password", { exact: true }).fill("browser test password"); + await Promise.all([page.waitForURL(`${origin}/`), page.getByRole("button", { name: "Sign in", exact: true }).click()]); + await page.getByRole("heading", { name: "Inbox", exact: true }).waitFor(); + const session = (await context.cookies()).find(cookie => cookie.name === "__Host-thirdshift-session"); + assert.ok(session); + assert.equal(session.httpOnly, true); + assert.equal(session.secure, true); + assert.equal(session.sameSite, "Strict"); + assert.ok(session.expires > Date.now() / 1000 + 29 * 86400); + assert.equal((await context.request.post(`${origin}/sign-out`)).status(), 403); + await page.getByRole("link", { name: "Console log", exact: true }).click(); + await page.getByText(/owner@example.test · sign-in/).waitFor(); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); + await page.getByRole("link", { name: "Account", exact: true }).click(); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); + await Promise.all([page.waitForURL(`${origin}/sign-in`), page.getByRole("button", { name: "Sign out this browser" }).click()]); + await page.goto(origin); + assert.equal(new URL(page.url()).pathname, "/sign-in"); + assert.ok(!(await context.cookies()).some(cookie => cookie.name === "__Host-thirdshift-session")); +}); diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index e023b032..caf7e08d 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -441,11 +441,17 @@ test("page keys, jump letters and URLs survive reload and the peek is a phone bo assert.equal(await page.locator('#peek').isVisible(), false); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); } - for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['f', 'waterfall'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { + for (const [letter, name] of [['b', 'lanes'], ['t', 'stages'], ['f', 'waterfall'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { await page.keyboard.press('g'); await page.keyboard.press(letter); await page.waitForURL(`**/?page=${name}`); } + for (const [letter, path] of [['a', '/account'], ['c', '/console-log']]) { + await page.keyboard.press('g'); + await page.keyboard.press(letter); + await page.waitForURL(`${origin}${path}`); + await page.goto(`${origin}/?page=inbox`); + } await page.keyboard.press('j'); await page.keyboard.press('j'); await page.keyboard.press('Space'); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 23921980..7ae9c1d7 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -26,6 +26,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Console Waterfall, tool-call timing | `src/logs/status/waterfall.rs` — retained Run/stage tree and scale; `src/logs/status/waterfall/matching.rs` — scoped Claude/Codex matching; `src/console/waterfall.rs` | `tests/console.rs`, `console/tests/waterfall.test.cjs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | +| Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | diff --git a/src/args.rs b/src/args.rs index eb9d4ce2..bcb15181 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,6 +25,14 @@ pub enum Command { Console { no_sign_in: bool, }, + ConsolePassword(Option), + ConsoleSignOut { + email: Option, + json: bool, + }, + ConsoleLog { + json: bool, + }, Status { json: bool, all: bool, @@ -248,10 +256,45 @@ pub fn parse(args: &[String]) -> Result { Some("version" | "--version" | "-V") => return Ok(Command::Version), Some("update") => return Ok(Command::Update), Some("console") => { + if args.get(1).is_some_and(|action| action == "sign-out") { + let mut email = None; + let mut json = false; + let mut arguments = args[2..].iter(); + while let Some(flag) = arguments.next() { + match flag.as_str() { + "--email" if email.is_none() => { + email = Some( + arguments + .next() + .filter(|email| !email.starts_with('-')) + .ok_or_else(|| anyhow::anyhow!("--email needs an address"))? + .clone(), + ); + } + "--json" if !json => json = true, + _ => { + bail!("usage: thirdshift console sign-out [--email
] [--json]") + } + } + } + return Ok(Command::ConsoleSignOut { email, json }); + } return match &args[1..] { [] => Ok(Command::Console { no_sign_in: false }), [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), - _ => bail!("usage: thirdshift console [--no-sign-in]"), + [action] if action == "password" => Ok(Command::ConsolePassword(None)), + [action, flag, email] + if action == "password" && flag == "--email" && !email.starts_with('-') => + { + Ok(Command::ConsolePassword(Some(email.clone()))) + } + [action] if action == "log" => Ok(Command::ConsoleLog { json: false }), + [action, flag] if action == "log" && flag == "--json" => { + Ok(Command::ConsoleLog { json: true }) + } + _ => bail!( + "usage: thirdshift console [--no-sign-in] | password [--email
] | sign-out [--email
] [--json] | log [--json]" + ), }; } Some("stop") => { diff --git a/src/config.rs b/src/config.rs index c01c308b..d4818803 100644 --- a/src/config.rs +++ b/src/config.rs @@ -791,6 +791,7 @@ silent_minutes = 10 # minutes without growth in either log before a Run is Sil [console] port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 +allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in; default [] other_consoles = [] # http or https addresses linked in the header, never fetched; default [] theme = "focus-auto" # Focus follows the system, or focus-light / focus-dark; default focus-auto @@ -973,6 +974,7 @@ mod tests { "inbox.days", "inbox.silent_minutes", "console.port", + "console.allowed_emails", "console.other_consoles", "console.theme", "activity.quiet_skips", diff --git a/src/config/console.rs b/src/config/console.rs index c23204e8..7269c2f8 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -6,6 +6,7 @@ use serde::Deserialize; #[serde(default, deny_unknown_fields)] pub struct ConsoleSettings { pub port: u16, + pub allowed_emails: Vec, pub other_consoles: Vec, pub theme: String, } @@ -14,6 +15,7 @@ impl Default for ConsoleSettings { fn default() -> Self { Self { port: 8711, + allowed_emails: Vec::new(), other_consoles: Vec::new(), theme: "focus-auto".into(), } @@ -22,6 +24,12 @@ impl Default for ConsoleSettings { impl ConsoleSettings { pub(super) fn validate(&self) -> Result<()> { + for email in &self.allowed_emails { + ensure!( + email.contains('@') && !email.chars().any(char::is_whitespace), + "console.allowed_emails must contain email addresses" + ); + } ensure!( matches!( self.theme.as_str(), diff --git a/src/console.rs b/src/console.rs index 529ad7e5..67bcea7d 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,5 +1,9 @@ //! The machine's Console. Async execution is confined to this module. +mod account_commands; +mod accounts; +mod auth; mod conversation; +pub use account_commands::{password, print_log, sign_out}; mod live; mod logs; mod peek; @@ -13,10 +17,11 @@ use std::net::Ipv4Addr; use anyhow::{Context, Result, ensure}; use askama::Template; use axum::Router; -use axum::extract::{Path, RawQuery}; -use axum::http::{StatusCode, header}; +use axum::extract::{DefaultBodyLimit, Path, RawQuery}; +use axum::http::{Method, StatusCode, header}; +use axum::middleware; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use include_dir::{Dir, include_dir}; use crate::config::UserConfig; @@ -29,6 +34,7 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); struct PageRoute { path: &'static str, cli: &'static [&'static str], + method: Method, view: View, } @@ -41,6 +47,11 @@ enum View { CommandLogs, Reader, LogStream, + ConsoleLog, + Account, + SignOut, + SignInPage, + SignIn, } const PAGES: &[PageRoute] = &[ @@ -50,76 +61,124 @@ const PAGES: &[PageRoute] = &[ "thirdshift show widgets#7 --json", "thirdshift logs widgets#7", ], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/lanes", cli: &["thirdshift status --all --json"], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/stages", cli: &["thirdshift status --all --json"], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/", cli: &["thirdshift status"], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/events", cli: &["thirdshift status"], + method: Method::GET, view: View::Changes, }, PageRoute { path: "/history", cli: &["thirdshift status --all --json"], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/security", cli: &["thirdshift status --all --json"], + method: Method::GET, view: View::Inbox, }, PageRoute { path: "/peek", cli: &["thirdshift show widgets#7 --json"], + method: Method::GET, view: View::Inspect, }, PageRoute { path: "/log", cli: &["thirdshift logs widgets#7"], + method: Method::GET, view: View::Log, }, PageRoute { path: "/logs", cli: &["thirdshift logs --json"], + method: Method::GET, view: View::Logs, }, PageRoute { path: "/logs/command/{id}", cli: &["thirdshift logs --command id --json"], + method: Method::GET, view: View::CommandLogs, }, PageRoute { path: "/logs/{id}", cli: &["thirdshift logs --file id"], + method: Method::GET, view: View::Reader, }, PageRoute { path: "/logs/{id}/stream/{offset}", cli: &["thirdshift logs --file id --follow"], + method: Method::GET, view: View::LogStream, }, + PageRoute { + path: "/console-log", + cli: &["thirdshift console log"], + method: Method::GET, + view: View::ConsoleLog, + }, + PageRoute { + path: "/account", + cli: &["thirdshift console sign-out"], + method: Method::GET, + view: View::Account, + }, + PageRoute { + path: "/sign-out", + cli: &["thirdshift console sign-out"], + method: Method::POST, + view: View::SignOut, + }, + // CLI account access is authorized by the terminal's Unix account. HTTP + // establishes that access with the password set by this paired command. + PageRoute { + path: "/sign-in", + cli: &["thirdshift console password"], + method: Method::GET, + view: View::SignInPage, + }, + PageRoute { + path: "/sign-in", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SignIn, + }, ]; pub fn serve(no_sign_in: bool) -> Result<()> { + let config = UserConfig::load()?; ensure!( - no_sign_in, - "Console sign-in is not available yet; use thirdshift console --no-sign-in on localhost" + no_sign_in || !config.console.allowed_emails.is_empty(), + "console.allowed_emails is empty; configure allowed addresses or use --no-sign-in on localhost" ); - let config = UserConfig::load()?; + if !no_sign_in { + accounts::Accounts::load()?; + } let executable = restart::Executable::current()?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -134,10 +193,26 @@ pub fn serve(no_sign_in: bool) -> Result<()> { let (changes, observer) = live::observe(render_inbox("thirdshift status").await?); axum::serve( listener, - router(changes).layer(axum::Extension(executable.clone())), + router(no_sign_in, changes).layer(axum::Extension(executable.clone())), ) .with_graceful_shutdown(async move { - watched.replaced().await; + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if watched.changed() { + break; + } + // Revoke removed accounts even when nobody makes a request, + // so re-adding an address cannot revive its old sessions. + if !no_sign_in { + match tokio::task::spawn_blocking(|| accounts::Accounts::load().map(drop)).await + { + Ok(Ok(())) => {} + error => { + eprintln!("thirdshift Console: can't reconcile allowlist: {error:?}") + } + } + } + } // Dropping the sole sender closes every SSE response before // graceful shutdown waits for the connections to finish. observer.abort(); @@ -152,27 +227,37 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } -fn router(changes: tokio::sync::watch::Receiver) -> Router { +fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli[0]; let path = page.path; - router = router.route( - page.path, - match page.view { - View::Inbox => get(move |query| inbox(cli, path, query)), - View::Inspect => get(inspect), - View::Log => get(log), - View::Changes => get(live::events), - View::Logs => get(logs::index), - View::CommandLogs => get(logs::command), - View::Reader => get(logs::reader), - View::LogStream => get(logs::stream), - }, - ); + let handler = match (&page.view, page.method.clone()) { + (View::Inbox, Method::GET) => get(move |query| inbox(cli, path, query)), + (View::Inspect, Method::GET) => get(inspect), + (View::Log, Method::GET) => get(log), + (View::Changes, Method::GET) => get(live::events), + (View::Logs, Method::GET) => get(logs::index), + (View::CommandLogs, Method::GET) => get(logs::command), + (View::Reader, Method::GET) => get(logs::reader), + (View::LogStream, Method::GET) => get(logs::stream), + (View::ConsoleLog, Method::GET) => get(auth::log), + (View::Account, Method::GET) => get(auth::account), + (View::SignOut, Method::POST) => post(auth::sign_out), + (View::SignInPage, Method::GET) => get(auth::sign_in_page), + (View::SignIn, Method::POST) => post(auth::sign_in), + _ => unreachable!("unknown Console page method"), + }; + router = router.route(page.path, handler); } router = router.route("/assets/{*path}", get(asset)); - router.with_state(changes) + let access = auth::Access::new(no_sign_in); + router + .with_state(changes) + .layer(DefaultBodyLimit::max(16 * 1024)) + .layer(middleware::from_fn_with_state(access.clone(), auth::guard)) + .layer(axum::Extension(access)) + .layer(middleware::from_fn(auth::headers)) } #[derive(Template)] @@ -298,7 +383,7 @@ async fn inspect(RawQuery(query): RawQuery) -> Response { }) .await { - Ok(Ok(html)) => html_response(html), + Ok(Ok(html)) => Html(html).into_response(), _ => (StatusCode::NOT_FOUND, "Command details are unavailable.").into_response(), } } @@ -326,17 +411,9 @@ async fn log(RawQuery(query): RawQuery) -> Response { } } -fn html_response(html: String) -> Response { - ( - [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), - (header::CACHE_CONTROL, "no-store"), - (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) - ).into_response() -} - fn page(result: std::result::Result, tokio::task::JoinError>) -> Response { match result { - Ok(Ok(html)) => html_response(html), + Ok(Ok(html)) => Html(html).into_response(), error => { eprintln!("thirdshift Console: can't read page: {error:?}"); ( @@ -380,14 +457,14 @@ async fn asset(Path(path): Path) -> Response { mod tests { use axum::{ Router, - routing::{Route, get}, + routing::{Route, get, post}, }; use std::sync::{Arc, Mutex}; use tower_layer::Layer; fn router() -> Router { let (_, changes) = tokio::sync::watch::channel(0); - super::router(changes) + super::router(false, changes) } // Router::route_layer walks every registered method service, including @@ -417,9 +494,28 @@ mod tests { let get_methods = registered_methods( Router::>::new() .route("/known", get(|| async {})) + .with_state(changes.clone()), + ); + let post_methods = registered_methods( + Router::>::new() + .route("/known", post(|| async {})) .with_state(changes), ); - let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route + // Each unique path has one fallback; merging GET and POST at sign-in + // adds only the POST service, not another fallback service. + let fallback = 1; + let expected = super::PAGES + .iter() + .map(|page| { + if page.method == axum::http::Method::GET { + get_methods + } else { + post_methods + } + }) + .sum::() + + get_methods + - fallback; assert_eq!( registered_methods(router), expected, diff --git a/src/console/account.html b/src/console/account.html new file mode 100644 index 00000000..b9aaf780 --- /dev/null +++ b/src/console/account.html @@ -0,0 +1,7 @@ +{% extends "console/account_base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +

Account

{{ email }}

+{% if signed_in %}{% endif %} +

End all sessions: thirdshift console sign-out --email <address>

+{% endblock %} diff --git a/src/console/account_base.html b/src/console/account_base.html new file mode 100644 index 00000000..90c9c3c3 --- /dev/null +++ b/src/console/account_base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}{% endblock %} · thirdshift + + + + +

thirdshift / Console

+ {% block navigation %}{% include "console/nav.html" %}{% endblock %} +
+
{% block content %}{% endblock %}
+ + diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs new file mode 100644 index 00000000..befa8aff --- /dev/null +++ b/src/console/account_commands.rs @@ -0,0 +1,70 @@ +//! Console account commands use the same account store and log as HTTP. +use anyhow::{Result, bail, ensure}; +use std::io::Write; + +use super::accounts::Accounts; +use crate::config::UserConfig; + +fn email(given: Option<&str>) -> Result { + if let Some(email) = given { + return Ok(email.into()); + } + let config = UserConfig::load()?; + if let [email] = &config.console.allowed_emails[..] { + return Ok(email.clone()); + } + bail!("give --email
; console.allowed_emails must name exactly one address to omit it") +} + +pub fn password(given: Option<&str>) -> Result { + let email = email(given)?; + ensure!( + UserConfig::load()?.console.allowed_emails.contains(&email), + "address is not allowed in console.allowed_emails" + ); + crate::interrupt::install()?; + let (first, second) = { + // Suppress echo before either prompt, and keep it off between them. + let _echo = crate::terminal::EchoOff::new()?; + (prompt("Password: ")?, prompt("Confirm password: ")?) + }; + ensure!(first == second, "passwords do not match"); + Accounts::load()?.set_password(&email, &first)?; + Ok(format!( + "Password set for {email}; previous sessions ended." + )) +} + +fn prompt(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush()?; + let answer = crate::terminal::line()?.ok_or_else(|| anyhow::anyhow!("password input ended"))?; + eprintln!(); + Ok(answer) +} + +pub fn sign_out(given: Option<&str>, json: bool) -> Result<()> { + let email = email(given)?; + Accounts::load()?.sign_out(&email, None)?; + if json { + println!( + "{}", + serde_json::json!({ "email": email, "signed_out": true }) + ); + } else { + println!("Signed out {email}."); + } + Ok(()) +} + +pub fn print_log(json: bool) -> Result<()> { + let events = Accounts::load()?.events()?; + if json { + println!("{}", serde_json::to_string(&events)?); + } else { + for event in events { + println!("{} {} {}", event.time, event.email, event.event); + } + } + Ok(()) +} diff --git a/src/console/account_log.html b/src/console/account_log.html new file mode 100644 index 00000000..6a8caf9e --- /dev/null +++ b/src/console/account_log.html @@ -0,0 +1,7 @@ +{% extends "console/account_base.html" %} +{% block title %}Console log{% endblock %} +{% block content %} +

Console log

thirdshift console log
+
    {% for event in events %}
  • {{ event.time }} · {{ event.email }} · {{ event.event }}
  • {% endfor %}
+{% if events.is_empty() %}

No account events.

{% endif %} +{% endblock %} diff --git a/src/console/accounts.rs b/src/console/accounts.rs new file mode 100644 index 00000000..489c731f --- /dev/null +++ b/src/console/accounts.rs @@ -0,0 +1,274 @@ +//! Durable Console accounts, shared by the server and CLI under one file lock. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub(super) const LIFETIME: i64 = 30 * 24 * 60 * 60; + +#[derive(Default, Deserialize, Serialize)] +struct Account { + password_hash: String, + #[serde(default)] + sessions: BTreeMap, + #[serde(default)] + failed_passwords: u32, + #[serde(default)] + next_attempt: i64, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum EventKind { + SignIn, + FailedSignIn, + PasswordSet, + SignOut, + BlockedAddress, +} + +impl std::fmt::Display for EventKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::SignIn => "sign-in", + Self::FailedSignIn => "failed-sign-in", + Self::PasswordSet => "password-set", + Self::SignOut => "sign-out", + Self::BlockedAddress => "blocked-address", + }) + } +} + +#[derive(Deserialize, Serialize)] +pub(super) struct Event { + pub time: i64, + pub email: String, + pub event: EventKind, +} + +pub(super) enum SignIn { + SignedIn(String), + Refused, + Slowdown, +} + +pub(super) struct Accounts { + root: PathBuf, + // Keep a separate, stable inode locked across account-file replacements. + _lock: File, + allowed: Vec, + now: i64, + accounts: BTreeMap, +} + +impl Accounts { + pub fn load() -> Result { + let config = crate::config::UserConfig::load()?; + Self::open( + &crate::config::home()?.join(".thirdshift"), + &config.console.allowed_emails, + chrono::Utc::now().timestamp(), + ) + } + + pub fn open(root: &Path, allowed: &[String], now: i64) -> Result { + fs::create_dir_all(root)?; + let lock = private_file(&root.join("console-accounts.lock"))?; + lock.lock()?; + let path = root.join("console-accounts.json"); + let accounts = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).context("invalid Console accounts")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(error) => return Err(error.into()), + }; + let mut store = Self { + root: root.into(), + _lock: lock, + allowed: allowed.to_vec(), + now, + accounts, + }; + let mut changed = false; + let mut blocked = Vec::new(); + for (email, account) in &mut store.accounts { + let before = account.sessions.len(); + if !allowed.contains(email) { + account.sessions.clear(); + if before != 0 { + blocked.push(email.clone()); + } + } else { + account + .sessions + .retain(|_, last_use| now.saturating_sub(*last_use) < LIFETIME); + } + changed |= before != account.sessions.len(); + } + if changed { + store.save()?; + } + for email in blocked { + store.record(&email, EventKind::BlockedAddress)?; + } + Ok(store) + } + + pub fn set_password(&mut self, email: &str, password: &str) -> Result<()> { + ensure!( + self.allowed.iter().any(|allowed| allowed == email), + "address is not allowed in console.allowed_emails" + ); + ensure!(!password.is_empty(), "password must not be empty"); + let salt = SaltString::generate(&mut OsRng); + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|error| anyhow::anyhow!("can't hash password: {error}"))? + .to_string(); + self.accounts.insert( + email.into(), + Account { + password_hash, + ..Account::default() + }, + ); + self.save()?; + self.record(email, EventKind::PasswordSet) + } + + pub fn sign_in(&mut self, email: &str, password: &str) -> Result { + if !self.allowed.iter().any(|allowed| allowed == email) { + self.record(email, EventKind::BlockedAddress)?; + return Ok(SignIn::Refused); + } + if self + .accounts + .get(email) + .is_some_and(|account| self.now < account.next_attempt) + { + self.record(email, EventKind::FailedSignIn)?; + return Ok(SignIn::Slowdown); + } + let valid = self.accounts.get(email).is_some_and(|account| { + PasswordHash::new(&account.password_hash).is_ok_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) + }); + if !valid { + if let Some(account) = self.accounts.get_mut(email) { + account.failed_passwords = account.failed_passwords.saturating_add(1); + if account.failed_passwords >= 5 { + account.next_attempt = self.now + 60; + } + self.save()?; + } + self.record(email, EventKind::FailedSignIn)?; + return Ok(SignIn::Refused); + } + let token = random_token(); + let account = self.accounts.get_mut(email).expect("verified account"); + account.failed_passwords = 0; + account.next_attempt = 0; + account.sessions.insert(digest(&token), self.now); + self.save()?; + self.record(email, EventKind::SignIn)?; + Ok(SignIn::SignedIn(token)) + } + + pub fn session(&mut self, token: &str, touch: bool) -> Result> { + let hash = digest(token); + let email = self.accounts.iter().find_map(|(email, account)| { + account.sessions.contains_key(&hash).then(|| email.clone()) + }); + if let Some(email) = &email + && touch + { + self.accounts + .get_mut(email) + .expect("session account") + .sessions + .insert(hash, self.now); + self.save()?; + } + Ok(email) + } + + // None ends every session for the account (CLI); Some ends this browser only. + pub fn sign_out(&mut self, email: &str, token: Option<&str>) -> Result<()> { + if let Some(account) = self.accounts.get_mut(email) { + match token { + Some(token) => { + account.sessions.remove(&digest(token)); + } + None => account.sessions.clear(), + } + self.save()?; + } + self.record(email, EventKind::SignOut) + } + + pub fn events(&self) -> Result> { + let text = match fs::read_to_string(self.root.join("console.log")) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + text.lines() + .map(|line| serde_json::from_str(line).context("invalid Console log event")) + .collect() + } + + fn save(&self) -> Result<()> { + let mut file = tempfile::NamedTempFile::new_in(&self.root)?; + file.write_all(&serde_json::to_vec(&self.accounts)?)?; + file.as_file().sync_all()?; + file.persist(self.root.join("console-accounts.json"))?; + Ok(()) + } + + fn record(&self, email: &str, event: EventKind) -> Result<()> { + let mut file = private_file(&self.root.join("console.log"))?; + serde_json::to_writer( + &mut file, + &Event { + time: self.now, + email: email.into(), + event, + }, + )?; + file.write_all(b"\n")?; + Ok(()) + } +} + +fn private_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + Ok(options.open(path)?) +} + +pub(super) fn random_token() -> String { + let mut bytes = [0; 32]; + OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(super) fn digest(token: &str) -> String { + format!("{:x}", Sha256::digest(token.as_bytes())) +} + +#[cfg(test)] +mod tests; diff --git a/src/console/accounts/tests.rs b/src/console/accounts/tests.rs new file mode 100644 index 00000000..bdf85a52 --- /dev/null +++ b/src/console/accounts/tests.rs @@ -0,0 +1,140 @@ +use super::*; + +fn signed_in(accounts: &mut Accounts) -> String { + let SignIn::SignedIn(token) = accounts.sign_in("owner@example.test", "password").unwrap() + else { + panic!("sign-in refused") + }; + token +} + +#[test] +fn five_wrong_passwords_slow_the_address_to_one_attempt_per_minute() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into(), "other@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "password") + .unwrap(); + accounts + .set_password("other@example.test", "password") + .unwrap(); + for _ in 0..5 { + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + } + assert!( + !matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "sixth try must wait" + ); + assert!(matches!( + accounts.sign_in("other@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_059).unwrap(); + assert!(!matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_060).unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + assert!( + !matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "each wrong try restarts the minute" + ); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_120).unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + assert!( + matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "success resets the slowdown" + ); +} + +#[test] +fn sessions_expire_thirty_days_after_last_use_and_allowlist_removal_revokes_them() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "password") + .unwrap(); + let token = signed_in(&mut accounts); + drop(accounts); + let month = 30 * 24 * 60 * 60; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month - 1).unwrap(); + assert!(accounts.session(&token, true).unwrap().is_some()); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 2).unwrap(); + assert!(accounts.session(&token, false).unwrap().is_some()); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 1).unwrap(); + assert!( + accounts.session(&token, true).unwrap().is_none(), + "expires at 30 days, even across restarts" + ); + let token = signed_in(&mut accounts); + drop(accounts); + drop(Accounts::open(dir.path(), &[], 1_000 + month * 2).unwrap()); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_001 + month * 2).unwrap(); + assert!( + accounts.session(&token, true).unwrap().is_none(), + "re-adding the address must not revive sessions" + ); + assert!( + matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "the password is kept" + ); +} + +#[test] +fn passwords_and_sessions_survive_reopening_and_password_changes_revoke_sessions() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "first password") + .unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + let SignIn::SignedIn(token) = accounts + .sign_in("owner@example.test", "first password") + .unwrap() + else { + panic!("sign-in refused") + }; + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_001).unwrap(); + assert_eq!( + accounts.session(&token, true).unwrap().as_deref(), + Some("owner@example.test") + ); + accounts + .set_password("owner@example.test", "new password") + .unwrap(); + assert!(accounts.session(&token, true).unwrap().is_none()); +} diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 8a377ffa..2c3437e8 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -85,6 +85,10 @@ footer { margin-top: 16px; } #log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } #log-content h3 { font-size: 14px; } .diff { border-left: 3px solid var(--look); padding-left: 10px; } +.account-form { display: block; max-width: 420px; margin: 20px 0; } +.account-form label { display: block; margin: 12px 0; } +.account-form input:not([type="hidden"]), .account-form button { display: block; width: 100%; padding: 10px; margin-top: 6px; font: inherit; background: var(--panel); color: var(--text); border: 1px solid var(--rule); border-radius: var(--radius); } +.account-form button { cursor: pointer; font-weight: var(--head-weight); } .lane { border-bottom: 1px solid var(--rule); padding: 10px 0; } .time-run h3 { font-size: 14px; margin: 4px 0; color: var(--text); } .lane .time-run + .time-run { margin-left: 12px; border-left: 1px solid var(--rule); } diff --git a/src/console/auth.rs b/src/console/auth.rs new file mode 100644 index 00000000..8aebbc4e --- /dev/null +++ b/src/console/auth.rs @@ -0,0 +1,314 @@ +//! HTTP sign-in, session authorization and CSRF for every Console action. +use anyhow::Result; +use askama::Template; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::{Extension, Form}; +use serde::Deserialize; +use subtle::ConstantTimeEq; + +use super::accounts::{self, Accounts, SignIn}; +use crate::config::UserConfig; + +const SESSION: &str = "__Host-thirdshift-session"; +const SIGN_IN: &str = "__Host-thirdshift-sign-in"; + +#[derive(Clone)] +pub(super) struct Access { + pub no_sign_in: bool, + local_csrf: String, +} + +impl Access { + pub fn new(no_sign_in: bool) -> Self { + Self { + no_sign_in, + local_csrf: accounts::random_token(), + } + } +} + +#[derive(Clone)] +pub(super) struct Viewer { + pub email: String, + token: Option, + csrf: String, +} + +impl Viewer { + // Streams recheck without extending idle lifetime on polling alone. + pub fn still_allowed(&self) -> Result { + match &self.token { + Some(token) => Ok(Accounts::load()?.session(token, false)?.is_some()), + None => Ok(true), + } + } +} + +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .find_map(|pair| { + let (key, value) = pair.trim().split_once('=')?; + (key == name && value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| value.to_owned()) + }) +} + +fn csrf(token: &str) -> String { + accounts::digest(&format!("csrf:{token}")) +} + +fn valid_csrf(given: &str, expected: &str) -> bool { + bool::from(given.as_bytes().ct_eq(expected.as_bytes())) +} + +fn set_cookie(response: &mut Response, name: &str, value: &str, age: i64) { + response.headers_mut().append( + header::SET_COOKIE, + format!("{name}={value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={age}") + .parse() + .expect("generated cookie"), + ); +} + +pub(super) async fn guard( + State(access): State, + mut request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + if path == "/sign-in" || path.starts_with("/assets/") { + return next.run(request).await; + } + let viewer = if access.no_sign_in { + Viewer { + email: "localhost (no sign-in)".into(), + token: None, + csrf: access.local_csrf, + } + } else { + let Some(token) = cookie(request.headers(), SESSION) else { + return Redirect::to("/sign-in").into_response(); + }; + let selected = token.clone(); + let result = + tokio::task::spawn_blocking(move || Accounts::load()?.session(&selected, true)).await; + match result { + Ok(Ok(Some(email))) => Viewer { + email, + csrf: csrf(&token), + token: Some(token), + }, + Ok(Ok(None)) => return Redirect::to("/sign-in").into_response(), + error => return failed(error), + } + }; + if !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) { + let supplied = request + .headers() + .get("x-csrf-token") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let (parts, body) = request.into_parts(); + let Ok(bytes) = to_bytes(body, 16 * 1024).await else { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + }; + let form = + serde_urlencoded::from_bytes::>(&bytes).unwrap_or_default(); + let supplied = supplied.or_else(|| { + form.into_iter() + .find(|(key, _)| key == "csrf") + .map(|(_, value)| value) + }); + if !supplied.is_some_and(|token| valid_csrf(&token, &viewer.csrf)) { + return StatusCode::FORBIDDEN.into_response(); + } + request = Request::from_parts(parts, Body::from(bytes)); + } + request.extensions_mut().insert(viewer.clone()); + let mut response = next.run(request).await; + if let Some(token) = viewer.token + && !response.headers().contains_key(header::SET_COOKIE) + { + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + } + response +} + +pub(super) async fn headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + for (name, value) in [ + ( + header::CONTENT_SECURITY_POLICY, + "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + (header::REFERRER_POLICY, "same-origin"), + ] { + response.headers_mut().insert(name, value.parse().unwrap()); + } + response +} + +#[derive(Template)] +#[template(path = "console/sign_in.html")] +struct SignInPage { + csrf: String, + theme: String, +} + +pub(super) async fn sign_in_page( + Extension(access): Extension, + headers: HeaderMap, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); + let form_token = csrf(&token); + let mut response = super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(SignInPage { + csrf: form_token, + theme: UserConfig::load()?.console.theme, + } + .render()?) + }) + .await, + ); + if response.status().is_success() { + set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + } + response +} + +#[derive(Deserialize)] +pub(super) struct SignInForm { + email: String, + password: String, + #[serde(default)] + csrf: String, +} + +pub(super) async fn sign_in( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(token) = cookie(&headers, SIGN_IN) else { + return StatusCode::FORBIDDEN.into_response(); + }; + if !valid_csrf(&form.csrf, &csrf(&token)) { + return StatusCode::FORBIDDEN.into_response(); + } + let result = + tokio::task::spawn_blocking(move || Accounts::load()?.sign_in(&form.email, &form.password)) + .await; + match result { + Ok(Ok(SignIn::SignedIn(token))) => { + let mut response = Redirect::to("/").into_response(); + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + response + } + Ok(Ok(SignIn::Refused)) => ( + StatusCode::UNAUTHORIZED, + axum::response::Html("Sign-in failed. Try again."), + ) + .into_response(), + Ok(Ok(SignIn::Slowdown)) => ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, "60")], + axum::response::Html( + "Sign-in failed. Wait one minute, then try again.", + ), + ) + .into_response(), + Ok(Err(error)) => failed(error), + Err(error) => failed(error), + } +} + +#[derive(Template)] +#[template(path = "console/account.html")] +struct AccountPage { + email: String, + csrf: String, + signed_in: bool, + theme: String, +} + +pub(super) async fn account(Extension(viewer): Extension) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(AccountPage { + email: viewer.email, + csrf: viewer.csrf, + signed_in: viewer.token.is_some(), + theme: UserConfig::load()?.console.theme, + } + .render()?) + }) + .await, + ) +} + +pub(super) async fn sign_out(Extension(viewer): Extension) -> Response { + let Some(token) = viewer.token else { + return StatusCode::NOT_FOUND.into_response(); + }; + let result = tokio::task::spawn_blocking(move || { + Accounts::load()?.sign_out(&viewer.email, Some(&token)) + }) + .await; + match result { + Ok(Ok(())) => { + let mut response = Redirect::to("/sign-in").into_response(); + set_cookie(&mut response, SESSION, "", 0); + response + } + error => failed(error), + } +} + +#[derive(Template)] +#[template(path = "console/account_log.html")] +struct LogPage { + events: Vec, + theme: String, +} + +pub(super) async fn log() -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(LogPage { + events: Accounts::load()?.events()?, + theme: UserConfig::load()?.console.theme, + } + .render()?) + }) + .await, + ) +} + +fn failed(error: impl std::fmt::Debug) -> Response { + eprintln!("thirdshift Console: account operation failed: {error:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read Console accounts; check the Console's stderr.", + ) + .into_response() +} diff --git a/src/console/live.rs b/src/console/live.rs index 618f4376..dd25d3e1 100644 --- a/src/console/live.rs +++ b/src/console/live.rs @@ -1,6 +1,7 @@ //! One reader per Console; slow browsers only need the latest change notice. use std::{convert::Infallible, time::Duration}; +use axum::Extension; use axum::extract::State; use axum::response::{ IntoResponse, Response, Sse, @@ -28,10 +29,35 @@ pub(super) fn observe(initial: String) -> (watch::Receiver, tokio::task::Jo (changes, observer) } -pub(super) async fn events(State(changes): State>) -> Response { +pub(super) async fn events( + State(changes): State>, + Extension(viewer): Extension, +) -> Response { // WatchStream emits immediately, so every reconnect also catches up. - let stream = WatchStream::new(changes) - .map(|_| Ok::<_, Infallible>(Event::default().event("commands").data("changed"))); + let stream = futures_util::stream::unfold( + (WatchStream::new(changes), viewer), + |(mut changes, viewer)| async move { + loop { + let signed_in = viewer.clone(); + match tokio::task::spawn_blocking(move || signed_in.still_allowed()).await { + Ok(Ok(true)) => {} + Ok(Ok(false)) => return None, + error => { + eprintln!("thirdshift Console: can't authorize Inbox stream: {error:?}"); + return None; + } + } + match tokio::time::timeout(Duration::from_millis(250), changes.next()).await { + Ok(Some(_)) => { + let event = Event::default().event("commands").data("changed"); + return Some((Ok::<_, Infallible>(event), (changes, viewer))); + } + Ok(None) => return None, + Err(_) => {} + } + } + }, + ); ( [("x-accel-buffering", "no"), ("cache-control", "no-store")], Sse::new(stream).keep_alive(KeepAlive::default()), diff --git a/src/console/logs.rs b/src/console/logs.rs index 824cab4d..1f66241f 100644 --- a/src/console/logs.rs +++ b/src/console/logs.rs @@ -141,6 +141,7 @@ fn read_lines(log: &Log, offset: u64, budget: usize) -> Result { pub(super) async fn stream( Path((id, offset)): Path<(String, u64)>, Extension(executable): Extension, + Extension(viewer): Extension, headers: HeaderMap, ) -> Response { let offset = headers @@ -155,7 +156,14 @@ pub(super) async fn stream( offset <= std::fs::metadata(log.path)?.len(), "invalid log offset" ); - Ok((std::sync::Arc::new(config), id, offset, false, executable)) + Ok(( + std::sync::Arc::new(config), + id, + offset, + false, + executable, + viewer, + )) }) .await; let state = match checked { @@ -164,7 +172,7 @@ pub(super) async fn stream( }; let events = futures_util::stream::unfold( state, - |(config, id, mut offset, done, executable)| async move { + |(config, id, mut offset, done, executable, viewer)| async move { if done { return None; } @@ -172,8 +180,9 @@ pub(super) async fn stream( let root = config.clone(); let selected = id.clone(); let watched = executable.clone(); + let signed_in = viewer.clone(); let result = tokio::task::spawn_blocking(move || { - if watched.changed() { + if watched.changed() || !signed_in.still_allowed()? { return Ok(None); } appended(&root, &selected, offset).map(Some) @@ -190,7 +199,7 @@ pub(super) async fn stream( .data(serde_json::to_string(&batch).expect("serializable log batch")); return Some(( Ok::<_, Infallible>(event), - (config, id, offset, done, executable), + (config, id, offset, done, executable, viewer), )); } Ok(Ok(Some(_))) => { @@ -201,7 +210,7 @@ pub(super) async fn stream( let event = Event::default() .event("log-error") .data("Can't read log; reopen the reader. Check the Console's stderr."); - return Some((Ok(event), (config, id, offset, true, executable))); + return Some((Ok(event), (config, id, offset, true, executable, viewer))); } } } diff --git a/src/console/nav.html b/src/console/nav.html index f70f4f36..4fa57bd3 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/restart.rs b/src/console/restart.rs index 20eef5ec..f400822c 100644 --- a/src/console/restart.rs +++ b/src/console/restart.rs @@ -1,6 +1,6 @@ //! Watch the installed path, rather than /proc's possibly unlinked executable. use std::path::PathBuf; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use anyhow::{Context, Result}; @@ -25,15 +25,6 @@ impl Executable { Ok(Self { path, identity }) } - pub async fn replaced(&self) { - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - if self.changed() { - return; - } - } - } - pub fn changed(&self) -> bool { identity(&self.path).is_ok_and(|current| current != self.identity) } diff --git a/src/console/sign_in.html b/src/console/sign_in.html new file mode 100644 index 00000000..8361d114 --- /dev/null +++ b/src/console/sign_in.html @@ -0,0 +1,13 @@ +{% extends "console/account_base.html" %} +{% block title %}Sign in{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

Sign in

+ +

Need a password? Ask the machine's owner to run thirdshift console password --email <address>.

+{% endblock %} diff --git a/src/main.rs b/src/main.rs index 5445a19d..eb83ef0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod session; mod setup; mod skills; mod spec_run; +mod terminal; #[cfg(test)] mod test_support; mod update; @@ -77,7 +78,10 @@ usage: thirdshift Run the factory on the iss thirdshift email-test [
] Send a test email through Resend thirdshift setup Choose defaults and write the User config thirdshift status [--all] [--json] List this machine's Commands - thirdshift console --no-sign-in Serve this machine's Inbox on localhost + thirdshift console [--no-sign-in] Serve this machine's Console on localhost + thirdshift console password [--email
] Set a Console password privately + thirdshift console sign-out [--email
] [--json] End an account's sessions + thirdshift console log [--json] Read this machine's Console log thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs @@ -135,6 +139,19 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), + Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), + Ok(Command::ConsoleSignOut { email, json }) => { + return match console::sign_out(email.as_deref(), json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } + Ok(Command::ConsoleLog { json }) => { + return match console::print_log(json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } Ok(Command::Console { no_sign_in }) => { return match console::serve(no_sign_in) { Ok(()) => ExitCode::SUCCESS, diff --git a/src/setup.rs b/src/setup.rs index 78b3b90c..6099e6d0 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -12,6 +12,7 @@ pub(crate) mod questions; +use crate::terminal::EchoOff; use std::io::{IsTerminal, Write}; use std::path::Path; @@ -372,89 +373,7 @@ impl Outside for OnMachine<'_> { /// answers available to the next question. Polling lets recorded signals end /// ordinary and hidden input even when there is no next line. fn terminal_line() -> Result> { - let mut line = Vec::new(); - loop { - interrupt::check()?; - let mut stdin = libc::pollfd { - fd: 0, - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: stdin points to one valid pollfd; polling does not own fd 0. - let ready = unsafe { libc::poll(&mut stdin, 1, 100) }; - interrupt::check()?; - if ready < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error.into()); - } - if ready == 0 { - continue; - } - if stdin.revents & libc::POLLNVAL != 0 { - return Err(std::io::Error::from_raw_os_error(libc::EBADF).into()); - } - let mut byte = 0u8; - // SAFETY: byte is writable for the one byte requested from fd 0. - let read = unsafe { libc::read(0, (&mut byte as *mut u8).cast(), 1) }; - interrupt::check()?; - if read < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error.into()); - } - if read == 0 { - if line.is_empty() { - return Ok(None); - } - break; - } - line.push(byte); - if byte == b'\n' { - break; - } - } - let line = String::from_utf8(line).context("terminal input is not UTF-8")?; - interrupt::check()?; - Ok(Some(line.trim().to_string())) -} - -/// Own the exact saved terminal settings while echo is suppressed. Every -/// return path, including recorded interruption, restores them by dropping. -struct EchoOff { - saved: libc::termios, -} - -impl EchoOff { - fn new() -> Result { - // SAFETY: an all-zero termios is a valid value for tcgetattr to fill. - let mut saved: libc::termios = unsafe { std::mem::zeroed() }; - // SAFETY: fd 0 is open, and `saved` is a valid termios to fill. - if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't read the terminal's settings"); - } - let echo_off = EchoOff { saved }; - let mut hidden = saved; - hidden.c_lflag &= !libc::ECHO; - // SAFETY: fd 0 is open, and `hidden` is a valid termios. - if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't turn the terminal's echo off"); - } - Ok(echo_off) - } -} - -impl Drop for EchoOff { - fn drop(&mut self) { - // SAFETY: fd 0 is open, and `saved` is the termios read from it. - unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; - } + Ok(crate::terminal::line()?.map(|line| line.trim().to_owned())) } #[cfg(test)] @@ -1132,6 +1051,7 @@ silent_minutes = 10 [console] port = 8711 +allowed_emails = [] other_consoles = [] theme = \"focus-auto\" diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 00000000..96ac63dd --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,89 @@ +//! Shared terminal input for Setup and Console passwords. +use crate::interrupt; +use anyhow::{Context, Result}; + +/// Read without read-ahead, so pasted answers remain for the next question. +/// Polling lets recorded signals end input even when no next line arrives. +pub fn line() -> Result> { + let mut line = Vec::new(); + loop { + interrupt::check()?; + let mut stdin = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: stdin points to one valid pollfd; polling does not own fd 0. + let ready = unsafe { libc::poll(&mut stdin, 1, 100) }; + interrupt::check()?; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + if ready == 0 { + continue; + } + if stdin.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::from_raw_os_error(libc::EBADF).into()); + } + let mut byte = 0u8; + // SAFETY: byte is writable for the one byte requested from fd 0. + let read = unsafe { libc::read(0, (&mut byte as *mut u8).cast(), 1) }; + interrupt::check()?; + if read < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + if read == 0 { + if line.is_empty() { + return Ok(None); + } + break; + } + line.push(byte); + if byte == b'\n' { + break; + } + } + let line = String::from_utf8(line).context("terminal input is not UTF-8")?; + interrupt::check()?; + Ok(Some(line.trim_end_matches(['\r', '\n']).to_owned())) +} + +/// Restore the exact saved terminal settings on every return path. +pub struct EchoOff { + saved: libc::termios, +} + +impl EchoOff { + pub fn new() -> Result { + // SAFETY: tcgetattr fills this termios from the open stdin descriptor. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't read the terminal's settings"); + } + let echo_off = Self { saved }; + let mut hidden = saved; + hidden.c_lflag &= !libc::ECHO; + // SAFETY: fd 0 is open, and hidden is a valid termios. + if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't turn the terminal's echo off"); + } + Ok(echo_off) + } +} + +impl Drop for EchoOff { + fn drop(&mut self) { + // SAFETY: fd 0 is open, and saved was read from it. + unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; + } +} diff --git a/tests/console.rs b/tests/console.rs index d194e76b..308d0341 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -11,6 +11,10 @@ use std::time::Duration; use serde_json::{Value, json}; +#[cfg(unix)] +#[path = "console/accounts.rs"] +mod accounts; + struct Console { child: Child, address: String, @@ -35,8 +39,17 @@ impl Drop for HeldFixture { impl Console { fn start(binary: &Path, home: &Path) -> Self { + Self::start_mode(binary, home, true) + } + + fn start_mode(binary: &Path, home: &Path, no_sign_in: bool) -> Self { + let args: &[&str] = if no_sign_in { + &["console", "--no-sign-in"] + } else { + &["console"] + }; let mut child = Command::new(binary) - .args(["console", "--no-sign-in"]) + .args(args) .env("HOME", home) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs new file mode 100644 index 00000000..39f4b5c5 --- /dev/null +++ b/tests/console/accounts.rs @@ -0,0 +1,572 @@ +use super::*; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::process::CommandExt; +use std::process::{ExitStatus, Output}; +use std::time::Instant; + +const EMAIL: &str = "owner@example.test"; +const PASSWORD: &str = "correct horse battery staple"; + +fn configured() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + allow(home.path(), true); + home +} + +fn allow(home: &Path, allowed: bool) { + fs::write( + home.join(".thirdshift/config.toml"), + format!( + "[console]\nport = 0\nallowed_emails = [{}]\n", + if allowed { + "\"owner@example.test\"" + } else { + "" + } + ), + ) + .unwrap(); +} + +fn cli(home: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(args) + .env("HOME", home) + .output() + .unwrap() +} + +fn client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap() +} + +fn get(console: &Console, path: &str, cookie: &str) -> reqwest::blocking::Response { + client() + .get(format!("{}{path}", console.address)) + .header("cookie", cookie) + .send() + .unwrap() +} + +fn csrf(html: &str) -> String { + html.split_once("name=\"csrf\" value=\"") + .expect("CSRF field") + .1 + .split('"') + .next() + .unwrap() + .into() +} + +fn cookie(response: &reqwest::blocking::Response) -> String { + response.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .into() +} + +fn sign_in(console: &Console, email: &str, password: &str) -> reqwest::blocking::Response { + let form = get(console, "/sign-in", ""); + assert_eq!(form.status(), 200); + let cookie = cookie(&form); + let token = csrf(&form.text().unwrap()); + client() + .post(format!("{}/sign-in", console.address)) + .header("cookie", cookie) + .form(&[("email", email), ("password", password), ("csrf", &token)]) + .send() + .unwrap() +} + +#[test] +fn account_pages_and_sign_in_follow_the_configured_theme() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + for theme in ["focus-dark", "focus-light"] { + fs::write( + home.path().join(".thirdshift/config.toml"), + format!("[console]\nport = 0\nallowed_emails = [\"{EMAIL}\"]\ntheme = \"{theme}\"\n"), + ) + .unwrap(); + let attribute = format!("data-theme=\"{theme}\""); + assert!( + get(&console, "/sign-in", "") + .text() + .unwrap() + .contains(&attribute) + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + for path in ["/", "/lanes", "/stages", "/account", "/console-log"] { + let page = get(&console, path, &session).text().unwrap(); + assert!(page.contains(&attribute), "{path} ignores {theme}"); + } + } +} + +#[test] +fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() { + let home = configured(); + let (status, _) = password(home.path(), None, PASSWORD, PASSWORD); + assert!(status.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + for path in [ + "/", + "/?page=lanes", + "/?page=stages", + "/?page=waterfall", + "/?page=2", + "/?page=3", + "/?page=4", + "/?page=history", + "/?page=security", + "/?page=logs", + "/history", + "/security", + "/lanes", + "/stages", + "/waterfall", + "/peek?command=widgets%237", + "/log?command=widgets%237", + "/logs", + "/logs/command/id", + "/logs/id", + "/logs/id/stream/0", + "/events", + "/console-log", + "/account", + ] { + assert_eq!( + get(&console, path, "").status(), + 303, + "{path} leaked without a session" + ); + } + assert_eq!(get(&console, "/assets/console.css", "").status(), 200); + assert_eq!( + client() + .post(format!("{}/sign-in", console.address)) + .form(&[("email", EMAIL), ("password", PASSWORD)]) + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!(sign_in(&console, EMAIL, "wrong").status(), 401); + assert_eq!( + sign_in(&console, "outsider@example.test", PASSWORD).status(), + 401 + ); + let response = sign_in(&console, EMAIL, PASSWORD); + assert_eq!(response.status(), 303); + let attributes = response.headers()["set-cookie"].to_str().unwrap(); + for attribute in [ + "HttpOnly", + "Secure", + "SameSite=Strict", + "Max-Age=2592000", + "Path=/", + ] { + assert!(attributes.contains(attribute), "{attributes}"); + } + let session = cookie(&response); + let accounts = + fs::read_to_string(home.path().join(".thirdshift/console-accounts.json")).unwrap(); + assert!(accounts.contains("$argon2id$")); + assert!(!accounts.contains(PASSWORD)); + assert!(!accounts.contains(session.split_once('=').unwrap().1)); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(home.path().join(".thirdshift/console-accounts.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let before = cli(home.path(), &["console", "log", "--json"]).stdout; + let page = get(&console, "/", &session); + assert_eq!(page.status(), 200); + for path in [ + "/?page=history", + "/?page=security", + "/?page=logs", + "/history", + "/security", + ] { + assert_eq!( + get(&console, path, &session).status(), + 200, + "signed-in access failed: {path}" + ); + } + assert!( + page.headers()["set-cookie"] + .to_str() + .unwrap() + .contains("Max-Age=2592000") + ); + let account = get(&console, "/account", &session).text().unwrap(); + let token = csrf(&account); + let log_page = get(&console, "/console-log", &session).text().unwrap(); + assert!(log_page.contains("thirdshift console log")); + let events: Vec = serde_json::from_slice(&before).unwrap(); + for event in events { + assert!(log_page.contains(event["event"].as_str().unwrap())); + } + assert_eq!( + cli(home.path(), &["console", "log", "--json"]).stdout, + before, + "page views were logged" + ); + for invalid in [None, Some("incorrect")] { + let mut request = client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &session); + if let Some(token) = invalid { + request = request.form(&[("csrf", token)]); + } + assert_eq!(request.send().unwrap().status(), 403); + assert_eq!(get(&console, "/", &session).status(), 200); + } + let response = client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &session) + .form(&[("csrf", token)]) + .send() + .unwrap(); + assert_eq!(response.status(), 303); + assert!( + response.headers()["set-cookie"] + .to_str() + .unwrap() + .contains("Max-Age=0") + ); + assert_eq!(get(&console, "/", &session).status(), 303); +} + +#[test] +fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start_mode(binary, home.path(), false); + let first = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let second = cookie(&sign_in(&console, EMAIL, PASSWORD)); + // A browser sign-out ends that browser, leaving the other one usable. + let token = csrf(&get(&console, "/account", &first).text().unwrap()); + assert_eq!( + client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &first) + .form(&[("csrf", token)]) + .send() + .unwrap() + .status(), + 303 + ); + assert_eq!(get(&console, "/", &first).status(), 303); + assert_eq!(get(&console, "/", &second).status(), 200); + let signed_out = cli( + home.path(), + &["console", "sign-out", "--email", EMAIL, "--json"], + ); + assert!(signed_out.status.success()); + assert_eq!( + serde_json::from_slice::(&signed_out.stdout).unwrap(), + json!({"email": EMAIL, "signed_out": true}) + ); + assert_eq!(get(&console, "/", &second).status(), 303); + let third = cookie(&sign_in(&console, EMAIL, PASSWORD)); + assert!( + password(home.path(), Some(EMAIL), "new password", "new password") + .0 + .success() + ); + assert_eq!(get(&console, "/", &third).status(), 303); + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 401); + let fourth = cookie(&sign_in(&console, EMAIL, "new password")); + drop(console); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!( + get(&console, "/", &fourth).status(), + 200, + "sessions survive server restarts" + ); + allow(home.path(), false); + assert_eq!( + get(&console, "/", &fourth).status(), + 303, + "removal is effective on the next request" + ); + assert_eq!(sign_in(&console, EMAIL, "new password").status(), 401); + allow(home.path(), true); + assert_eq!( + get(&console, "/", &fourth).status(), + 303, + "re-adding must not revive a session" + ); + let fifth = cookie(&sign_in(&console, EMAIL, "new password")); + // Removal must also be observed while no HTTP requests arrive. + let before = fs::read_to_string(home.path().join(".thirdshift/console.log")) + .unwrap() + .len(); + allow(home.path(), false); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let text = fs::read_to_string(home.path().join(".thirdshift/console.log")).unwrap(); + if text.len() > before && text[before..].contains("blocked-address") { + break; + } + assert!( + Instant::now() < deadline, + "idle server never revoked the removed account" + ); + std::thread::sleep(Duration::from_millis(25)); + } + allow(home.path(), true); + assert_eq!(get(&console, "/", &fifth).status(), 303); + assert_eq!( + sign_in(&console, EMAIL, "new password").status(), + 303, + "account password is retained" + ); +} + +#[test] +fn http_slowdown_survives_restarts_and_allowed_accounts_still_need_a_password() { + let home = configured(); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!( + sign_in(&console, EMAIL, PASSWORD).status(), + 401, + "allowlisting alone grants no access" + ); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + for _ in 0..5 { + assert_eq!(sign_in(&console, EMAIL, "wrong").status(), 401); + } + let response = sign_in(&console, EMAIL, PASSWORD); + assert_eq!(response.status(), 429); + assert_eq!(response.headers()["retry-after"], "60"); + drop(console); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 429); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_open_log_stream_closes_when_cli_sign_out_revokes_its_session() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + let mut live = record(home.path(), 7, None, 0); + let path = folder.join("7-fixture.log"); + fs::write(&path, "first line\n").unwrap(); + live["process_id"] = json!(std::process::id()); + let stat = fs::read_to_string("/proc/self/stat").unwrap(); + let ticks = stat + .rsplit_once(") ") + .unwrap() + .1 + .split_whitespace() + .nth(19) + .unwrap(); + let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap(); + live["process_start"] = json!(format!("{}:{ticks}", boot.trim())); + write_record(home.path(), 7, &live); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let inventory = get(&console, "/logs", &session).text().unwrap(); + let link = inventory + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(url, _)| url.starts_with("/logs/")) + .unwrap() + .0; + let mut response = get(&console, &format!("{link}/stream/0"), &session); + assert_eq!(response.status(), 200); + let mut buffer = [0; 4096]; + let first = response.read(&mut buffer).unwrap(); + assert!(String::from_utf8_lossy(&buffer[..first]).contains("first line")); + assert!( + String::from_utf8_lossy(&buffer[..first]).contains("\"done\":false"), + "fixture must be a live stream" + ); + assert!(cli(home.path(), &["console", "sign-out"]).status.success()); + let mut rest = String::new(); + response.read_to_string(&mut rest).unwrap(); + assert!(!rest.contains("first line")); + assert_eq!( + get(&console, &format!("{link}/stream/0"), &session).status(), + 303 + ); +} + +#[test] +fn an_open_inbox_stream_closes_when_its_session_is_revoked() { + for revoke in ["sign-out", "password", "allowlist"] { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let response = get(&console, "/events", &session); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let mut stream = BufReader::new(response); + let mut first = String::new(); + loop { + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0); + if line.trim().is_empty() { + break; + } + first.push_str(&line); + } + assert!(first.contains("event: commands")); + match revoke { + "sign-out" => assert!(cli(home.path(), &["console", "sign-out"]).status.success()), + "password" => { + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + } + "allowlist" => allow(home.path(), false), + _ => unreachable!(), + } + let mut rest = String::new(); + stream.read_to_string(&mut rest).unwrap(); + assert_eq!(get(&console, "/events", &session).status(), 303, "{revoke}"); + } +} + +// Exercise the real terminal interface, including controlling-TTY input and echo. +fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (ExitStatus, String) { + let (mut master, mut slave) = (-1, -1); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + let mut master = unsafe { fs::File::from_raw_fd(master) }; + let slave = unsafe { fs::File::from_raw_fd(slave) }; + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console", "password"]) + .env("HOME", home) + .stdin(slave.try_clone().unwrap()) + .stdout(slave.try_clone().unwrap()) + .stderr(slave); + if let Some(email) = email { + command.args(["--email", email]); + } + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + let mut transcript = String::new(); + let mut prompts = 0; + loop { + if Instant::now() > deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("password command timed out: {transcript}"); + } + let mut poll = libc::pollfd { + fd: master.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + if unsafe { libc::poll(&mut poll, 1, 100) } > 0 { + let mut buffer = [0; 1024]; + if let Ok(length) = master.read(&mut buffer) { + transcript.push_str(&String::from_utf8_lossy(&buffer[..length])); + if prompts == 0 && transcript.contains("Password: ") { + writeln!(master, "{first}").unwrap(); + prompts = 1; + } + if prompts == 1 && transcript.contains("Confirm password: ") { + writeln!(master, "{second}").unwrap(); + prompts = 2; + } + } + } + if let Some(status) = child.try_wait().unwrap() { + return (status, transcript); + } + } +} + +#[test] +fn console_password_prompts_privately_and_console_log_reports_account_events() { + let home = configured(); + let (status, transcript) = password(home.path(), None, PASSWORD, PASSWORD); + assert!(status.success(), "{transcript}"); + assert!(transcript.contains("Confirm password: "), "{transcript}"); + assert!( + !transcript.contains(PASSWORD), + "terminal echoed the password" + ); + let log = cli(home.path(), &["console", "log", "--json"]); + assert!( + log.status.success(), + "{}", + String::from_utf8_lossy(&log.stderr) + ); + let events: Vec = serde_json::from_slice(&log.stdout).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["email"], EMAIL); + assert_eq!(events[0]["event"], "password-set"); + assert!(cli(home.path(), &["console", "sign-out"]).status.success()); + let text = cli(home.path(), &["console", "log"]); + assert!(String::from_utf8_lossy(&text.stdout).contains("sign-out")); + let (status, _) = password( + home.path(), + Some("outsider@example.test"), + PASSWORD, + PASSWORD, + ); + assert!(!status.success()); + let (status, _) = password(home.path(), Some(EMAIL), "different", "mismatch"); + assert!(!status.success()); +}