From 04a86ead95b4282f4a8e0bf29ff962b3a89efff6 Mon Sep 17 00:00:00 2001
From: Miles Ward
Date: Wed, 9 Sep 2026 02:08:47 -0400
Subject: [PATCH] v0.2: covered codes, service marks, and an audit log
Codes are covered until revealed. A reveal is a server-side, per-session
window (60 s, two periods): POST /api/accounts/{id}/reveal opens it and
returns the code, GET /api/codes only ever returns codes for accounts in
that window, and /hide closes it early. The browser therefore never holds
a code it has not asked for by name, which is what makes the log honest.
Settings -> Log is an audit trail in the style of Daito: one line per
event with who, how long ago and from which address. It records what the
server observed - sign-ins and failed attempts (under the name tried),
reveals, accounts added / renamed / removed, delivery and credential
changes, test messages. Entries are JSON lines in audit.jsonl (owner-only,
newest 5,000 kept, compacted at twice that). The address comes from
X-Forwarded-For / X-Real-IP when parseable, else the TCP peer via
ConnectInfo; in Docker bridge networking that peer is the gateway, which
the README and template now say.
The account list shows each service's mark instead of an initial. The
pack (crates/webui/src/icons.js, generated by scripts/fetch-icons.py) is
~175 marks from Simple Icons (CC0) plus Font Awesome Free brands (CC BY
4.0) for Amazon, AWS, Microsoft, LinkedIn, Slack, Xbox, Salesforce and
Yahoo, which Simple Icons no longer carries. Matching is by issuer name,
then shorter word prefixes, then the label's mail domain; unmatched
accounts keep the initial tile. Marks that would vanish against a theme's
tile fall back to ink for that theme only.
The setup and Delivery platform pickers are plain text; the coloured dots
are gone.
Router tests cover the reveal window, session scoping, 401s, and the
forwarded address landing in the log.
Co-Authored-By: Claude Fable 5.1
---
Cargo.lock | 14 +-
Cargo.toml | 2 +-
README.md | 61 +++-
crates/core/src/audit.rs | 427 ++++++++++++++++++++++++
crates/core/src/lib.rs | 2 +
crates/http-api/Cargo.toml | 2 +
crates/http-api/src/app.rs | 247 ++++++++++++--
crates/http-api/src/auth.rs | 227 ++++++++++++-
crates/http-api/src/state.rs | 3 +-
crates/http-api/tests/reveal_and_log.rs | 290 ++++++++++++++++
crates/openmfa/src/main.rs | 17 +-
crates/webui/src/app.css | 52 ++-
crates/webui/src/app.js | 294 +++++++++++++---
crates/webui/src/icons.js | 6 +
crates/webui/src/index.html | 1 +
crates/webui/src/lib.rs | 8 +
deploy/unraid/openmfa.xml | 7 +-
docs/PUBLISHING.md | 14 +-
scripts/build-and-publish-unraid.sh | 8 +-
scripts/fetch-icons.py | 238 +++++++++++++
20 files changed, 1802 insertions(+), 118 deletions(-)
create mode 100644 crates/core/src/audit.rs
create mode 100644 crates/http-api/tests/reveal_and_log.rs
create mode 100644 crates/webui/src/icons.js
create mode 100755 scripts/fetch-icons.py
diff --git a/Cargo.lock b/Cargo.lock
index 6bf3bdd..eadcffb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1210,7 +1210,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openmfa"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"anyhow",
"axum",
@@ -1225,7 +1225,7 @@ dependencies = [
[[package]]
name = "openmfa-core"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"argon2",
"base32",
@@ -1250,7 +1250,7 @@ dependencies = [
[[package]]
name = "openmfa-delivery"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"axum",
"openmfa-core",
@@ -1268,7 +1268,7 @@ dependencies = [
[[package]]
name = "openmfa-http-api"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -1282,7 +1282,9 @@ dependencies = [
"rqrr",
"serde",
"serde_json",
+ "tempfile",
"tokio",
+ "tower",
"tower-http",
"tracing",
"uuid",
@@ -1290,7 +1292,7 @@ dependencies = [
[[package]]
name = "openmfa-render"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"image",
"resvg",
@@ -1302,7 +1304,7 @@ dependencies = [
[[package]]
name = "openmfa-webui"
-version = "0.1.1"
+version = "0.2.0"
[[package]]
name = "parking_lot"
diff --git a/Cargo.toml b/Cargo.toml
index 3e14ce0..3a2fa76 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
-version = "0.1.1"
+version = "0.2.0"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
diff --git a/README.md b/README.md
index 9bd1b46..a318a8e 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,8 @@
The web UI is what you'd expect from Google or Microsoft Authenticator: a
-searchable list of accounts, each with its six digits and a countdown ring.
+searchable list of accounts behind recognisable service marks, each code
+covered until you reveal it, with a countdown ring while it shows.
The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and
Ubisoft in the vault, your chosen channel holds three messages, each one
staying current on its own.
@@ -165,6 +166,9 @@ What the app does hold up its end on:
to keep it off the data volume entirely — then a leaked copy of that volume
is useless on its own.
- Everything under `/api/*` requires a session. There is no public read path.
+- Codes are **covered until revealed**, per session, and every reveal is
+ logged with the caller's address — a screen left unattended shows dots, and
+ a borrowed session leaves a trace.
- The UI is fully offline: no CDN, no remote fonts, no external requests. The
only outbound traffic the app ever makes is to the chat platform you chose.
@@ -216,6 +220,23 @@ of Discord, Slack or Telegram — optional, so you can wire a platform later fro
Settings → Delivery. Switching platforms afterwards keeps the other platforms'
details, so you can move back and forth without re-pasting tokens.
+**Reading a code.** Codes are covered by default — six dots and an eye. Click
+to reveal one: the digits appear with their countdown ring and a copy button,
+stay for a minute (long enough to survive a rollover), then go back under
+cover on their own. A reveal is a request to the server, which is what makes
+the log below trustworthy: the browser never holds a code it hasn't asked for
+by name, so "covered" is not a CSS trick.
+
+**Service marks.** An `otpauth://` link carries no artwork — only the issuer,
+the label and the secret — so OpenMFA ships a pack of about 175 marks
+([Simple Icons](https://simpleicons.org/), plus a few Font Awesome brands for
+the ones Simple Icons no longer carries: Amazon, AWS, Microsoft, LinkedIn,
+Slack, Xbox, Salesforce, Yahoo) and matches on the issuer name — the whole
+name first, then shorter prefixes ("Google Workspace" → Google), then the
+label's mail domain (`me@gmail.com` → Gmail). Unmatched accounts keep the
+initial tile. `scripts/fetch-icons.py` regenerates the pack; add a slug or an
+alias there.
+
**Adding an account** offers three routes:
- **Upload** a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots,
@@ -263,6 +284,22 @@ tile; the *Account* field (usually the username or email) is left alone unless
you change it. A rename reaches the chat within a second or two — the push loop
clears its "already sent" marker so it doesn't wait for the code to roll.
+### The log
+
+**Settings → Log** is an audit trail in the style of Daito: one line per event
+— *admin revealed GitHub · miles@example.com* — with how long ago, who, and
+from which address underneath. It records what the server did, never what the
+browser claims: sign-ins (and failed attempts, under the name that was tried),
+every reveal, accounts added, renamed and removed, delivery and credential
+changes, test messages. Entries live in `audit.jsonl` beside the vault,
+owner-only; the newest 5,000 are kept.
+
+The address is the TCP peer unless a reverse proxy forwards the real one in
+`X-Forwarded-For` or `X-Real-IP`. On Unraid in **bridge** networking the peer
+is Docker's gateway (`172.17.0.1`) for every LAN client — put the app behind
+Nginx Proxy Manager, SWAG or a Cloudflare tunnel, or run it on `host` or a
+custom `br0` network, to see real addresses.
+
---
## Configuration
@@ -294,11 +331,11 @@ but Text is the frugal choice for many accounts.
```
crates/
core/ TOTP, vault, crypto, settings (incl. provider config), admin auth,
- Google Authenticator export parsing — no network
+ the audit log, Google Authenticator export parsing — no network
render/ SVG → WebP tile rendering (resvg + embedded JetBrains Mono)
delivery/ Discord, Slack and Telegram clients behind one Sink; the push engine
- webui/ Embedded HTML/CSS/JS, compiled into the binary
- http-api/ axum router, sessions, QR decoding
+ webui/ Embedded HTML/CSS/JS and the service-icon pack, compiled into the binary
+ http-api/ axum router, sessions and reveal windows, QR decoding, the log API
openmfa/ The binary
```
@@ -316,7 +353,7 @@ The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
JetBrains Mono. The accent is monochrome in the vein of [Daito](https://daito.io):
ink in light mode, near-white in dark, so the only colour on screen is status
-(live, expiring, error) and the three platform dots. Light and dark both ship.
+(live, expiring, error) and the service marks. Light and dark both ship.
### Testing
@@ -327,7 +364,9 @@ that speak each platform's real error conventions — Slack's `ok:false` on HTTP
200, Telegram's `description` strings, Discord's JSON error codes — asserting
that tiles are edited in place rather than re-posted, that deleted tiles are
recovered, that dead credentials are surfaced instead of hammered, and that
-renames refresh without waiting for a rollover.
+renames refresh without waiting for a rollover. The router is exercised end to
+end as well: codes stay covered until revealed, a reveal is scoped to the
+session that asked, and the log carries the forwarded client address.
---
@@ -345,6 +384,11 @@ renames refresh without waiting for a rollover.
## Changelog
+- **v0.2** — Covered codes, service marks, and a log. Codes show as dots until
+ revealed (server-side, per session, for a minute); the account list shows
+ each service's mark instead of an initial, from a bundled pack of ~175;
+ **Settings → Log** records reveals, sign-ins and every change with who and
+ from which address; the setup and delivery pickers are plain text.
- **v0.1.1** — Monochrome UI. The accent is ink rather than blue across the
primary buttons, the floating **Add an account** button, the empty-state
shield, the settings tabs and the focus glow on inputs; the settings tabs
@@ -356,4 +400,7 @@ renames refresh without waiting for a rollover.
## Licence
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
-1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`).
+1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`). The service-icon pack in
+`crates/webui/src/icons.js` is generated from [Simple Icons](https://simpleicons.org/)
+(CC0 1.0) and [Font Awesome Free](https://fontawesome.com/license/free) brand
+icons (CC BY 4.0); the marks themselves remain trademarks of their owners.
diff --git a/crates/core/src/audit.rs b/crates/core/src/audit.rs
new file mode 100644
index 0000000..7f4a268
--- /dev/null
+++ b/crates/core/src/audit.rs
@@ -0,0 +1,427 @@
+//! Append-only audit log: who did what, from where, when.
+//!
+//! Every entry is one JSON line in `audit.jsonl` under the work directory,
+//! written as it happens. The most recent [`KEEP`] entries are also held in
+//! memory, which is what the UI reads; the file is compacted back down to
+//! that many once it has grown to twice the size, so it can't fill a disk.
+//!
+//! The log records what the *server* observed — a code was handed out, a
+//! session was opened, an account was renamed. Nothing is client-reported,
+//! so an entry means the thing actually happened.
+
+use crate::unix_now_millis;
+use parking_lot::Mutex;
+use serde::{Deserialize, Serialize};
+use std::collections::VecDeque;
+use std::fs::{self, OpenOptions};
+use std::io::{BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+pub const FILE_NAME: &str = "audit.jsonl";
+
+/// Entries held in memory and served to the UI. The file is compacted to
+/// this many once it reaches [`COMPACT_AT`] lines.
+pub const KEEP: usize = 5000;
+const COMPACT_AT: usize = KEEP * 2;
+
+/// Longest actor / target / detail string that is stored. Anything longer is
+/// cut, so a hostile client can't bloat the log through a 4 KB username.
+const MAX_FIELD: usize = 200;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuditAction {
+ /// The admin account was created (first run).
+ Setup,
+ Login,
+ LoginFailed,
+ Logout,
+ /// A code was uncovered in the web UI.
+ Reveal,
+ AccountAdded,
+ AccountUpdated,
+ AccountDeleted,
+ SettingsUpdated,
+ TestMessage,
+ CredentialsUpdated,
+}
+
+impl AuditAction {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Setup => "setup",
+ Self::Login => "login",
+ Self::LoginFailed => "login_failed",
+ Self::Logout => "logout",
+ Self::Reveal => "reveal",
+ Self::AccountAdded => "account_added",
+ Self::AccountUpdated => "account_updated",
+ Self::AccountDeleted => "account_deleted",
+ Self::SettingsUpdated => "settings_updated",
+ Self::TestMessage => "test_message",
+ Self::CredentialsUpdated => "credentials_updated",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AuditEntry {
+ /// Monotonic within one log; the cursor for paging.
+ pub id: u64,
+ /// Milliseconds since the Unix epoch.
+ pub ts: u64,
+ /// Username of whoever did it. For a failed sign-in, the name tried.
+ pub actor: String,
+ pub action: AuditAction,
+ /// What it was done to — an account's issuer, usually.
+ #[serde(default)]
+ pub target: String,
+ /// Free text: the account label, what changed, and so on.
+ #[serde(default)]
+ pub detail: String,
+ /// Client address as seen by the server, or as forwarded by a proxy.
+ #[serde(default)]
+ pub ip: String,
+}
+
+struct Inner {
+ entries: VecDeque,
+ next_id: u64,
+ /// Lines currently in the file, so compaction can be decided without
+ /// re-reading it.
+ on_disk: usize,
+}
+
+#[derive(Clone)]
+pub struct AuditLog {
+ path: PathBuf,
+ inner: Arc>,
+}
+
+impl std::fmt::Debug for AuditLog {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AuditLog")
+ .field("path", &self.path)
+ .field("entries", &self.len())
+ .finish_non_exhaustive()
+ }
+}
+
+impl AuditLog {
+ /// Open (or start) the log under `work_dir`.
+ ///
+ /// A damaged line is skipped with a warning rather than taking the whole
+ /// log with it: the audit trail is the one file whose partial loss is
+ /// preferable to a refusal to start.
+ pub fn open(work_dir: &Path) -> Self {
+ let path = work_dir.join(FILE_NAME);
+ let mut entries: VecDeque = VecDeque::new();
+ let mut on_disk = 0usize;
+ let mut bad = 0usize;
+ if let Ok(f) = fs::File::open(&path) {
+ for line in BufReader::new(f).lines().map_while(Result::ok) {
+ if line.trim().is_empty() {
+ continue;
+ }
+ on_disk += 1;
+ match serde_json::from_str::(&line) {
+ Ok(e) => {
+ entries.push_back(e);
+ if entries.len() > KEEP {
+ entries.pop_front();
+ }
+ }
+ Err(_) => bad += 1,
+ }
+ }
+ }
+ if bad > 0 {
+ tracing::warn!(target: "openmfa::audit", skipped = bad, "unreadable audit lines skipped");
+ }
+ let next_id = entries.back().map_or(1, |e| e.id + 1);
+ Self {
+ path,
+ inner: Arc::new(Mutex::new(Inner {
+ entries,
+ next_id,
+ on_disk,
+ })),
+ }
+ }
+
+ /// Record an event. Never fails: a log that can't be written is reported
+ /// to the process log, and the request that caused it still succeeds.
+ pub fn record(
+ &self,
+ actor: &str,
+ action: AuditAction,
+ target: impl Into,
+ detail: impl Into,
+ ip: &str,
+ ) -> AuditEntry {
+ let mut g = self.inner.lock();
+ let entry = AuditEntry {
+ id: g.next_id,
+ ts: unix_now_millis(),
+ actor: clip(actor),
+ action,
+ target: clip(&target.into()),
+ detail: clip(&detail.into()),
+ ip: clip(ip),
+ };
+ g.next_id += 1;
+ g.entries.push_back(entry.clone());
+ if g.entries.len() > KEEP {
+ g.entries.pop_front();
+ }
+ self.append(&mut g, &entry);
+ entry
+ }
+
+ /// Newest first. `before` is an entry id: only entries older than it are
+ /// returned, which is how the UI pages backwards. The flag says whether
+ /// anything older still exists.
+ pub fn recent(&self, limit: usize, before: Option) -> (Vec, bool) {
+ let g = self.inner.lock();
+ let mut out: Vec = g
+ .entries
+ .iter()
+ .rev()
+ .filter(|e| before.is_none_or(|b| e.id < b))
+ .take(limit + 1)
+ .cloned()
+ .collect();
+ let more = out.len() > limit;
+ out.truncate(limit);
+ (out, more)
+ }
+
+ pub fn len(&self) -> usize {
+ self.inner.lock().entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ fn append(&self, g: &mut Inner, entry: &AuditEntry) {
+ if g.on_disk >= COMPACT_AT {
+ self.rewrite(g);
+ return;
+ }
+ let line = match serde_json::to_string(entry) {
+ Ok(l) => l,
+ Err(e) => {
+ tracing::error!(target: "openmfa::audit", "could not serialize audit entry: {e}");
+ return;
+ }
+ };
+ match open_append(&self.path).and_then(|mut f| writeln!(f, "{line}")) {
+ Ok(()) => g.on_disk += 1,
+ Err(e) => {
+ tracing::error!(target: "openmfa::audit", "could not write the audit log: {e}");
+ }
+ }
+ }
+
+ /// Replace the file with the in-memory window, atomically.
+ fn rewrite(&self, g: &mut Inner) {
+ let mut body = String::new();
+ for e in &g.entries {
+ if let Ok(l) = serde_json::to_string(e) {
+ body.push_str(&l);
+ body.push('\n');
+ }
+ }
+ let tmp = self.path.with_extension("jsonl.tmp");
+ let result = open_truncate(&tmp)
+ .and_then(|mut f| f.write_all(body.as_bytes()))
+ .and_then(|()| fs::rename(&tmp, &self.path));
+ match result {
+ Ok(()) => g.on_disk = g.entries.len(),
+ Err(e) => {
+ tracing::error!(target: "openmfa::audit", "could not compact the audit log: {e}");
+ }
+ }
+ }
+}
+
+fn clip(s: &str) -> String {
+ // Control characters would let one entry masquerade as several when the
+ // file is read by eye; keep the text but drop those.
+ let cleaned: String = s.chars().filter(|c| !c.is_control()).collect();
+ if cleaned.chars().count() <= MAX_FIELD {
+ cleaned
+ } else {
+ cleaned.chars().take(MAX_FIELD).collect::() + "…"
+ }
+}
+
+/// Owner-only, like the vault: the log names accounts and addresses.
+fn private_options() -> OpenOptions {
+ let mut opts = OpenOptions::new();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ opts.mode(0o600);
+ }
+ opts
+}
+
+fn open_append(path: &Path) -> std::io::Result {
+ if let Some(parent) = path.parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ private_options().create(true).append(true).open(path)
+}
+
+fn open_truncate(path: &Path) -> std::io::Result {
+ private_options()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn fill(log: &AuditLog, n: usize) {
+ for i in 0..n {
+ log.record(
+ "admin",
+ AuditAction::Reveal,
+ format!("Acct {i}"),
+ "",
+ "10.0.0.1",
+ );
+ }
+ }
+
+ #[test]
+ fn records_newest_first_and_pages_backwards() {
+ let dir = tempdir().unwrap();
+ let log = AuditLog::open(dir.path());
+ fill(&log, 5);
+
+ let (page, more) = log.recent(2, None);
+ assert_eq!(page.len(), 2);
+ assert!(more);
+ assert_eq!(page[0].target, "Acct 4");
+ assert_eq!(page[1].target, "Acct 3");
+
+ let (page2, more2) = log.recent(10, Some(page[1].id));
+ assert_eq!(
+ page2.iter().map(|e| e.target.as_str()).collect::>(),
+ ["Acct 2", "Acct 1", "Acct 0"]
+ );
+ assert!(!more2);
+ }
+
+ #[test]
+ fn survives_a_reopen_and_keeps_ids_monotonic() {
+ let dir = tempdir().unwrap();
+ {
+ let log = AuditLog::open(dir.path());
+ log.record("admin", AuditAction::Login, "", "", "192.168.1.5");
+ log.record(
+ "admin",
+ AuditAction::Reveal,
+ "GitHub",
+ "me@example.com",
+ "192.168.1.5",
+ );
+ }
+ let log = AuditLog::open(dir.path());
+ assert_eq!(log.len(), 2);
+ let e = log.record("admin", AuditAction::Logout, "", "", "192.168.1.5");
+ assert_eq!(e.id, 3, "ids continue across restarts");
+ let (page, _) = log.recent(1, None);
+ assert_eq!(page[0].action, AuditAction::Logout);
+ assert_eq!(page[0].ip, "192.168.1.5");
+ }
+
+ #[test]
+ fn skips_a_damaged_line_instead_of_failing() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join(FILE_NAME);
+ let good = serde_json::to_string(&AuditEntry {
+ id: 1,
+ ts: 1,
+ actor: "admin".into(),
+ action: AuditAction::Setup,
+ target: String::new(),
+ detail: String::new(),
+ ip: "127.0.0.1".into(),
+ })
+ .unwrap();
+ fs::write(&path, format!("{good}\nthis is not json\n\n")).unwrap();
+ let log = AuditLog::open(dir.path());
+ assert_eq!(log.len(), 1);
+ assert_eq!(log.record("a", AuditAction::Login, "", "", "x").id, 2);
+ }
+
+ #[test]
+ fn compacts_the_file_once_it_doubles() {
+ let dir = tempdir().unwrap();
+ let log = AuditLog::open(dir.path());
+ fill(&log, COMPACT_AT + 5);
+ let lines = fs::read_to_string(dir.path().join(FILE_NAME))
+ .unwrap()
+ .lines()
+ .count();
+ assert!(
+ lines <= KEEP + 5,
+ "file should have been compacted, has {lines} lines"
+ );
+ assert_eq!(log.len(), KEEP);
+ // What is on disk is exactly the in-memory window, newest last.
+ let reopened = AuditLog::open(dir.path());
+ assert_eq!(
+ reopened.len(),
+ KEEP,
+ "a reopen keeps the newest KEEP entries"
+ );
+ assert_eq!(
+ reopened.recent(1, None).0[0].target,
+ format!("Acct {}", COMPACT_AT + 4)
+ );
+ }
+
+ #[test]
+ fn clips_long_and_control_laden_fields() {
+ let dir = tempdir().unwrap();
+ let log = AuditLog::open(dir.path());
+ let long = "x".repeat(400);
+ let e = log.record("ad\nmin", AuditAction::Login, long.as_str(), "", "");
+ assert_eq!(e.actor, "admin");
+ assert_eq!(e.target.chars().count(), MAX_FIELD + 1);
+ assert!(e.target.ends_with('…'));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn file_is_owner_only() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempdir().unwrap();
+ let log = AuditLog::open(dir.path());
+ log.record("admin", AuditAction::Login, "", "", "");
+ let mode = fs::metadata(dir.path().join(FILE_NAME))
+ .unwrap()
+ .permissions()
+ .mode();
+ assert_eq!(mode & 0o777, 0o600);
+ }
+
+ #[test]
+ fn actions_round_trip_through_serde_as_snake_case() {
+ let s = serde_json::to_string(&AuditAction::LoginFailed).unwrap();
+ assert_eq!(s, "\"login_failed\"");
+ assert_eq!(AuditAction::LoginFailed.as_str(), "login_failed");
+ let back: AuditAction = serde_json::from_str("\"account_added\"").unwrap();
+ assert_eq!(back, AuditAction::AccountAdded);
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index b3efe06..436d167 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -7,6 +7,7 @@
//! tested without a socket in sight.
#![forbid(unsafe_code)]
+pub mod audit;
pub mod auth;
pub mod crypto;
pub mod error;
@@ -15,6 +16,7 @@ pub mod settings;
pub mod totp;
pub mod vault;
+pub use audit::{AuditAction, AuditEntry, AuditLog};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use crypto::{Sealed, VaultKey};
pub use error::{Error, Result};
diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml
index 2978b53..d1e06e9 100644
--- a/crates/http-api/Cargo.toml
+++ b/crates/http-api/Cargo.toml
@@ -30,3 +30,5 @@ workspace = true
[dev-dependencies]
qrcode = { version = "0.14", default-features = false, features = ["image"] }
base64.workspace = true
+tower = { version = "0.5", features = ["util"] }
+tempfile = "3"
diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs
index 8accd32..29a39fe 100644
--- a/crates/http-api/src/app.rs
+++ b/crates/http-api/src/app.rs
@@ -1,23 +1,29 @@
//! Router, asset serving, and the account / code / settings endpoints.
-use crate::auth;
+use crate::auth::{self, Caller};
use crate::state::AppState;
use axum::{
- extract::{DefaultBodyLimit, Multipart, Path, State},
+ extract::{DefaultBodyLimit, Multipart, Path, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use openmfa_core as da;
-use openmfa_core::{unix_now, unix_now_millis, Settings};
+use openmfa_core::{unix_now, unix_now_millis, AuditAction, Provider, Settings, Totp};
use serde::Deserialize;
-use serde_json::json;
+use serde_json::{json, Value};
+use std::time::{Duration, Instant};
/// Cap on an uploaded QR image. Generous enough for a full phone photo,
/// small enough that it can't be used to exhaust memory.
const MAX_UPLOAD: usize = 12 * 1024 * 1024;
+/// How long a revealed code stays uncovered. Two periods: long enough to
+/// type it and to survive a rollover mid-way, short enough that a screen
+/// left unattended goes back to dots on its own.
+const REVEAL_TTL: Duration = Duration::from_secs(60);
+
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/api/me", get(auth::api_me))
@@ -32,7 +38,10 @@ pub fn router(state: AppState) -> Router {
"/api/accounts/{id}",
axum::routing::patch(update_account).delete(delete_account),
)
+ .route("/api/accounts/{id}/reveal", post(reveal_account))
+ .route("/api/accounts/{id}/hide", post(hide_account))
.route("/api/codes", get(list_codes))
+ .route("/api/log", get(list_log))
.route(
"/api/qr/decode",
post(decode_qr).layer(DefaultBodyLimit::max(MAX_UPLOAD)),
@@ -48,6 +57,7 @@ pub fn router(state: AppState) -> Router {
.route("/", get(index))
.route("/assets/app.js", get(asset_js))
.route("/assets/app.css", get(asset_css))
+ .route("/assets/icons.js", get(asset_icons))
.route("/assets/logo.svg", get(asset_logo))
.route("/healthz", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
@@ -97,6 +107,18 @@ async fn asset_css() -> Response {
.into_response()
}
+/// The bundled service-icon pack — generated, see `scripts/fetch-icons.py`.
+async fn asset_icons() -> Response {
+ (
+ [
+ (header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
+ (header::CACHE_CONTROL, CACHE),
+ ],
+ openmfa_webui::icons_js(),
+ )
+ .into_response()
+}
+
async fn asset_logo() -> Response {
(
[
@@ -192,7 +214,11 @@ struct ImportBody {
/// server re-parses it. That keeps secrets from ever making the round trip
/// out to the browser and back, which they would if the preview had returned
/// them.
-async fn import_accounts(State(state): State, Json(body): Json) -> Response {
+async fn import_accounts(
+ State(state): State,
+ caller: Caller,
+ Json(body): Json,
+) -> Response {
let batch = match da::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
@@ -217,7 +243,18 @@ async fn import_accounts(State(state): State, Json(body): Json (StatusCode::CREATED, Json(outcome)).into_response(),
+ Ok(outcome) => {
+ for a in &outcome.added {
+ state.audit.record(
+ caller.name(),
+ AuditAction::AccountAdded,
+ &a.issuer,
+ &a.label,
+ &caller.ip,
+ );
+ }
+ (StatusCode::CREATED, Json(outcome)).into_response()
+ }
Err(e) => crate::error_response(&e),
}
}
@@ -234,23 +271,66 @@ struct UpdateBody {
async fn update_account(
State(state): State,
+ caller: Caller,
Path(id): Path,
Json(body): Json,
) -> Response {
+ let before = state.vault.get(&id);
match state.vault.update(
&id,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
- Ok(a) => Json(a).into_response(),
+ Ok(a) => {
+ // Say what changed, so the log reads "renamed from X" rather than
+ // an unhelpful "updated".
+ let mut changes = Vec::new();
+ if let Some(b) = &before {
+ if b.issuer != a.issuer {
+ changes.push(format!("renamed from {}", b.issuer));
+ }
+ if b.label != a.label {
+ changes.push(format!("account changed from {}", b.label));
+ }
+ if b.push_enabled != a.push_enabled {
+ changes.push(if a.push_enabled {
+ "push on".to_string()
+ } else {
+ "push off".to_string()
+ });
+ }
+ }
+ state.audit.record(
+ caller.name(),
+ AuditAction::AccountUpdated,
+ &a.issuer,
+ changes.join(", "),
+ &caller.ip,
+ );
+ Json(a).into_response()
+ }
Err(e) => crate::error_response(&e),
}
}
-async fn delete_account(State(state): State, Path(id): Path) -> Response {
+async fn delete_account(
+ State(state): State,
+ caller: Caller,
+ Path(id): Path,
+) -> Response {
+ let gone = state.vault.get(&id);
match state.vault.remove(&id) {
Ok(message_id) => {
+ if let Some(g) = &gone {
+ state.audit.record(
+ caller.name(),
+ AuditAction::AccountDeleted,
+ &g.issuer,
+ &g.label,
+ &caller.ip,
+ );
+ }
// Take the tile down too, best-effort and off the response path —
// the account is already gone from the vault either way.
if let Some(mid) = message_id {
@@ -269,28 +349,45 @@ fn default_true() -> bool {
// ── Codes ─────────────────────────────────────────────────────────────────
-/// Current and next code for every account, plus the server clock.
+/// One account's current and next code, plus when the reveal lapses.
///
/// Returning the *next* code as well is what lets the browser roll over at a
/// period boundary with no request in flight — one fetch covers two periods,
-/// so the digits never blank out. Shipping the server's clock lets the UI
+/// so the digits never blank out.
+fn code_json(id: &str, totp: &Totp, now: u64, until: Instant) -> Value {
+ let remaining = until.saturating_duration_since(Instant::now());
+ json!({
+ "id": id,
+ "code": totp.code_at(now),
+ "next": totp.next_code_at(now),
+ "period": totp.period,
+ // Milliseconds, to pair with the JS clock.
+ "expires_at": totp.expires_at(now) * 1000,
+ "revealed_until": unix_now_millis() + remaining.as_millis() as u64,
+ })
+}
+
+/// Codes for the accounts this session has revealed, plus the server clock.
+///
+/// Everything else stays covered: the browser never receives a code it has
+/// not asked for by name, so "hidden" means hidden and every code that was
+/// ever shown has a line in the log. Shipping the server's clock lets the UI
/// drive its countdown off our time rather than the viewing device's, which
/// may be minutes off.
-async fn list_codes(State(state): State) -> Response {
+async fn list_codes(State(state): State, caller: Caller) -> Response {
+ let revealed = caller
+ .token
+ .as_deref()
+ .map(|t| state.sessions.revealed(t))
+ .unwrap_or_default();
let now = unix_now();
- let codes: Vec<_> = state
+ let codes: Vec = state
.vault
.all_with_totp()
.into_iter()
- .map(|(account, totp)| {
- json!({
- "id": account.id,
- "code": totp.code_at(now),
- "next": totp.next_code_at(now),
- "period": totp.period,
- // Milliseconds, to pair with the JS clock.
- "expires_at": totp.expires_at(now) * 1000,
- })
+ .filter_map(|(account, totp)| {
+ let until = revealed.get(&account.id)?;
+ Some(code_json(&account.id, &totp, now, *until))
})
.collect();
@@ -301,6 +398,79 @@ async fn list_codes(State(state): State) -> Response {
.into_response()
}
+/// Uncover one account's code for this session and write that down.
+async fn reveal_account(
+ State(state): State,
+ caller: Caller,
+ Path(id): Path,
+) -> Response {
+ let Some(account) = state.vault.get(&id) else {
+ return crate::error_response(&da::Error::not_found("account"));
+ };
+ let until = caller
+ .token
+ .as_deref()
+ .and_then(|t| state.sessions.reveal(t, &id, REVEAL_TTL));
+ let Some(until) = until else {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({ "error": "authentication required" })),
+ )
+ .into_response();
+ };
+ let totp = match state.vault.totp_for(&id) {
+ Ok(t) => t,
+ Err(e) => return crate::error_response(&e),
+ };
+ state.audit.record(
+ caller.name(),
+ AuditAction::Reveal,
+ &account.issuer,
+ &account.label,
+ &caller.ip,
+ );
+ (
+ [(header::CACHE_CONTROL, "no-store")],
+ Json(code_json(&id, &totp, unix_now(), until)),
+ )
+ .into_response()
+}
+
+/// Cover a code again before its window lapses. Not logged: nothing was
+/// disclosed by hiding.
+async fn hide_account(
+ State(state): State,
+ caller: Caller,
+ Path(id): Path,
+) -> Response {
+ if let Some(t) = caller.token.as_deref() {
+ state.sessions.hide(t, &id);
+ }
+ StatusCode::NO_CONTENT.into_response()
+}
+
+// ── Log ───────────────────────────────────────────────────────────────────
+
+#[derive(Debug, Deserialize)]
+struct LogQuery {
+ #[serde(default)]
+ limit: Option,
+ /// Entry id; only older entries are returned. From the previous page's
+ /// last entry.
+ #[serde(default)]
+ before: Option,
+}
+
+async fn list_log(State(state): State, Query(q): Query) -> Response {
+ let limit = q.limit.unwrap_or(100).clamp(1, 500);
+ let (entries, has_more) = state.audit.recent(limit, q.before);
+ (
+ [(header::CACHE_CONTROL, "no-store")],
+ Json(json!({ "entries": entries, "has_more": has_more })),
+ )
+ .into_response()
+}
+
// ── QR ────────────────────────────────────────────────────────────────────
/// Decode an uploaded image and return the first `otpauth://` payload.
@@ -376,7 +546,11 @@ async fn get_settings(State(state): State) -> Response {
.into_response()
}
-async fn put_settings(State(state): State, Json(incoming): Json) -> Response {
+async fn put_settings(
+ State(state): State,
+ caller: Caller,
+ Json(incoming): Json,
+) -> Response {
match state.settings.replace(incoming) {
Ok((s, destination_changed)) => {
if destination_changed {
@@ -393,15 +567,40 @@ async fn put_settings(State(state): State, Json(incoming): Json crate::error_response(&e),
}
}
-async fn test_delivery(State(state): State) -> Response {
+async fn test_delivery(State(state): State, caller: Caller) -> Response {
match state.engine.send_test().await {
- Ok(()) => StatusCode::NO_CONTENT.into_response(),
+ Ok(()) => {
+ let provider = state
+ .settings
+ .snapshot()
+ .provider
+ .map_or("no platform", Provider::display_name);
+ state.audit.record(
+ caller.name(),
+ AuditAction::TestMessage,
+ provider,
+ "",
+ &caller.ip,
+ );
+ StatusCode::NO_CONTENT.into_response()
+ }
Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
}
}
diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs
index 3a54707..049c4d0 100644
--- a/crates/http-api/src/auth.rs
+++ b/crates/http-api/src/auth.rs
@@ -14,18 +14,20 @@
use crate::state::AppState;
use axum::{
body::Body,
- extract::{Request, State},
- http::{header, HeaderValue, StatusCode},
+ extract::{ConnectInfo, FromRequestParts, Request, State},
+ http::{header, request::Parts, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openmfa_core as da;
-use openmfa_core::AdminPublic;
+use openmfa_core::{AdminPublic, AuditAction};
use parking_lot::RwLock;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
+use std::convert::Infallible;
+use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -38,6 +40,9 @@ pub const SESSION_COOKIE: &str = "openmfa_session";
struct Session {
username: String,
expires_at: Instant,
+ /// Accounts whose codes this session may currently see, and until when.
+ /// Codes are covered by default; a reveal opens one for a short window.
+ revealed: HashMap,
}
/// In-memory session table. Sessions deliberately do not survive a restart —
@@ -58,6 +63,7 @@ impl SessionStore {
Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
+ revealed: HashMap::new(),
},
);
id
@@ -80,6 +86,46 @@ impl SessionStore {
self.inner.write().remove(id);
}
+ /// Name on a token without extending its idle timer.
+ pub fn peek(&self, id: &str) -> Option {
+ let g = self.inner.read();
+ let s = g.get(id)?;
+ (s.expires_at > Instant::now()).then(|| s.username.clone())
+ }
+
+ /// Let this session see one account's code for `ttl`. Returns the
+ /// deadline, or `None` when the token is not a live session.
+ pub fn reveal(&self, id: &str, account_id: &str, ttl: Duration) -> Option {
+ let mut g = self.inner.write();
+ let s = g.get_mut(id)?;
+ if s.expires_at <= Instant::now() {
+ g.remove(id);
+ return None;
+ }
+ let until = Instant::now() + ttl;
+ s.revealed.insert(account_id.to_string(), until);
+ Some(until)
+ }
+
+ /// Cover a code again ahead of its deadline.
+ pub fn hide(&self, id: &str, account_id: &str) {
+ if let Some(s) = self.inner.write().get_mut(id) {
+ s.revealed.remove(account_id);
+ }
+ }
+
+ /// Accounts this session may see codes for right now, with deadlines.
+ /// Expired reveals are dropped on the way out.
+ pub fn revealed(&self, id: &str) -> HashMap {
+ let mut g = self.inner.write();
+ let Some(s) = g.get_mut(id) else {
+ return HashMap::new();
+ };
+ let now = Instant::now();
+ s.revealed.retain(|_, until| *until > now);
+ s.revealed.clone()
+ }
+
/// Invalidate everything — used after a credential change so a cookie
/// minted under the old password stops working.
pub fn revoke_all(&self) {
@@ -161,6 +207,65 @@ pub async fn require_auth(
.into_response()
}
+// ── Caller ────────────────────────────────────────────────────────────────
+
+/// Who is asking, and from where — what the audit log writes down.
+#[derive(Debug, Clone)]
+pub struct Caller {
+ pub token: Option,
+ pub username: Option,
+ pub ip: String,
+}
+
+impl Caller {
+ pub fn name(&self) -> &str {
+ self.username.as_deref().unwrap_or("unknown")
+ }
+}
+
+impl FromRequestParts for Caller {
+ type Rejection = Infallible;
+
+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result {
+ let token = parse_cookie(&parts.headers);
+ let username = token.as_deref().and_then(|t| state.sessions.peek(t));
+ let peer = parts
+ .extensions
+ .get::>()
+ .map(|c| c.0);
+ Ok(Self {
+ token,
+ username,
+ ip: client_ip(&parts.headers, peer),
+ })
+ }
+}
+
+/// The address to write in the log.
+///
+/// A reverse proxy (Nginx Proxy Manager, SWAG, a Cloudflare tunnel) puts the
+/// real client in `X-Forwarded-For` / `X-Real-IP`; those win when they carry
+/// a parseable address. Otherwise it is the TCP peer — which, inside Docker's
+/// bridge network, is the gateway rather than the LAN client. The headers are
+/// trusted as-is: the only party who can send them straight to this port is
+/// the signed-in admin, and a log they could lie to about their own address
+/// is not worth a configuration knob.
+pub fn client_ip(headers: &axum::http::HeaderMap, peer: Option) -> String {
+ for name in ["x-forwarded-for", "x-real-ip"] {
+ let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) else {
+ continue;
+ };
+ if let Some(ip) = v
+ .split(',')
+ .map(str::trim)
+ .find_map(|s| s.parse::().ok())
+ {
+ return ip.to_string();
+ }
+ }
+ peer.map_or_else(|| "unknown".to_string(), |p| p.ip().to_string())
+}
+
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -181,7 +286,11 @@ pub struct SetupBody {
/// First-run setup: create the admin and, optionally, wire a chat platform in
/// one step so the operator lands on a working install.
-pub async fn api_setup(State(state): State, Json(body): Json) -> Response {
+pub async fn api_setup(
+ State(state): State,
+ caller: Caller,
+ Json(body): Json,
+) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
@@ -218,6 +327,9 @@ pub async fn api_setup(State(state): State, Json(body): Json, Json(body): Json) -> Response {
+pub async fn api_login(
+ State(state): State,
+ caller: Caller,
+ Json(body): Json,
+) -> Response {
let admin = state.admin.clone();
+ let attempted = body.username.trim().to_string();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(da::Error::crypto(e)));
match verdict {
Ok(Some(public)) => {
+ state
+ .audit
+ .record(&public.username, AuditAction::Login, "", "", &caller.ip);
let token = state.sessions.create(&public.username);
session_response(StatusCode::OK, &public, &token)
}
// Deliberately vague: no distinction between "no such user" and
- // "wrong password", so the endpoint can't be used to enumerate.
- Ok(None) => (
- StatusCode::UNAUTHORIZED,
- Json(json!({ "error": "invalid username or password" })),
- )
- .into_response(),
+ // "wrong password", so the endpoint can't be used to enumerate. The
+ // attempt is logged under the name that was tried.
+ Ok(None) => {
+ state
+ .audit
+ .record(&attempted, AuditAction::LoginFailed, "", "", &caller.ip);
+ (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({ "error": "invalid username or password" })),
+ )
+ .into_response()
+ }
Err(e) => crate::error_response(&e),
}
}
-pub async fn api_logout(State(state): State, headers: axum::http::HeaderMap) -> Response {
+pub async fn api_logout(
+ State(state): State,
+ caller: Caller,
+ headers: axum::http::HeaderMap,
+) -> Response {
if let Some(t) = parse_cookie(&headers) {
+ if let Some(name) = &caller.username {
+ state
+ .audit
+ .record(name, AuditAction::Logout, "", "", &caller.ip);
+ }
state.sessions.revoke(&t);
}
let mut resp = StatusCode::NO_CONTENT.into_response();
@@ -308,9 +443,16 @@ pub struct UpdateCredentialsBody {
/// the keyboard is the owner and not someone using a borrowed browser.
pub async fn api_update_credentials(
State(state): State,
+ caller: Caller,
Json(body): Json,
) -> Response {
let admin = state.admin.clone();
+ let what = match (body.new_username.is_some(), body.new_password.is_some()) {
+ (true, true) => "username and password",
+ (true, false) => "username",
+ (false, true) => "password",
+ (false, false) => "nothing",
+ };
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
@@ -323,6 +465,13 @@ pub async fn api_update_credentials(
match result {
Ok(public) => {
+ state.audit.record(
+ &public.username,
+ AuditAction::CredentialsUpdated,
+ "",
+ what,
+ &caller.ip,
+ );
// Every prior cookie dies with the old password.
state.sessions.revoke_all();
let token = state.sessions.create(&public.username);
@@ -411,6 +560,60 @@ mod tests {
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
+ #[test]
+ fn reveals_are_per_session_and_expire() {
+ let s = SessionStore::default();
+ let t = s.create("admin");
+ assert!(s.revealed(&t).is_empty(), "nothing is visible by default");
+
+ let until = s.reveal(&t, "acct-1", Duration::from_secs(60)).unwrap();
+ assert!(until > Instant::now());
+ assert!(s.revealed(&t).contains_key("acct-1"));
+
+ // A different session sees nothing.
+ let other = s.create("admin");
+ assert!(s.revealed(&other).is_empty());
+
+ // Hide is immediate.
+ s.hide(&t, "acct-1");
+ assert!(s.revealed(&t).is_empty());
+
+ // A zero TTL is expired on the next read.
+ s.reveal(&t, "acct-2", Duration::ZERO).unwrap();
+ assert!(s.revealed(&t).is_empty());
+
+ // Dead tokens cannot reveal.
+ assert!(s.reveal("nope", "acct-1", Duration::from_secs(1)).is_none());
+ }
+
+ #[test]
+ fn client_ip_prefers_a_forwarded_address_then_falls_back_to_the_peer() {
+ let peer: SocketAddr = "172.17.0.1:54321".parse().unwrap();
+ let mut h = axum::http::HeaderMap::new();
+ assert_eq!(client_ip(&h, Some(peer)), "172.17.0.1");
+ assert_eq!(client_ip(&h, None), "unknown");
+
+ h.insert("x-real-ip", HeaderValue::from_static("192.168.1.123"));
+ assert_eq!(client_ip(&h, Some(peer)), "192.168.1.123");
+
+ // X-Forwarded-For wins over X-Real-IP, and its first hop is the client.
+ h.insert(
+ "x-forwarded-for",
+ HeaderValue::from_static("203.0.113.9, 10.0.0.2"),
+ );
+ assert_eq!(client_ip(&h, Some(peer)), "203.0.113.9");
+
+ // Garbage in the header is not copied into the log.
+ h.insert(
+ "x-forwarded-for",
+ HeaderValue::from_static("