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