From 16828d0e3fb354bf34073521864c12e1b0e8a192 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:00:29 +0000 Subject: [PATCH 1/5] Add Console accounts, sign-in and session controls (#821) --- Cargo.lock | 167 +++++++++++- Cargo.toml | 9 +- console/README.md | 33 ++- console/tests/accounts.test.cjs | 66 +++++ docs/code-navigation.md | 1 + src/args.rs | 24 +- src/config.rs | 2 + src/config/console.rs | 8 + src/console.rs | 133 +++++++-- src/console/account.html | 7 + src/console/account_commands.rs | 62 +++++ src/console/account_log.html | 7 + src/console/accounts.rs | 252 +++++++++++++++++ src/console/accounts/tests.rs | 140 ++++++++++ src/console/assets/console.css | 4 + src/console/auth.rs | 298 ++++++++++++++++++++ src/console/inbox.html | 2 +- src/console/logs.rs | 19 +- src/console/restart.rs | 11 +- src/console/sign_in.html | 11 + src/main.rs | 14 +- src/setup.rs | 85 +----- src/terminal.rs | 89 ++++++ tests/console.rs | 15 +- tests/console/accounts.rs | 469 ++++++++++++++++++++++++++++++++ 25 files changed, 1792 insertions(+), 136 deletions(-) create mode 100644 console/tests/accounts.test.cjs create mode 100644 src/console/account.html create mode 100644 src/console/account_commands.rs create mode 100644 src/console/account_log.html create mode 100644 src/console/accounts.rs create mode 100644 src/console/accounts/tests.rs create mode 100644 src/console/auth.rs create mode 100644 src/console/sign_in.html create mode 100644 src/terminal.rs create mode 100644 tests/console/accounts.rs diff --git a/Cargo.lock b/Cargo.lock index 36097d92..da3d1f6d 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", "toml", @@ -1823,6 +1968,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" @@ -1859,6 +2010,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 b3303421..62e09e49 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 08d6748d..0185ba1d 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
]` 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 first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs new file mode 100644 index 00000000..5b2a440f --- /dev/null +++ b/console/tests/accounts.test.cjs @@ -0,0 +1,66 @@ +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"]\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 } }); + 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(() => 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(); + await page.getByRole("link", { name: "Account", exact: true }).click(); + 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/docs/code-navigation.md b/docs/code-navigation.md index 1f496f7e..cd9c6613 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -24,6 +24,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | 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..98a65196 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,6 +25,11 @@ pub enum Command { Console { no_sign_in: bool, }, + ConsolePassword(Option), + ConsoleSignOut(Option), + ConsoleLog { + json: bool, + }, Status { json: bool, all: bool, @@ -251,7 +256,24 @@ pub fn parse(args: &[String]) -> Result { 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] if action == "sign-out" => Ok(Command::ConsoleSignOut(None)), + [action, flag, email] if flag == "--email" && !email.starts_with('-') => { + match action.as_str() { + "password" => Ok(Command::ConsolePassword(Some(email.clone()))), + "sign-out" => Ok(Command::ConsoleSignOut(Some(email.clone()))), + _ => { + bail!("usage: thirdshift console password|sign-out [--email
]") + } + } + } + [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|sign-out [--email
] | log [--json]" + ), }; } Some("stop") => { diff --git a/src/config.rs b/src/config.rs index c01c308b..48d951f9 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 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 fafc46bd..f3919374 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 logs; mod restart; @@ -9,10 +13,11 @@ use std::net::Ipv4Addr; use anyhow::{Context, Result, ensure}; use askama::Template; use axum::Router; -use axum::extract::Path; -use axum::http::{StatusCode, header}; +use axum::extract::{DefaultBodyLimit, Path}; +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; @@ -25,37 +30,73 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); struct PageRoute { path: &'static str, cli: &'static str, + method: Method, } const PAGES: &[PageRoute] = &[ PageRoute { path: "/", cli: "thirdshift status", + method: Method::GET, }, PageRoute { path: "/logs", cli: "thirdshift logs --json", + method: Method::GET, }, PageRoute { path: "/logs/command/{id}", cli: "thirdshift logs --command id --json", + method: Method::GET, }, PageRoute { path: "/logs/{id}", cli: "thirdshift logs --file id", + method: Method::GET, }, PageRoute { path: "/logs/{id}/stream/{offset}", cli: "thirdshift logs --file id --follow", + method: Method::GET, + }, + PageRoute { + path: "/console-log", + cli: "thirdshift console log", + method: Method::GET, + }, + PageRoute { + path: "/account", + cli: "thirdshift console sign-out", + method: Method::GET, + }, + PageRoute { + path: "/sign-out", + cli: "thirdshift console sign-out", + method: Method::POST, + }, + // 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, + }, + PageRoute { + path: "/sign-in", + cli: "thirdshift console password", + method: Method::POST, }, ]; 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() @@ -69,9 +110,27 @@ pub fn serve(no_sign_in: bool) -> Result<()> { let watched = executable.clone(); axum::serve( listener, - router().layer(axum::Extension(executable.clone())), + router(no_sign_in).layer(axum::Extension(executable.clone())), ) - .with_graceful_shutdown(async move { watched.replaced().await }) + .with_graceful_shutdown(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if watched.changed() { + return; + } + // 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:?}") + } + } + } + } + }) .await .context("Console server failed")?; Ok::<_, anyhow::Error>(()) @@ -81,22 +140,32 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } -fn router() -> Router { +fn router(no_sign_in: bool) -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli; - let handler = match page.path { - "/" => get(move || inbox(cli)), - "/logs" => get(logs::index), - "/logs/command/{id}" => get(logs::command), - "/logs/{id}" => get(logs::reader), - "/logs/{id}/stream/{offset}" => get(logs::stream), + let handler = match (page.path, page.method.clone()) { + ("/", Method::GET) => get(move || inbox(cli)), + ("/logs", Method::GET) => get(logs::index), + ("/logs/command/{id}", Method::GET) => get(logs::command), + ("/logs/{id}", Method::GET) => get(logs::reader), + ("/logs/{id}/stream/{offset}", Method::GET) => get(logs::stream), + ("/console-log", Method::GET) => get(auth::log), + ("/account", Method::GET) => get(auth::account), + ("/sign-out", Method::POST) => post(auth::sign_out), + ("/sign-in", Method::GET) => get(auth::sign_in_page), + ("/sign-in", Method::POST) => post(auth::sign_in), _ => unreachable!("unknown Console page"), }; router = router.route(page.path, handler); } router = router.route("/assets/{*path}", get(asset)); + let access = auth::Access::new(no_sign_in); router + .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)] @@ -147,14 +216,14 @@ async fn inbox(cli: &'static str) -> Response { fn page(result: std::result::Result, tokio::task::JoinError>) -> Response { match result { - Ok(Ok(html)) => ( - [(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(), + Ok(Ok(html)) => Html(html).into_response(), error => { eprintln!("thirdshift Console: can't read page: {error:?}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Can't read this page; check the Console's stderr.").into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read this page; check the Console's stderr.", + ) + .into_response() } } } @@ -214,7 +283,23 @@ mod tests { // Measure the GET registration footprint through axum's public layer // interface, so this does not depend on its implicit HEAD/fallback count. let get_methods = registered_methods(Router::new().route("/known", get(|| async {}))); - let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route + let post_methods = + registered_methods(Router::new().route("/known", axum::routing::post(|| async {}))); + // 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, @@ -243,14 +328,14 @@ mod tests { #[test] fn every_console_route_has_a_cli_equivalent() { - assert_cli_parity(super::router()); + assert_cli_parity(super::router(false)); } #[test] #[should_panic(expected = "registered routes must all have a CLI pair")] fn parity_rejects_a_route_registered_without_a_cli_pair() { assert_cli_parity( - super::router().route("/unpaired", get(|| async { "page with no CLI pair" })), + super::router(false).route("/unpaired", get(|| async { "page with no CLI pair" })), ); } } diff --git a/src/console/account.html b/src/console/account.html new file mode 100644 index 00000000..72e9e2b9 --- /dev/null +++ b/src/console/account.html @@ -0,0 +1,7 @@ + +Account · thirdshift +

thirdshift / Console

+

Account

{{ email }}

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

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

+
diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs new file mode 100644 index 00000000..dc339ea9 --- /dev/null +++ b/src/console/account_commands.rs @@ -0,0 +1,62 @@ +//! 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>) -> Result { + let email = email(given)?; + Accounts::load()?.sign_out(&email, None)?; + Ok(format!("Signed out {email}.")) +} + +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..a80794f3 --- /dev/null +++ b/src/console/account_log.html @@ -0,0 +1,7 @@ + +Console log · thirdshift +

thirdshift / Console

+

Console log

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

No account events.

{% endif %} +
diff --git a/src/console/accounts.rs b/src/console/accounts.rs new file mode 100644 index 00000000..dde94a9c --- /dev/null +++ b/src/console/accounts.rs @@ -0,0 +1,252 @@ +//! 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)] +pub(super) struct Event { + pub time: i64, + pub email: String, + pub event: String, +} + +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, "blocked-address")?; + } + 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, "password-set") + } + + pub fn sign_in(&mut self, email: &str, password: &str) -> Result { + if !self.allowed.iter().any(|allowed| allowed == email) { + self.record(email, "blocked-address")?; + return Ok(SignIn::Refused); + } + if self + .accounts + .get(email) + .is_some_and(|account| self.now < account.next_attempt) + { + self.record(email, "failed-sign-in")?; + 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, "failed-sign-in")?; + 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, "sign-in")?; + 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, "sign-out") + } + + 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: &str) -> 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: event.into(), + }, + )?; + 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 730e2af8..1c0c892f 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -59,6 +59,10 @@ aside { font-size: 12px; } #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; } +form { max-width: 420px; margin: 20px 0; } +form label { display: block; margin: 12px 0; } +form input:not([type="hidden"]), 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); } +form button { cursor: pointer; font-weight: var(--head-weight); } @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/auth.rs b/src/console/auth.rs new file mode 100644 index 00000000..b7db1161 --- /dev/null +++ b/src/console/auth.rs @@ -0,0 +1,298 @@ +//! 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}; + +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, +} + +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 mut response = match (SignInPage { csrf: csrf(&token) }).render() { + Ok(html) => super::page(Ok(Ok(html))), + Err(error) => return failed(error), + }; + 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, +} + +pub(super) async fn account(Extension(viewer): Extension) -> Response { + match (AccountPage { + email: viewer.email, + csrf: viewer.csrf, + signed_in: viewer.token.is_some(), + }) + .render() + { + Ok(html) => super::page(Ok(Ok(html))), + Err(error) => failed(error), + } +} + +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, +} + +pub(super) async fn log() -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(LogPage { + events: Accounts::load()?.events()?, + } + .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/inbox.html b/src/console/inbox.html index f7d24861..e7239627 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -17,7 +17,7 @@ {% if !consoles.is_empty() %}{% endif %}
- +

Inbox

{{ cli }}
{% match inbox.warning %}{% when Some with (warning) %}

Warning: {{ warning }}

{% when None %}{% endmatch %} {% for section in inbox.sections %} 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/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..5899a17c --- /dev/null +++ b/src/console/sign_in.html @@ -0,0 +1,11 @@ + +Sign in · thirdshift +

thirdshift / Console

Sign in

+
+ + + + +
+

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

+
diff --git a/src/main.rs b/src/main.rs index 5445a19d..f28a6582 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
] 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,14 @@ 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)) => return outcome(console::sign_out(email.as_deref())), + 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..89b08fd8 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)] 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 75445600..54832d14 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -9,6 +9,10 @@ use std::time::Duration; use serde_json::{Value, json}; +#[cfg(unix)] +#[path = "console/accounts.rs"] +mod accounts; + struct Console { child: Child, address: String, @@ -18,8 +22,17 @@ struct Console { 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..d88eecb2 --- /dev/null +++ b/tests/console/accounts.rs @@ -0,0 +1,469 @@ +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 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 [ + "/", + "/logs", + "/logs/command/id", + "/logs/id", + "/logs/id/stream/0", + "/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); + 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); + assert!( + cli(home.path(), &["console", "sign-out", "--email", EMAIL]) + .status + .success() + ); + 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 + ); +} + +// 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(), + std::ptr::null(), + ) + }, + 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, 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()); +} From 23cb5a664ab165da59a113cfd149a33b8e66dbfb Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:04:30 +0000 Subject: [PATCH 2/5] Address Console Standards review findings --- console/README.md | 2 +- src/args.rs | 43 ++++++++++++++++++++++++--------- src/console/account.html | 8 +++--- src/console/account_base.html | 16 ++++++++++++ src/console/account_commands.rs | 12 +++++++-- src/console/account_log.html | 8 +++--- src/console/accounts.rs | 42 ++++++++++++++++++++++++-------- src/console/sign_in.html | 10 +++++--- src/main.rs | 9 +++++-- tests/console/accounts.rs | 12 ++++++--- 10 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/console/account_base.html diff --git a/console/README.md b/console/README.md index 0185ba1d..1b25a2f3 100644 --- a/console/README.md +++ b/console/README.md @@ -43,7 +43,7 @@ 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
]` ends every session for that address; setting a new password +[--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. diff --git a/src/args.rs b/src/args.rs index 98a65196..bcb15181 100644 --- a/src/args.rs +++ b/src/args.rs @@ -26,7 +26,10 @@ pub enum Command { no_sign_in: bool, }, ConsolePassword(Option), - ConsoleSignOut(Option), + ConsoleSignOut { + email: Option, + json: bool, + }, ConsoleLog { json: bool, }, @@ -253,26 +256,44 @@ 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 }), [action] if action == "password" => Ok(Command::ConsolePassword(None)), - [action] if action == "sign-out" => Ok(Command::ConsoleSignOut(None)), - [action, flag, email] if flag == "--email" && !email.starts_with('-') => { - match action.as_str() { - "password" => Ok(Command::ConsolePassword(Some(email.clone()))), - "sign-out" => Ok(Command::ConsoleSignOut(Some(email.clone()))), - _ => { - bail!("usage: thirdshift console password|sign-out [--email
]") - } - } + [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|sign-out [--email
] | log [--json]" + "usage: thirdshift console [--no-sign-in] | password [--email
] | sign-out [--email
] [--json] | log [--json]" ), }; } diff --git a/src/console/account.html b/src/console/account.html index 72e9e2b9..0c47fb69 100644 --- a/src/console/account.html +++ b/src/console/account.html @@ -1,7 +1,7 @@ - -Account · thirdshift -

thirdshift / Console

+{% 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..c7e6ec29 --- /dev/null +++ b/src/console/account_base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}{% endblock %} · thirdshift + + + + +

thirdshift / Console

+ {% block navigation %}{% endblock %} +
+
{% block content %}{% endblock %}
+ + diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs index dc339ea9..befa8aff 100644 --- a/src/console/account_commands.rs +++ b/src/console/account_commands.rs @@ -43,10 +43,18 @@ fn prompt(prompt: &str) -> Result { Ok(answer) } -pub fn sign_out(given: Option<&str>) -> Result { +pub fn sign_out(given: Option<&str>, json: bool) -> Result<()> { let email = email(given)?; Accounts::load()?.sign_out(&email, None)?; - Ok(format!("Signed out {email}.")) + if json { + println!( + "{}", + serde_json::json!({ "email": email, "signed_out": true }) + ); + } else { + println!("Signed out {email}."); + } + Ok(()) } pub fn print_log(json: bool) -> Result<()> { diff --git a/src/console/account_log.html b/src/console/account_log.html index a80794f3..6a8caf9e 100644 --- a/src/console/account_log.html +++ b/src/console/account_log.html @@ -1,7 +1,7 @@ - -Console log · thirdshift -

thirdshift / Console

+{% 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 index dde94a9c..489c731f 100644 --- a/src/console/accounts.rs +++ b/src/console/accounts.rs @@ -24,11 +24,33 @@ struct Account { 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: String, + pub event: EventKind, } pub(super) enum SignIn { @@ -93,7 +115,7 @@ impl Accounts { store.save()?; } for email in blocked { - store.record(&email, "blocked-address")?; + store.record(&email, EventKind::BlockedAddress)?; } Ok(store) } @@ -117,12 +139,12 @@ impl Accounts { }, ); self.save()?; - self.record(email, "password-set") + 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, "blocked-address")?; + self.record(email, EventKind::BlockedAddress)?; return Ok(SignIn::Refused); } if self @@ -130,7 +152,7 @@ impl Accounts { .get(email) .is_some_and(|account| self.now < account.next_attempt) { - self.record(email, "failed-sign-in")?; + self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Slowdown); } let valid = self.accounts.get(email).is_some_and(|account| { @@ -148,7 +170,7 @@ impl Accounts { } self.save()?; } - self.record(email, "failed-sign-in")?; + self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Refused); } let token = random_token(); @@ -157,7 +179,7 @@ impl Accounts { account.next_attempt = 0; account.sessions.insert(digest(&token), self.now); self.save()?; - self.record(email, "sign-in")?; + self.record(email, EventKind::SignIn)?; Ok(SignIn::SignedIn(token)) } @@ -190,7 +212,7 @@ impl Accounts { } self.save()?; } - self.record(email, "sign-out") + self.record(email, EventKind::SignOut) } pub fn events(&self) -> Result> { @@ -212,14 +234,14 @@ impl Accounts { Ok(()) } - fn record(&self, email: &str, event: &str) -> Result<()> { + 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: event.into(), + event, }, )?; file.write_all(b"\n")?; diff --git a/src/console/sign_in.html b/src/console/sign_in.html index 5899a17c..17d82fda 100644 --- a/src/console/sign_in.html +++ b/src/console/sign_in.html @@ -1,6 +1,8 @@ - -Sign in · thirdshift -

thirdshift / Console

Sign in

+{% extends "console/account_base.html" %} +{% block title %}Sign in{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

Sign in

@@ -8,4 +10,4 @@

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 f28a6582..eb83ef0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,7 @@ usage: thirdshift Run the factory on the iss thirdshift status [--all] [--json] List this machine's Commands 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
] End an account's sessions + 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 @@ -140,7 +140,12 @@ 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)) => return outcome(console::sign_out(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, diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index d88eecb2..742693db 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -224,10 +224,14 @@ fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() ); assert_eq!(get(&console, "/", &first).status(), 303); assert_eq!(get(&console, "/", &second).status(), 200); - assert!( - cli(home.path(), &["console", "sign-out", "--email", EMAIL]) - .status - .success() + 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)); From 8435a71789c2f897c0e38d5a2783f8c5d7fbbde9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:09:05 +0000 Subject: [PATCH 3/5] Complete Console config defaults and Setup fixture --- src/config.rs | 2 +- src/setup.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 48d951f9..d4818803 100644 --- a/src/config.rs +++ b/src/config.rs @@ -791,7 +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 +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 diff --git a/src/setup.rs b/src/setup.rs index 89b08fd8..6099e6d0 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1051,6 +1051,7 @@ silent_minutes = 10 [console] port = 8711 +allowed_emails = [] other_consoles = [] theme = \"focus-auto\" From e043b5214e00f6e08f0a846bb27c01b76ff957d5 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:40:17 +0000 Subject: [PATCH 4/5] Apply Console themes and shared navigation to account pages --- console/tests/accounts.test.cjs | 7 +++-- src/console/account_base.html | 4 +-- src/console/auth.rs | 46 ++++++++++++++++++++++----------- tests/console/accounts.rs | 30 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs index 5b2a440f..be9c3e4f 100644 --- a/console/tests/accounts.test.cjs +++ b/console/tests/accounts.test.cjs @@ -13,7 +13,7 @@ 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"]\n'); + 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({ @@ -39,11 +39,12 @@ after(async () => { }); 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 } }); + 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"); @@ -58,7 +59,9 @@ test("sign-in and sign-out work on a phone without JavaScript using secure cooki 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"); diff --git a/src/console/account_base.html b/src/console/account_base.html index c7e6ec29..90c9c3c3 100644 --- a/src/console/account_base.html +++ b/src/console/account_base.html @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@

thirdshift / Console

- {% block navigation %}{% endblock %} + {% block navigation %}{% include "console/nav.html" %}{% endblock %}
{% block content %}{% endblock %}
diff --git a/src/console/auth.rs b/src/console/auth.rs index b7db1161..8aebbc4e 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -11,6 +11,7 @@ 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"; @@ -164,6 +165,7 @@ pub(super) async fn headers(request: Request, next: Next) -> Response { #[template(path = "console/sign_in.html")] struct SignInPage { csrf: String, + theme: String, } pub(super) async fn sign_in_page( @@ -174,11 +176,20 @@ pub(super) async fn sign_in_page( return StatusCode::NOT_FOUND.into_response(); } let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); - let mut response = match (SignInPage { csrf: csrf(&token) }).render() { - Ok(html) => super::page(Ok(Ok(html))), - Err(error) => return failed(error), - }; - set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + 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 } @@ -237,19 +248,22 @@ struct AccountPage { email: String, csrf: String, signed_in: bool, + theme: String, } pub(super) async fn account(Extension(viewer): Extension) -> Response { - match (AccountPage { - email: viewer.email, - csrf: viewer.csrf, - signed_in: viewer.token.is_some(), - }) - .render() - { - Ok(html) => super::page(Ok(Ok(html))), - Err(error) => failed(error), - } + 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 { @@ -274,6 +288,7 @@ pub(super) async fn sign_out(Extension(viewer): Extension) -> Response { #[template(path = "console/account_log.html")] struct LogPage { events: Vec, + theme: String, } pub(super) async fn log() -> Response { @@ -281,6 +296,7 @@ pub(super) async fn log() -> Response { tokio::task::spawn_blocking(move || -> Result { Ok(LogPage { events: Accounts::load()?.events()?, + theme: UserConfig::load()?.console.theme, } .render()?) }) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index e5cc1237..c89c200e 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -87,6 +87,36 @@ fn sign_in(console: &Console, email: &str, password: &str) -> reqwest::blocking: .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 ["/", "/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(); From ff40cffadc0b3dcab67e58401f2da133e4d5ebe1 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 02:44:51 +0000 Subject: [PATCH 5/5] Fix Console terminal test compilation on macOS The new password helper assumed Linux libc signatures. Pass mutable null pointers to openpty and infer ioctl's request type, matching the existing terminal test support. Validated the exact helper against aarch64-apple-darwin libc: reproduced both E0308 errors before the fix and passed afterward. All 19 Console integration tests, cargo fmt --check, and cargo clippy --test console -- -D warnings pass on Linux. --- tests/console/accounts.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index c89c200e..30757f82 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -427,8 +427,8 @@ fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (Exi &mut master, &mut slave, std::ptr::null_mut(), - std::ptr::null(), - std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), ) }, 0 @@ -447,7 +447,7 @@ fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (Exi } unsafe { command.pre_exec(|| { - if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 { + if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { return Err(std::io::Error::last_os_error()); } Ok(())