Compare commits

3 Commits
Author SHA1 Message Date
DSH Agent 2f4d899119 dependency-check: run in an image that has both cargo and Node
The first run died on `exec: node: not found` — rust:1.95-bookworm has no Node,
and the report needs cargo to resolve the workspace graph alongside Node to read
the registries. .gitea/scripts/ci.Dockerfile layers the two official images
together, so the parsing exists once rather than once per language.
2026-09-20 02:54:21 -04:00
DSH Agent 9fcc59fbc9 Add a six-hourly dependency check
Reports drifted crates, the Rust pin and the Docker base images in a single
issue titled "Dependency updates", rewritten in place rather than commented on
and closed automatically when nothing is out of date.

Nothing is upgraded automatically: this reports, a human decides. The report
separates what `cargo update` covers from what crosses a breaking boundary —
including the pre-1.0 rule, where `0.5 -> 0.6` is as breaking as `1.5 -> 2.0`.

The scanner is a standalone script under .gitea/scripts so the same file serves
the sibling Rust repos; the workflow reads the Rust version from this repo's own
Dockerfile rather than hard-coding one, so the check runs on the compiler the
image actually ships with.

Requires a REGISTRY_TOKEN secret.
2026-09-20 02:49:31 -04:00
Miles WardandClaude Fable 5.1 04a86ead95 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 <[email protected]>
2026-09-09 02:08:47 -04:00
23 changed files with 2523 additions and 118 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# CI-only image for the dependency check.
#
# The report is a Node script because it has to compare semantic versions and
# read four different HTTP APIs, and the official `rust` image ships no Node —
# while the `node` image ships no cargo, which `cargo metadata` needs to resolve
# the workspace graph. Rather than maintain the parsing twice in two languages,
# this layers the two together.
#
# Node is copied from the official image rather than installed from a
# third-party apt repository: no extra signing key, no extra network
# dependency at build time, and the same binaries everyone else runs.
ARG RUST_VERSION=1.95
ARG NODE_VERSION=22-bookworm-slim
FROM node:${NODE_VERSION} AS node
FROM rust:${RUST_VERSION}-bookworm
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& node --version
+516
View File
@@ -0,0 +1,516 @@
//! Report every dependency that has a newer release than the one in use.
//!
//! Runs from `.gitea/workflows/dependency-check.yml` on a six-hour schedule.
//! Prints a Markdown report on stdout and exits 0 whether or not anything is
//! out of date — "up to date" is a normal outcome, not a failure. The workflow
//! decides what to do with the report; this file only gathers facts.
//!
//! Three things are checked, because all three go stale independently and none
//! of them is visible from the others:
//!
//! * **Crates** — every locked dependency in `Cargo.lock` against crates.io.
//! * **The Rust pin** — `rust-toolchain.toml`'s channel against the latest
//! stable release, and the Dockerfile's `RUST_VERSION` against both, since
//! those two drifting apart is how a local build and a shipped image end up
//! on different compilers.
//! * **Base images** — the Dockerfile's `FROM` tags against their upstream
//! registries.
//!
//! Network only: no crates are downloaded, nothing is built, and the working
//! tree is never modified.
import { readFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
const UA = "dependency-check (+https://gitea.milesward.dev/mward4)";
// ── version comparison ──────────────────────────────────────────────────────
/// Parse `1.2.3`, `1.2`, `1.2.3-alpha.1`, and `=1.2.3` into a comparable
/// tuple. Returns null for anything that isn't a version we understand, so an
/// unparseable requirement is reported as "unknown" rather than silently
/// sorting to the top.
function parseVersion(text) {
const m = /^[=~^]?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.*))?$/.exec(text.trim());
if (!m) return null;
return {
major: Number(m[1]),
minor: Number(m[2] ?? 0),
patch: Number(m[3] ?? 0),
pre: m[4] ?? "",
};
}
/// Order two parsed versions. A pre-release sorts below its release, so
/// `2.0.0-rc.1` never displaces a stable `1.9.0` as "the newest".
function compare(a, b) {
for (const key of ["major", "minor", "patch"]) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
}
if (a.pre === b.pre) return 0;
if (!a.pre) return 1;
if (!b.pre) return -1;
return a.pre < b.pre ? -1 : 1;
}
/// How a version difference is allowed to be taken.
///
/// The pre-1.0 rule is the one that matters here: semver treats `0.x.y` as
/// unstable, so `0.5 → 0.6` breaks exactly as hard as `1.5 → 2.0`. Getting this
/// wrong would label half the RustCrypto family "minor or patch" when in fact
/// `cargo update` will refuse every one of them.
function bumpKind(from, to) {
if (to.major !== from.major) return "breaking";
if (from.major === 0 && to.minor !== from.minor) return "breaking";
return "compatible";
}
/// The newest release reached without crossing a breaking boundary — what
/// `cargo update` will move to on its own.
function bestCompatible(from, versions) {
let best = null;
for (const raw of versions) {
const v = parseVersion(raw);
// `bumpKind` is checked before comparing against `from`: `0.14.7` is not
// semver-compatible with `1.0.0` even though it is numerically lower, and
// the naive `compare > 0` test would let `0.14 → 1.4` through as routine.
if (!v || bumpKind(from, v) !== "compatible" || compare(v, from) <= 0) continue;
if (!best || compare(v, best) > 0) best = v;
}
return best;
}
/// The newest release of any kind, which for a breaking bump is what a human
/// would have to adopt.
function bestOverall(from, versions) {
let best = null;
for (const raw of versions) {
const v = parseVersion(raw);
if (!v || compare(v, from) <= 0) continue;
if (!best || compare(v, best) > 0) best = v;
}
return best;
}
const fmt = (v) => (v ? `${v.major}.${v.minor}.${v.patch}${v.pre ? `-${v.pre}` : ""}` : "?");
// ── inputs ──────────────────────────────────────────────────────────────────
/// Resolve the dependency graph with cargo itself.
///
/// `--locked` rather than a hand-rolled Cargo.lock parse: it fails instead of
/// silently rewriting the lockfile, which matters because this runs on a
/// schedule and must never mutate the repository it is reporting on. Cargo.toml
/// is still read directly below for the declared requirement, since metadata
/// reports the resolved version rather than the constraint.
function cargoMetadata() {
let raw;
try {
raw = execFileSync("cargo", ["metadata", "--format-version", "1", "--locked"], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
// A cargo failure here means no report at all. Say why in one line rather
// than letting a raw stack trace stand in for an explanation.
const detail = (e.stderr || e.message || "").toString().trim().split("\n")[0];
throw new Error(`cargo metadata failed: ${detail}`);
}
return JSON.parse(raw);
}
function declaredRequirements() {
// Handles the `dep = "1.2"` and `dep = { version = "1.2", ... }` forms and
// nothing else, which is every dependency this workspace declares.
const text = readFileSync("Cargo.toml", "utf8");
const out = new Map();
let section = "";
for (const line of text.split("\n")) {
const header = /^\[([^\]]+)\]/.exec(line);
if (header) {
section = header[1];
continue;
}
if (!/dependencies/.test(section)) continue;
const m = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$/.exec(line);
if (!m) continue;
const [, name, value] = m;
const quoted = /^"([^"]+)"/.exec(value);
if (quoted) out.set(name, quoted[1]);
else {
const v = /version\s*=\s*"([^"]+)"/.exec(value);
if (v) out.set(name, v[1]);
}
}
return out;
}
// ── external lookups ────────────────────────────────────────────────────────
/// The latest stable Rust, from the release channel manifest.
///
/// Read from the sparse index rather than the crates.io API. The API is rate
/// limited and answers 429 under a burst of ~130 lookups, which silently
/// turned most of this report into "could not check" on the first run; the
/// index is the same data, one request per crate, no limit and no user agent
/// requirement.
async function publishedVersions(name) {
// The index shards by name length and case: 1-char names live under "1",
// 2-char under "2", 3-char under "3/<first letter>", and everything else
// under the first two characters. Names are lowercased on the way in.
const lower = name.toLowerCase();
const prefix =
lower.length === 1
? `1/${lower}`
: lower.length === 2
? `2/${lower}`
: lower.length === 3
? `3/${lower[0]}/${lower}`
: `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}`;
try {
const res = await fetch(`https://index.crates.io/${prefix}`, {
headers: { "user-agent": UA },
});
// The index answers 404 for a crate that does not exist and 410 for one
// that was removed; both mean "nothing to compare against".
if (!res.ok) return null;
return (await res.text())
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((entry) => !entry.yanked && !entry.vers.includes("-"))
.map((entry) => entry.vers);
} catch {
return null;
}
}
/// The latest stable Rust, from the release channel manifest.
async function latestRust() {
try {
const res = await fetch("https://static.rust-lang.org/dist/channel-rust-stable.toml", {
headers: { "user-agent": UA },
});
if (!res.ok) return null;
const m = /\[pkg\.rust\]\nversion = "([^"]+)"/.exec(await res.text());
// The manifest value carries the commit: "1.98.1 (48a229cea 2026-09-01)".
// Only the number is comparable to the Dockerfile's RUST_VERSION.
return m ? m[1].split(/\s+/)[0] : null;
} catch {
return null;
}
}
/// Tags for a Docker Hub repository, or null if it can't be read.
async function dockerTags(repo) {
const [ns, name] = repo.includes("/") ? repo.split("/") : ["library", repo];
try {
const res = await fetch(`https://hub.docker.com/v2/repositories/${ns}/${name}/tags?page_size=100`, {
headers: { "user-agent": UA },
});
if (!res.ok) return null;
const data = await res.json();
return data.results.map((t) => t.name);
} catch {
return null;
}
}
// ── checks ──────────────────────────────────────────────────────────────────
async function checkCrates() {
const meta = cargoMetadata();
const requirements = declaredRequirements();
// One row per crate actually compiled into the workspace. Dev-dependencies
// and build-dependencies are real dependencies too, so they are included —
// a stale build script is still a stale build script.
const seen = new Map();
for (const pkg of meta.packages) {
if (meta.workspace_members.includes(pkg.id)) continue;
seen.set(pkg.name, { from: pkg.version, kind: pkg.source ? "registry" : "path" });
}
const rows = [];
for (const [name, info] of [...seen].sort((a, b) => a[0].localeCompare(b[0]))) {
if (info.kind === "path") continue;
// Direct means "named in Cargo.toml", which is the only set the author can
// act on. The rest move when whatever pulls them in moves, so they are
// reported but never counted as work.
const direct = requirements.has(name);
const current = parseVersion(info.from);
const versions = await publishedVersions(name);
if (!current || !versions) {
rows.push({ name, from: info.from, to: null, kind: "unknown", direct, note: "could not check" });
continue;
}
const compatible = bestCompatible(current, versions);
const overall = bestOverall(current, versions);
if (!overall) continue; // already newest
// `compatible` is what `cargo update` will do unaided. If the newest
// release is further out than that, the gap is a breaking change someone
// has to adopt deliberately.
const kind = compatible ? "compatible" : bumpKind(current, overall);
rows.push({
name,
from: info.from,
to: fmt(overall),
kind,
direct,
note: kind === "compatible" ? "`cargo update`" : "**breaking — needs review**",
});
}
return rows;
}
function rustPin() {
const toolchain = existsSync("rust-toolchain.toml")
? readFileSync("rust-toolchain.toml", "utf8")
: "";
const channel = /channel\s*=\s*"([^"]+)"/.exec(toolchain)?.[1] ?? null;
// The image bakes its own toolchain, so this is the number that decides what
// actually compiles the release binary.
const dockerfile = existsSync("deploy/docker/Dockerfile")
? readFileSync("deploy/docker/Dockerfile", "utf8")
: existsSync("Dockerfile")
? readFileSync("Dockerfile", "utf8")
: "";
const image = /ARG\s+RUST_VERSION=([0-9.]+)/.exec(dockerfile)?.[1] ?? null;
// The number `cargo` enforces as the minimum supported toolchain.
const manifest = readFileSync("Cargo.toml", "utf8");
const msrv = /rust-version\s*=\s*"([^"]+)"/.exec(manifest)?.[1] ?? null;
return { channel, image, msrv };
}
/// Every `FROM` in the Dockerfile, with the `ARG` substitution applied for the
/// one argument this build actually parameterises.
function baseImages() {
const path = existsSync("deploy/docker/Dockerfile")
? "deploy/docker/Dockerfile"
: existsSync("Dockerfile")
? "Dockerfile"
: null;
if (!path) return [];
const text = readFileSync(path, "utf8");
const args = new Map(
[...text.matchAll(/^ARG\s+([A-Za-z0-9_]+)=(\S+)/gm)].map((m) => [m[1], m[2]]),
);
const images = [];
for (const m of text.matchAll(/^FROM\s+(?:--platform=\S+\s+)?(\S+)/gm)) {
let ref = m[1];
// `node:${NODE_VERSION}-slim` and friends.
ref = ref.replace(/\$\{?([A-Za-z0-9_]+)\}?/g, (whole, name) => args.get(name) ?? whole);
if (ref.includes("$")) continue; // still unresolved; skip rather than guess
const [repo, tag = "latest"] = ref.split(":");
images.push({ repo, tag });
}
return images;
}
async function checkRustToolchain() {
const pin = rustPin();
const latest = await latestRust();
return { ...pin, latest };
}
/// The newest tag that keeps the variant suffix the Dockerfile asked for.
///
/// `rust:1.95-bookworm` and `node:22-bookworm-slim` are not interchangeable
/// with their plain counterparts, so a tag is only a candidate if it ends in
/// exactly the suffix already in use.
function bestTagFor(currentTag, tags) {
const m = /^([\d.]+)(-.*)?$/.exec(currentTag);
if (!m) return null;
const [, , suffix = ""] = m;
let best = null;
for (const tag of tags) {
if (suffix) {
if (!tag.endsWith(suffix)) continue;
} else if (!/^[\d.]+$/.test(tag)) {
continue;
}
const v = parseVersion(suffix ? tag.slice(0, -suffix.length) : tag);
// Date-stamped tags like `alpine:20260805` parse as versions but are edge
// or snapshot builds, not releases. No official image we track is anywhere
// near a three-digit major.
if (v && !suffix && v.major >= 100) continue;
if (v && (!best || compare(v, best) > 0)) best = v;
}
return best;
}
async function checkImages() {
const rows = [];
for (const { repo, tag } of baseImages()) {
const tags = await dockerTags(repo);
if (!tags) {
rows.push({ repo, from: tag, to: null, note: "could not check" });
continue;
}
const parsed = parseVersion(tag);
const best = bestTagFor(tag, tags);
if (!parsed || !best) {
rows.push({ repo, from: tag, to: null, note: "tag is not a version" });
continue;
}
// A base image major bump is a whole new distro release; same rule as a
// crate, so it is reported but flagged rather than treated as routine.
const bump = bumpKind(parsed, best);
rows.push({
repo,
from: tag,
to: fmt(best),
note: bump === "compatible" ? "" : "**breaking — needs review**",
});
}
return rows;
}
// ── report ──────────────────────────────────────────────────────────────────
function table(rows, columns) {
const head = `| ${columns.map((c) => c.title).join(" | ")} |`;
const rule = `| ${columns.map(() => "---").join(" | ")} |`;
const body = rows.map((r) => `| ${columns.map((c) => c.get(r)).join(" | ")} |`);
return [head, rule, ...body].join("\n");
}
async function main() {
const [crates, rust, images] = await Promise.all([checkCrates(), checkRustToolchain(), checkImages()]);
// Split by who can act. Only a crate named in Cargo.toml can be moved by
// hand; everything else follows whichever direct dependency pulls it in, so
// listing those as tasks would be noise that trains the reader to ignore the
// report.
const direct = crates.filter((r) => r.direct);
const transitive = crates.filter((r) => !r.direct);
const routine = direct.filter((r) => r.kind === "compatible");
const breaking = direct.filter((r) => r.kind === "breaking");
const rustOutdated = Boolean(rust.latest && rust.image && rust.image !== rust.latest);
// Transitive crates held back by a version boundary upstream has not crossed.
// Worth knowing, not worth acting on.
const heldBack = transitive.filter((r) => r.kind === "breaking");
const parts = [];
const actionable = routine.length + breaking.length + images.length + (rustOutdated ? 1 : 0);
parts.push(
actionable === 0
? "Everything you can act on is current."
: `${routine.length} routine bump${routine.length === 1 ? "" : "s"}, ` +
`${breaking.length} breaking change${breaking.length === 1 ? "" : "s"}` +
(images.length ? `, ${images.length} base image${images.length === 1 ? "" : "s"}` : "") +
(rustOutdated ? ", and a newer Rust" : "") +
".",
);
if (heldBack.length) {
parts.push(
`_A further ${heldBack.length} transitive crates have newer releases blocked behind a ` +
"breaking boundary upstream has not crossed yet. They are omitted below because " +
"nothing here can move them._",
);
}
if (routine.length) {
parts.push(
"### Direct dependencies — `cargo update` covers these\n\n" +
table(routine, [
{ title: "Crate", get: (r) => `\`${r.name}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
]),
);
}
if (breaking.length) {
parts.push(
"### Direct dependencies — breaking, a human has to decide\n\n" +
"These cross a version boundary where semver allows anything to change. " +
"`cargo update` will not touch them: `Cargo.toml` needs editing and the " +
"changelog reading.\n\n" +
table(breaking, [
{ title: "Crate", get: (r) => `\`${r.name}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
]),
);
}
const rustRows = rust.latest
? [
{
what: "Dockerfile `RUST_VERSION`",
using: rust.image ?? "not pinned",
latest: rust.latest,
note: rustOutdated ? "**the shipped binary is built by an older compiler**" : "current",
},
{
what: "`rust-toolchain.toml` channel",
using: rust.channel ?? "unknown",
latest: rust.latest,
note:
rust.channel === "stable"
? "tracks stable, so it is already current"
: "pinned to a fixed release",
},
]
: [
{
what: "latest stable Rust",
using: rust.image ?? "unknown",
latest: "—",
note: "could not reach static.rust-lang.org",
},
];
parts.push(
"### Rust\n\n" +
table(rustRows, [
{ title: "What", get: (r) => r.what },
{ title: "Declared", get: (r) => `\`${r.using}\`` },
{ title: "Latest", get: (r) => `\`${r.latest}\`` },
{ title: "Note", get: (r) => r.note },
]) +
(rust.msrv ? `\n\nMinimum supported Rust version: \`${rust.msrv}\`.` : ""),
);
if (images.length) {
parts.push(
"### Base images\n\n" +
table(images, [
{ title: "Image", get: (r) => `\`${r.repo}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
{ title: "Note", get: (r) => r.note },
]),
);
}
parts.push("_Checked every 6 hours by `.gitea/workflows/dependency-check.yml`._");
console.log(parts.join("\n\n"));
// Machine-readable footer, so the workflow never has to parse prose.
console.error(
`::dep-check:: routine=${routine.length} breaking=${breaking.length} ` +
`images=${images.length} rust=${rustOutdated ? 1 : 0}`,
);
}
try {
await main();
} catch (e) {
// Print the reason and exit non-zero. The workflow treats a missing report as
// a broken check rather than a clean bill of health, so this keeps the
// tracking issue open instead of closing it on no evidence.
console.error(`dependency check failed: ${e.message}`);
process.exit(1);
}
+184
View File
@@ -0,0 +1,184 @@
name: Check for dependency updates
# Runs every six hours and reports anything out of date in one place: the
# crates in Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
# and the Dockerfile's base images.
#
# Everything lands in ONE issue titled "Dependency updates", edited in place
# rather than commented on, so there is a single current answer instead of a
# pile of history. When nothing is out of date the issue is closed — an empty
# issue left open is a thing people stop reading, which defeats the point.
#
# Nothing is upgraded automatically. `cargo update` on a schedule would land
# unreviewed code in a service that holds the operator's second factor, and a
# breaking bump needs a human regardless.
on:
schedule:
# Every six hours, off the hour so this doesn't compete with an image
# rebuild for a runner.
- cron: '23 */6 * * *'
workflow_dispatch:
# One run at a time. A manual dispatch during a scheduled run would otherwise
# race it and could open the issue twice.
concurrency:
group: dependency-check
cancel-in-progress: false
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
# The toolchain comes from the repo rather than being hard-coded here, so
# the check runs on the same compiler the image ships with and this file
# can be copied between repos unchanged.
- name: Resolve the pinned Rust version
id: rust
run: |
set -eu
FILE=deploy/docker/Dockerfile
[ -f "$FILE" ] || FILE=Dockerfile
VERSION=$(grep -E '^ARG RUST_VERSION=' "$FILE" | head -1 | cut -d= -f2)
[ -n "$VERSION" ] || { echo "::error::no ARG RUST_VERSION in $FILE"; exit 1; }
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "checking with rust ${VERSION}"
# The workspace is streamed into the container over stdin rather than
# bind-mounted, so this works whether the runner shares a filesystem with
# the daemon or not.
#
# The image is rust + node, built from .gitea/scripts/ci.Dockerfile: the
# report needs cargo to resolve the workspace graph and Node to read the
# registries, and neither official image has both.
#
# RUSTUP_TOOLCHAIN overrides rust-toolchain.toml: the image's own
# toolchain is enough to read Cargo.lock, and honouring the file would
# make every run fetch a second compiler for no benefit.
#
# Cargo is deliberately *not* run offline: at least one project here
# depends on a crate published to a private registry, and `--offline`
# turns that into a failed resolution and therefore no report at all.
- name: Generate report
run: |
set -eu
VERSION="${{ steps.rust.outputs.version }}"
docker build \
--build-arg "RUST_VERSION=${VERSION}" \
--tag "dep-check:${VERSION}" \
--file .gitea/scripts/ci.Dockerfile \
.gitea/scripts > /dev/null
tar cf - --exclude=./.git --exclude=./target . | docker run --rm -i \
-e RUSTUP_TOOLCHAIN="${VERSION}" \
-e CARGO_TERM_COLOR=never \
"dep-check:${VERSION}" \
sh -ec 'mkdir -p /work && cd /work && tar xf - && exec node .gitea/scripts/dependency-report.mjs' \
> /tmp/report.md 2> /tmp/report.err || {
echo "::error::the dependency report failed to generate"
cat /tmp/report.err
exit 1
}
cat /tmp/report.md
grep -E '::dep-check::' /tmp/report.err || true
# An empty report is a broken check, not a clean bill of health.
# Failing here keeps the tracking issue open rather than closing it on
# the strength of a file that never got written.
test -s /tmp/report.md
grep -oE 'routine=[0-9]+ breaking=[0-9]+ images=[0-9]+ rust=[0-9]+' /tmp/report.err \
> /tmp/counts
cat /tmp/counts
- name: Open, update or close the tracking issue
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -eu
API="https://gitea.milesward.dev/api/v1"
node -e '
const fs = require("fs");
const report = fs.readFileSync("/tmp/report.md", "utf8").trim();
const counts = fs.readFileSync("/tmp/counts", "utf8").trim();
const [routine, breaking, images, rust] = counts.match(/[0-9]+/g).map(Number);
const actionable = routine + breaking + images + rust;
const body =
(actionable === 0
? "Everything tracked is current. Closing this until something changes.\n\n"
: "") +
report +
"\n\n---\n\n" +
"Rewritten by `.gitea/workflows/dependency-check.yml` every 6 hours, so this is " +
"always the latest report rather than a pile of comments.";
fs.writeFileSync("/tmp/state.json", JSON.stringify({ actionable, body }));
'
ACTIONABLE=$(node -e 'process.stdout.write(String(require("/tmp/state.json").actionable))')
echo "actionable=$ACTIONABLE"
# Existing issue by title, any state: a previously closed one is
# exactly the one to update and reopen, and searching all states is
# also what stops a duplicate being created after a close.
#
# The `|| true` matters: `set -e` plus `pipefail` would abort the step
# on a transient API error, and creating a second issue is a far worse
# outcome than missing one run.
NUMBER=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/issues?state=all&type=issues&limit=100" \
| node -e '
let s = "";
process.stdin.on("data", d => s += d).on("end", () => {
try {
const issues = JSON.parse(s);
const hit = issues.find(i => i.title === "Dependency updates");
process.stdout.write(hit ? String(hit.number) : "");
} catch { process.stdout.write(""); }
});
' || true)
BODY=$(node -e 'process.stdout.write(JSON.stringify({ body: require("/tmp/state.json").body }))')
if [ -z "$NUMBER" ]; then
if [ "$ACTIONABLE" = "0" ]; then
echo "everything current and no issue open — nothing to do"
exit 0
fi
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(node -e 'process.stdout.write(JSON.stringify({ title: "Dependency updates", body: require("/tmp/state.json").body }))')" \
"${API}/repos/${REPO}/issues" > /dev/null
echo "opened the tracking issue"
exit 0
fi
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
echo "updated issue #${NUMBER}"
# Reopen when there is work, close when there isn't. Done as a second
# call because the state is not part of the body above.
if [ "$ACTIONABLE" = "0" ]; then WANT=closed; else WANT=open; fi
STATE=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/issues/${NUMBER}" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).state))')
if [ "$STATE" != "$WANT" ]; then
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"state\":\"${WANT}\"}" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
echo "issue #${NUMBER}: ${STATE} -> ${WANT}"
fi
Generated
+8 -6
View File
@@ -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"
+1 -1
View File
@@ -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"
+54 -7
View File
@@ -11,7 +11,8 @@
</p>
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 (`[email protected]` → 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 · [email protected]* — 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.
+427
View File
@@ -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<AuditEntry>,
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<Mutex<Inner>>,
}
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<AuditEntry> = 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::<AuditEntry>(&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<String>,
detail: impl Into<String>,
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<u64>) -> (Vec<AuditEntry>, bool) {
let g = self.inner.lock();
let mut out: Vec<AuditEntry> = 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::<String>() + "…"
}
}
/// 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<fs::File> {
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<fs::File> {
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::<Vec<_>>(),
["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",
"[email protected]",
"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);
}
}
+2
View File
@@ -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};
+2
View File
@@ -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"
+223 -24
View File
@@ -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<AppState>, Json(body): Json<ImportBody>) -> Response {
async fn import_accounts(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<ImportBody>,
) -> 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<AppState>, Json(body): Json<ImportB
match state.vault.add_many(&queued) {
// The push loop picks new accounts up on its next tick; we don't kick
// a sync from here, so only one pass is ever in flight.
Ok(outcome) => (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<AppState>,
caller: Caller,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> 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<AppState>, Path(id): Path<String>) -> Response {
async fn delete_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> 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<AppState>) -> Response {
async fn list_codes(State(state): State<AppState>, 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<Value> = 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<AppState>) -> Response {
.into_response()
}
/// Uncover one account's code for this session and write that down.
async fn reveal_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> 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<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> 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<usize>,
/// Entry id; only older entries are returned. From the previous page's
/// last entry.
#[serde(default)]
before: Option<u64>,
}
async fn list_log(State(state): State<AppState>, Query(q): Query<LogQuery>) -> 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<AppState>) -> Response {
.into_response()
}
async fn put_settings(State(state): State<AppState>, Json(incoming): Json<Settings>) -> Response {
async fn put_settings(
State(state): State<AppState>,
caller: Caller,
Json(incoming): Json<Settings>,
) -> Response {
match state.settings.replace(incoming) {
Ok((s, destination_changed)) => {
if destination_changed {
@@ -393,15 +567,40 @@ async fn put_settings(State(state): State<AppState>, Json(incoming): Json<Settin
"destination changed; every tile will be reposted"
);
}
let mut detail = format!("push {}", if s.push_enabled { "on" } else { "off" });
if destination_changed {
detail.push_str(", destination changed");
}
state.audit.record(
caller.name(),
AuditAction::SettingsUpdated,
s.provider.map_or("no platform", Provider::display_name),
detail,
&caller.ip,
);
Json(s).into_response()
}
Err(e) => crate::error_response(&e),
}
}
async fn test_delivery(State(state): State<AppState>) -> Response {
async fn test_delivery(State(state): State<AppState>, 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(),
}
}
+215 -12
View File
@@ -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<String, Instant>,
}
/// 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<String> {
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<Instant> {
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<String, Instant> {
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<String>,
pub username: Option<String>,
pub ip: String,
}
impl Caller {
pub fn name(&self) -> &str {
self.username.as_deref().unwrap_or("unknown")
}
}
impl FromRequestParts<AppState> for Caller {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Infallible> {
let token = parse_cookie(&parts.headers);
let username = token.as_deref().and_then(|t| state.sessions.peek(t));
let peer = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.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<SocketAddr>) -> 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::<IpAddr>().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<AppState>, Json(body): Json<SetupBody>) -> Response {
pub async fn api_setup(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
@@ -218,6 +327,9 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
if let Err(e) = state.settings.replace(checked) {
tracing::warn!(target: "openmfa::http", "could not store delivery settings at setup: {e}");
}
state
.audit
.record(&public.username, AuditAction::Setup, "", "", &caller.ip);
let token = state.sessions.create(&public.username);
session_response(StatusCode::CREATED, &public, &token)
}
@@ -231,30 +343,53 @@ pub struct LoginBody {
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
pub async fn api_login(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<LoginBody>,
) -> 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<AppState>, headers: axum::http::HeaderMap) -> Response {
pub async fn api_logout(
State(state): State<AppState>,
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<AppState>,
caller: Caller,
Json(body): Json<UpdateCredentialsBody>,
) -> 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("<script>, 10.0.0.2"),
);
assert_eq!(client_ip(&h, Some(peer)), "10.0.0.2");
h.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
h.remove("x-real-ip");
assert_eq!(client_ip(&h, Some(peer)), "172.17.0.1");
}
#[test]
fn logout_cookie_expires_immediately() {
assert!(cookie("", 0).contains("Max-Age=0"));
+2 -1
View File
@@ -1,6 +1,6 @@
//! Shared handler state.
use openmfa_core::{AdminStore, SettingsStore, Vault};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault};
use openmfa_delivery::PushEngine;
use std::sync::Arc;
@@ -13,6 +13,7 @@ pub struct AppState {
pub settings: Arc<SettingsStore>,
pub sessions: SessionStore,
pub engine: PushEngine,
pub audit: AuditLog,
pub version: &'static str,
}
+290
View File
@@ -0,0 +1,290 @@
//! Through the router: codes stay covered until a reveal, reveals are scoped
//! to the session that asked, and everything the server did is in the log
//! with the caller's address.
use axum::body::Body;
use axum::http::{header, HeaderMap, Request, StatusCode};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use openmfa_http_api::{auth::SessionStore, router, AppState};
use serde_json::{json, Value};
use tower::ServiceExt;
const URI: &str = "otpauth://totp/GitHub:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=GitHub";
const CLIENT: &str = "203.0.113.9";
fn app(dir: &std::path::Path) -> axum::Router {
let key = VaultKey::load_or_create(dir).unwrap();
let vault = Vault::load(dir, key).unwrap();
let settings = SettingsStore::load_or_default(dir);
let engine = PushEngine::new(vault.clone(), settings.clone(), &Endpoints::from_env());
router(AppState {
admin: AdminStore::load_or_default(dir),
vault,
settings,
sessions: SessionStore::default(),
engine,
audit: AuditLog::open(dir),
version: "test",
})
}
async fn call(
app: &axum::Router,
method: &str,
path: &str,
cookie: Option<&str>,
body: Option<Value>,
) -> (StatusCode, HeaderMap, Value) {
let mut req = Request::builder()
.method(method)
.uri(path)
// What a reverse proxy would add: client first, then the hop.
.header("x-forwarded-for", format!("{CLIENT}, 10.0.0.2"));
if let Some(c) = cookie {
req = req.header(header::COOKIE, format!("openmfa_session={c}"));
}
let req = match body {
Some(b) => req
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(b.to_string()))
.unwrap(),
None => req.body(Body::empty()).unwrap(),
};
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, headers, value)
}
fn session_cookie(h: &HeaderMap) -> String {
let raw = h.get(header::SET_COOKIE).unwrap().to_str().unwrap();
raw.split(';')
.next()
.unwrap()
.strip_prefix("openmfa_session=")
.unwrap()
.to_string()
}
async fn set_up(app: &axum::Router) -> String {
let (status, headers, _) = call(
app,
"POST",
"/api/setup",
None,
Some(json!({ "username": "admin", "password": "correct horse battery" })),
)
.await;
assert_eq!(status, StatusCode::CREATED);
session_cookie(&headers)
}
async fn import_github(app: &axum::Router, cookie: &str) -> String {
let (status, _, out) = call(
app,
"POST",
"/api/accounts/import",
Some(cookie),
Some(json!({ "uri": URI, "items": [{ "index": 0 }] })),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{out}");
out["added"][0]["id"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn codes_stay_covered_until_revealed_and_the_reveal_is_logged() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
// Nothing is handed out unasked.
let (status, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// Reveal returns the code and opens the window for this session.
let (status, _, revealed) = call(
&app,
"POST",
&format!("/api/accounts/{id}/reveal"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "{revealed}");
let code = revealed["code"].as_str().unwrap();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert!(
revealed["revealed_until"].as_u64().unwrap()
> revealed["expires_at"].as_u64().unwrap() - 60_000
);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
let list = codes["codes"].as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], id);
assert_eq!(list[0]["code"], code);
// Another session of the same admin sees dots.
let (status, headers, _) = call(
&app,
"POST",
"/api/login",
None,
Some(json!({ "username": "admin", "password": "correct horse battery" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let other = session_cookie(&headers);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&other), None).await;
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// Hide covers it again at once.
let (status, _, _) = call(
&app,
"POST",
&format!("/api/accounts/{id}/hide"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// The log has the whole story, newest first, with the forwarded address.
let (status, _, log) = call(&app, "GET", "/api/log?limit=50", Some(&cookie), None).await;
assert_eq!(status, StatusCode::OK);
let entries = log["entries"].as_array().unwrap();
let actions: Vec<&str> = entries
.iter()
.map(|e| e["action"].as_str().unwrap())
.collect();
assert_eq!(actions, ["login", "reveal", "account_added", "setup"]);
let reveal = &entries[1];
assert_eq!(reveal["actor"], "admin");
assert_eq!(reveal["target"], "GitHub");
assert_eq!(reveal["detail"], "[email protected]");
assert_eq!(reveal["ip"], CLIENT);
assert!(reveal["ts"].as_u64().unwrap() > 1_700_000_000_000);
assert_eq!(log["has_more"], false);
// Paging backwards from the reveal gives what came before it.
let before = reveal["id"].as_u64().unwrap();
let (_, _, older) = call(
&app,
"GET",
&format!("/api/log?limit=10&before={before}"),
Some(&cookie),
None,
)
.await;
let older: Vec<&str> = older["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["action"].as_str().unwrap())
.collect();
assert_eq!(older, ["account_added", "setup"]);
}
#[tokio::test]
async fn the_log_and_reveals_need_a_session() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
let (status, _, _) = call(&app, "GET", "/api/log", None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, _) = call(
&app,
"POST",
&format!("/api/accounts/{id}/reveal"),
None,
None,
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
// A reveal of something that doesn't exist is a 404, not a log entry.
let (status, _, _) = call(
&app,
"POST",
"/api/accounts/nope/reveal",
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (_, _, log) = call(&app, "GET", "/api/log", Some(&cookie), None).await;
assert!(log["entries"]
.as_array()
.unwrap()
.iter()
.all(|e| e["action"] != "reveal"));
}
#[tokio::test]
async fn failed_sign_ins_and_account_changes_are_logged() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
let (status, _, _) = call(
&app,
"POST",
"/api/login",
None,
Some(json!({ "username": " Mallory ", "password": "wrong" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, _) = call(
&app,
"PATCH",
&format!("/api/accounts/{id}"),
Some(&cookie),
Some(json!({ "issuer": "GitHub Enterprise", "push_enabled": false })),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = call(
&app,
"DELETE",
&format!("/api/accounts/{id}"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, _, log) = call(&app, "GET", "/api/log", Some(&cookie), None).await;
let entries = log["entries"].as_array().unwrap();
assert_eq!(entries[0]["action"], "account_deleted");
assert_eq!(entries[0]["target"], "GitHub Enterprise");
assert_eq!(entries[1]["action"], "account_updated");
assert_eq!(entries[1]["detail"], "renamed from GitHub, push off");
assert_eq!(entries[2]["action"], "login_failed");
assert_eq!(
entries[2]["actor"], "Mallory",
"the attempted name, trimmed"
);
assert_eq!(entries[2]["ip"], CLIENT);
}
+12 -5
View File
@@ -4,7 +4,7 @@
use anyhow::Context;
use clap::Parser;
use openmfa_core::{AdminStore, SettingsStore, Vault, VaultKey};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use openmfa_http_api::AppState;
use std::net::SocketAddr;
@@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> {
let vault = Vault::load(&args.work_dir, key).context("could not load the vault")?;
let admin = AdminStore::load_or_default(&args.work_dir);
let settings = SettingsStore::load_or_default(&args.work_dir);
let audit = AuditLog::open(&args.work_dir);
let engine = PushEngine::new(vault.clone(), settings.clone(), &Endpoints::from_env());
tokio::spawn(engine.clone().run());
@@ -66,6 +67,7 @@ async fn main() -> anyhow::Result<()> {
settings,
sessions: openmfa_http_api::auth::SessionStore::default(),
engine,
audit,
version: VERSION,
};
@@ -88,10 +90,15 @@ async fn main() -> anyhow::Result<()> {
);
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
// `with_connect_info` is what gives handlers the peer address for the
// audit log when no proxy is forwarding one.
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
tracing::info!(target: "openmfa", "shutting down");
Ok(())
+46 -6
View File
@@ -431,6 +431,14 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
box-shadow: inset 0 0 0 1px var(--line-strong);
user-select: none;
}
/* A recognised service shows its mark in the brand colour. Marks that are
near-black or near-white would vanish against one of the two tiles, so
those fall back to ink for that theme only. */
.avatar.brand { background: var(--surface); }
.avatar.brand svg { width: 20px; height: 20px; fill: var(--brand, currentColor); display: block; }
:root[data-theme="dark"] .avatar.brand[data-tone="dark"] svg { fill: currentColor; }
:root[data-theme="light"] .avatar.brand[data-tone="light"] svg { fill: currentColor; }
.import-row .avatar.brand svg { width: 16px; height: 16px; }
.acct .meta { min-width: 0; }
.acct .issuer {
@@ -462,6 +470,16 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
}
.code:hover { background: var(--hover-2); }
.code .gap { display: inline-block; width: 0.42em; }
/* Covered: the same footprint as the digits, so rows don't jump on reveal. */
.code.covered {
display: inline-flex; align-items: center; gap: 9px;
color: var(--ink-3);
letter-spacing: 0.1em;
}
.code.covered svg { width: 17px; height: 17px; color: var(--ink-2); flex: none; }
.code.covered:hover { color: var(--ink-2); }
.code.covered:hover svg { color: var(--ink); }
/* Last 5 seconds: the code is about to roll. */
.acct.expiring .code { color: var(--orange); }
@@ -671,12 +689,6 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
background: var(--surface);
box-shadow: var(--shadow-btn);
}
.segmented .dot {
width: 9px; height: 9px; border-radius: 50%; flex: none;
box-shadow: inset 0 0 0 1px #0000001f;
opacity: .55;
}
.segmented button.active .dot { opacity: 1; }
.provider-fields:not(:empty) { margin-top: 14px; }
.provider-fields .alert { margin-bottom: 14px; }
.brand svg { width: 26px; height: 26px; display: block; }
@@ -812,6 +824,34 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
/* ── Utility ──────────────────────────────────────────────────────────── */
/* ── Log ──────────────────────────────────────────────────────────────── */
.log-list { display: flex; flex-direction: column; }
.log-row {
display: grid; grid-template-columns: 28px 1fr; gap: 12px;
padding: 11px 0;
}
.log-row + .log-row { border-top: 1px solid var(--line-soft); }
.log-glyph {
display: grid; place-items: center;
width: 28px; height: 28px; margin-top: 1px;
border-radius: 8px;
color: var(--ink-2);
background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line);
}
.log-glyph svg { width: 14px; height: 14px; }
.log-body { min-width: 0; }
.log-line { font-size: 13.5px; line-height: 1.45; overflow-wrap: anywhere; }
.log-meta {
display: flex; flex-wrap: wrap; gap: 0 9px;
margin-top: 3px;
font-size: 12px; color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.log-meta span + span::before { content: "|"; margin-right: 9px; color: var(--line-strong); }
.log-empty { padding: 26px 0; text-align: center; font-size: 13px; color: var(--ink-3); }
.mono { font-family: var(--mono); }
.muted { color: var(--ink-2); }
.small { font-size: 12.5px; }
+251 -43
View File
@@ -90,6 +90,10 @@ const ICON = {
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'],
eye: ['M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'],
eyeOff: ['M17.9 17.9A10 10 0 0 1 12 19c-6.5 0-10-7-10-7a17 17 0 0 1 4.1-4.9', 'M9.9 5.2A9.5 9.5 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-2 2.9', 'M14.1 14.1a3 3 0 1 1-4.2-4.2', 'M2 2l20 20'],
login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'],
list: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'],
};
// ── State ──────────────────────────────────────────────────────────────────
@@ -97,7 +101,7 @@ const ICON = {
const state = {
me: null, // /api/me payload
accounts: [], // account metadata
codes: new Map(), // id -> { code, next, period, digits }
codes: new Map(), // id -> { code, next, period, expires_at, revealed_until } — revealed accounts only
clockOffsetMs: 0, // serverNow - clientNow
query: '',
view: 'vault',
@@ -162,6 +166,66 @@ function initials(str) {
return (s[0] || '?').toUpperCase();
}
/* ── Service icons ────────────────────────────────────────────────────────
* The pack in icons.js (generated; Simple Icons + a few Font Awesome brands)
* is keyed by slug and comes with an alias table. An otpauth:// link carries
* no artwork — only issuer, label and the secret — so the match is by name:
* the whole issuer, then shorter word-prefixes of it ("Google Workspace" →
* "google"), then the label's mail domain ("[email protected]" → gmail). Anything
* unmatched falls back to the initial. */
function iconKey(s) {
return String(s || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '');
}
function serviceIcon(issuer, label) {
const icons = window.OPENMFA_ICONS || {};
const aliases = window.OPENMFA_ICON_ALIASES || {};
const tries = [];
const whole = iconKey(issuer);
if (whole) tries.push(whole);
const words = String(issuer || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
for (let n = words.length - 1; n >= 1; n--) tries.push(words.slice(0, n).join(''));
const at = String(label || '').indexOf('@');
if (at >= 0) {
const domain = String(label).slice(at + 1).toLowerCase();
tries.push(iconKey(domain), iconKey(domain.split('.')[0]));
}
for (const key of tries) {
if (!key) continue;
const slug = icons[key] ? key : aliases[key];
if (slug && icons[slug]) {
const [title, viewBox, paths, color] = icons[slug];
return { slug, title, viewBox, paths, color };
}
}
return null;
}
/** Relative luminance of a #rrggbb colour, 0..1 — decides whether a brand
* colour would vanish against the current theme's tile. */
function luminance(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 0.5;
const n = parseInt(m[1], 16);
const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * lin(n >> 16) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
}
/** The initials tile, upgraded to the service's mark when we have one. */
function avatarFor(issuer, label) {
const ic = serviceIcon(issuer, label);
if (!ic) return h('div.avatar', { text: initials(issuer || label) });
const L = luminance(ic.color);
const svg = icon(ic.paths, { viewBox: ic.viewBox, fill: 'currentColor', stroke: 'none' });
svg.removeAttribute('stroke-width');
return h('div.avatar.brand', {
title: ic.title,
style: '--brand:' + ic.color,
dataset: { tone: L < 0.12 ? 'dark' : L > 0.7 ? 'light' : 'mid' },
}, [svg]);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
@@ -241,9 +305,9 @@ function brandMark(size) {
// ── Delivery platforms ───────────────────────────────────────────────────────
const PROVIDERS = [
{ id: 'discord', name: 'Discord', color: '#5865F2' },
{ id: 'slack', name: 'Slack', color: '#2EB67D' },
{ id: 'telegram', name: 'Telegram', color: '#229ED9' },
{ id: 'discord', name: 'Discord' },
{ id: 'slack', name: 'Slack' },
{ id: 'telegram', name: 'Telegram' },
];
function providerName(id) {
@@ -257,7 +321,7 @@ function providerPicker(initial, onChange) {
const buttons = PROVIDERS.map((p) => h('button', {
type: 'button', dataset: { provider: p.id },
onclick: () => { current = current === p.id ? null : p.id; paint(); onChange(current); },
}, [h('span.dot', { style: 'background:' + p.color }), p.name]));
}, [p.name]));
const el = h('div.segmented', {}, buttons);
function paint() { for (const b of buttons) b.classList.toggle('active', b.dataset.provider === current); }
paint();
@@ -521,6 +585,7 @@ function toggleTheme() {
function renderView() {
const el = document.getElementById('view');
if (!el) return;
if (logClock) { clearInterval(logClock); logClock = null; }
const fab = document.querySelector('.fab');
if (fab) fab.remove();
if (state.view === 'settings') el.replaceChildren(...settingsView());
@@ -571,15 +636,61 @@ function vaultView() {
}
function acctRow(a) {
const pushDot = h('span.push-dot', {
title: pushTitle(a),
class: 'push-dot ' + pushClass(a),
});
const slot = h('div.right', { dataset: { slot: a.id } });
paintSlot(slot, a);
return h('div.acct', { dataset: { acct: a.id } }, [
avatarFor(a.issuer, a.label),
h('div.meta', {}, [
h('div.issuer', { text: a.issuer || 'Unnamed' }),
h('div.label', {}, [
pushDot,
h('span', { text: ' ' + (a.label || '—') }),
]),
]),
slot,
]);
}
/* Codes are covered until asked for. The right-hand slot of a row has two
* shapes — dots with an eye, or the live digits with a copy button and the
* countdown ring — and is only rebuilt when it changes shape; the ticker
* updates digits and ring in place. */
function paintSlot(slot, a) {
const c = state.codes.get(a.id);
const mode = c ? 'open' : 'covered';
if (slot.dataset.mode === mode) return;
slot.dataset.mode = mode;
const editBtn = h('button.icon-btn', {
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.edit, { width: 1.9 })]);
if (!c) {
const reveal = h('button.code.covered', {
type: 'button', title: 'Reveal code', 'aria-label': `Reveal ${a.issuer} code`,
onclick: () => revealAccount(a),
}, [h('span.dots', { text: '••• •••' }), icon(ICON.eye, { width: 1.9 })]);
slot.replaceChildren(reveal, editBtn);
return;
}
const codeBtn = h('button.code', {
type: 'button', title: 'Click to copy', dataset: { code: a.id },
}, ['······']);
codeBtn.addEventListener('click', async () => {
const c = state.codes.get(a.id);
if (!c) return;
const ok = await copyText(c.code);
toast(ok ? `Copied ${a.issuer} code` : 'Could not copy', ok ? 'ok' : 'err');
});
onclick: () => copyCode(a),
}, codeSpans(c.code));
codeBtn.dataset.shown = c.code;
const copyBtn = h('button.icon-btn', {
type: 'button', 'aria-label': `Copy ${a.issuer} code`, title: 'Copy',
onclick: () => copyCode(a),
}, [icon(ICON.copy, { width: 1.9 })]);
const ring = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
ring.setAttribute('class', 'ring');
@@ -589,32 +700,27 @@ function acctRow(a) {
'<circle class="fg" cx="13" cy="13" r="11"></circle>';
ring.dataset.ring = a.id;
const avatar = h('div.avatar', { text: initials(a.issuer || a.label) });
slot.replaceChildren(codeBtn, copyBtn, ring, editBtn);
}
const pushDot = h('span.push-dot', {
title: pushTitle(a),
class: 'push-dot ' + pushClass(a),
});
async function revealAccount(a) {
try {
const c = await api('/api/accounts/' + encodeURIComponent(a.id) + '/reveal', { method: 'POST' });
state.codes.set(a.id, {
code: c.code, next: c.next, period: c.period,
expires_at: c.expires_at, revealed_until: c.revealed_until,
});
const slot = document.querySelector(`.right[data-slot="${cssEscape(a.id)}"]`);
if (slot) paintSlot(slot, a);
tick();
} catch (e) { toast(e.message, 'err'); }
}
const row = h('div.acct', { dataset: { acct: a.id } }, [
avatar,
h('div.meta', {}, [
h('div.issuer', { text: a.issuer || 'Unnamed' }),
h('div.label', {}, [
pushDot,
h('span', { text: ' ' + (a.label || '—') }),
]),
]),
h('div.right', {}, [
codeBtn,
ring,
h('button.icon-btn', {
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.edit, { width: 1.9 })]),
]),
]);
return row;
async function copyCode(a) {
const c = state.codes.get(a.id);
if (!c) return;
const ok = await copyText(c.code);
toast(ok ? `Copied ${a.issuer} code` : 'Could not copy', ok ? 'ok' : 'err');
}
function pushClass(a) {
@@ -649,7 +755,11 @@ function tick() {
for (const a of state.accounts) {
const c = state.codes.get(a.id);
if (!c) continue;
// A reveal lapses on its own: back to dots, no request needed.
if (c && c.revealed_until && t >= c.revealed_until) state.codes.delete(a.id);
const slot = document.querySelector(`.right[data-slot="${cssEscape(a.id)}"]`);
if (slot) paintSlot(slot, a);
if (!c || !state.codes.has(a.id)) continue;
const period = (c.period || 30) * 1000;
// Roll over locally the instant the period boundary passes — the next
@@ -712,15 +822,16 @@ async function refreshCodes() {
const rtt = Date.now() - t0;
// Assume the response was generated at the midpoint of the round trip.
state.clockOffsetMs = data.server_time - (t0 + rtt / 2);
// Only revealed accounts come back; anything else goes back under cover.
const seen = new Set();
for (const c of data.codes) {
seen.add(c.id);
state.codes.set(c.id, {
code: c.code, next: c.next, period: c.period,
expires_at: c.expires_at,
expires_at: c.expires_at, revealed_until: c.revealed_until,
});
}
// Drop codes for accounts that no longer exist.
const live = new Set(state.accounts.map((a) => a.id));
for (const id of [...state.codes.keys()]) if (!live.has(id)) state.codes.delete(id);
for (const id of [...state.codes.keys()]) if (!seen.has(id)) state.codes.delete(id);
}
async function reloadAll() {
@@ -1099,7 +1210,7 @@ function openAddSheet() {
boxes.forEach((b) => b.addEventListener('change', sync));
const list = h('div.import-list', {}, rows.map((r, i) => {
const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) });
const av = avatarFor(r.acct.issuer, r.acct.label);
return h('label.import-row', {}, [
boxes[i],
av,
@@ -1230,7 +1341,7 @@ function openAccountSheet(a) {
function settingsView() {
const nav = h('div.settings-nav', {}, [
['delivery', 'Delivery'], ['account', 'Account'], ['about', 'About'],
['delivery', 'Delivery'], ['account', 'Account'], ['log', 'Log'], ['about', 'About'],
].map(([k, lbl]) => h('button.btn' + (state.settingsTab === k ? '.btn-primary' : ''), {
onclick: () => { state.settingsTab = k; renderView(); }, text: lbl,
})));
@@ -1242,6 +1353,7 @@ function settingsView() {
let body;
if (state.settingsTab === 'account') body = accountSettings();
else if (state.settingsTab === 'log') body = logSettings();
else if (state.settingsTab === 'about') body = aboutSettings();
else body = deliverySettings();
@@ -1379,6 +1491,100 @@ function accountSettings() {
]);
}
// ── Log ────────────────────────────────────────────────────────────────────
const LOG_VERBS = {
setup: 'created the admin account',
login: 'signed in',
login_failed: 'failed to sign in',
logout: 'signed out',
reveal: 'revealed',
account_added: 'added',
account_updated: 'updated',
account_deleted: 'removed',
settings_updated: 'changed delivery to',
test_message: 'sent a test message to',
credentials_updated: 'changed the admin',
};
const LOG_ICONS = {
setup: 'shield', login: 'login', login_failed: 'alert', logout: 'logout', reveal: 'eye',
account_added: 'plus', account_updated: 'edit', account_deleted: 'trash',
settings_updated: 'settings', test_message: 'send', credentials_updated: 'key',
};
function timeAgo(ts) {
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (s < 45) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
const hrs = Math.round(m / 60);
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
const d = Math.round(hrs / 24);
if (d === 1) return 'yesterday';
if (d < 14) return `${d} days ago`;
return new Date(ts).toLocaleDateString();
}
let logClock = null;
function logRow(e) {
const verb = LOG_VERBS[e.action] || e.action.replace(/_/g, ' ');
const line = [h('strong', { text: e.actor }), ' ' + verb];
if (e.target) line.push(' ', h('strong', { text: e.target }));
if (e.detail) line.push(' ', h('span.muted', { text: e.detail }));
return h('div.log-row', { title: new Date(e.ts).toLocaleString() }, [
h('div.log-glyph', {}, [icon(ICON[LOG_ICONS[e.action]] || ICON.info, { width: 1.9 })]),
h('div.log-body', {}, [
h('div.log-line', {}, line),
h('div.log-meta', {}, [
h('span.ago', { dataset: { ts: e.ts }, text: timeAgo(e.ts) }),
h('span', { text: e.actor }),
h('span', { text: e.ip || 'unknown address' }),
]),
]),
]);
}
/** Settings → Log: what the server did and for whom, newest first. */
function logSettings() {
const list = h('div.log-list');
const older = h('button.btn.hidden', { type: 'button', text: 'Show older' });
let oldest = null;
async function load() {
older.disabled = true;
try {
const q = '/api/log?limit=100' + (oldest ? '&before=' + oldest : '');
const r = await api(q);
for (const e of r.entries) list.append(logRow(e));
if (r.entries.length) oldest = r.entries[r.entries.length - 1].id;
older.classList.toggle('hidden', !r.has_more);
if (!list.children.length) {
list.append(h('div.log-empty', { text: 'Nothing yet. Reveals, sign-ins and changes will show up here.' }));
}
} catch (e) { toast(e.message, 'err'); }
older.disabled = false;
}
older.addEventListener('click', load);
load();
// Keep "3 minutes ago" honest while the tab is open.
if (logClock) clearInterval(logClock);
logClock = setInterval(() => {
for (const el of document.querySelectorAll('.log-meta .ago')) el.textContent = timeAgo(Number(el.dataset.ts));
}, 30000);
return h('div.card', {}, [
h('div.card-head', {}, [
h('div', {}, [
h('h2', { text: 'Log' }),
h('div.card-sub', { text: 'Every code revealed, sign-in and change, with who did it and from where.' }),
]),
]),
h('div.card-body', {}, [list, h('div', { style: 'margin-top:12px' }, [older])]),
]);
}
function aboutSettings() {
const card = h('div.card', {}, [
h('div.card-head', {}, [h('h2', { text: 'About' })]),
@@ -1389,6 +1595,8 @@ function aboutSettings() {
]),
h('p.small.muted', { style: 'margin-top:14px' , text:
'OpenMFA keeps one live message per account in your Discord, Slack or Telegram channel and edits it in place as each code rolls.' }),
h('p.small.muted', { style: 'margin-top:8px', text:
'Service marks come from Simple Icons (CC0) and Font Awesome Free (CC BY 4.0) and remain trademarks of their owners.' }),
]),
]);
return card;
+6
View File
@@ -0,0 +1,6 @@
// GENERATED by scripts/fetch-icons.py — do not edit by hand.
// Simple Icons 16.30.0 (CC0-1.0) and Font Awesome Free 6.7.2 brands
// (CC BY 4.0). Brand marks are trademarks of their respective owners.
// Shape: slug -> [title, viewBox, [path d, ...], brand colour].
window.OPENMFA_ICONS = {"1password":["1Password","0 0 24 24",["M12 0c6.627 0 12 5.373 12 12 0 6.628-5.373 12-12 12S0 18.628 0 12C0 5.373 5.373 0 12 0m-.893 4.86c-.485 0-.727.001-.913.095a.87.87 0 0 0-.378.379c-.094.185-.095.428-.095.912v2.747c0 .12 0 .182.016.238q.02.075.065.138a1 1 0 0 0 .175.162l.695.564c.113.092.17.139.19.194a.22.22 0 0 1 0 .15c-.02.056-.077.102-.19.194l-.695.564a1 1 0 0 0-.175.162.4.4 0 0 0-.065.138 1 1 0 0 0-.016.238v6.019c0 .485 0 .728.095.913a.87.87 0 0 0 .378.378c.186.094.428.094.913.094h1.786c.485 0 .727 0 .913-.094a.87.87 0 0 0 .378-.378c.095-.185.095-.428.095-.913v-2.747c0-.12 0-.182-.016-.238a.4.4 0 0 0-.065-.138 1 1 0 0 0-.175-.162l-.695-.564c-.113-.092-.17-.138-.191-.193a.22.22 0 0 1 0-.152c.02-.055.078-.1.19-.193l.696-.564a1 1 0 0 0 .175-.162.4.4 0 0 0 .065-.138 1 1 0 0 0 .016-.238V6.246c0-.484 0-.727-.095-.912a.87.87 0 0 0-.378-.379c-.186-.094-.428-.094-.913-.094Z"],"#145FE4"],"airbnb":["Airbnb","0 0 24 24",["M12.001 18.275c-1.353-1.697-2.148-3.184-2.413-4.457-.263-1.027-.16-1.848.291-2.465.477-.71 1.188-1.056 2.121-1.056s1.643.345 2.12 1.063c.446.61.558 1.432.286 2.465-.291 1.298-1.085 2.785-2.412 4.458zm9.601 1.14c-.185 1.246-1.034 2.28-2.2 2.783-2.253.98-4.483-.583-6.392-2.704 3.157-3.951 3.74-7.028 2.385-9.018-.795-1.14-1.933-1.695-3.394-1.695-2.944 0-4.563 2.49-3.927 5.382.37 1.565 1.352 3.343 2.917 5.332-.98 1.085-1.91 1.856-2.732 2.333-.636.344-1.245.558-1.828.609-2.679.399-4.778-2.2-3.825-4.88.132-.345.395-.98.845-1.961l.025-.053c1.464-3.178 3.242-6.79 5.285-10.795l.053-.132.58-1.116c.45-.822.635-1.19 1.351-1.643.346-.21.77-.315 1.246-.315.954 0 1.698.558 2.016 1.007.158.239.345.557.582.953l.558 1.089.08.159c2.041 4.004 3.821 7.608 5.279 10.794l.026.025.533 1.22.318.764c.243.613.294 1.222.213 1.858zm1.22-2.39c-.186-.583-.505-1.271-.9-2.094v-.03c-1.889-4.006-3.642-7.608-5.307-10.844l-.111-.163C15.317 1.461 14.468 0 12.001 0c-2.44 0-3.476 1.695-4.535 3.898l-.081.16c-1.669 3.236-3.421 6.843-5.303 10.847v.053l-.559 1.22c-.21.504-.317.768-.345.847C-.172 20.74 2.611 24 5.98 24c.027 0 .132 0 .265-.027h.372c1.75-.213 3.554-1.325 5.384-3.317 1.829 1.989 3.635 3.104 5.382 3.317h.372c.133.027.239.027.265.027 3.37.003 6.152-3.261 4.802-6.975z"],"#FF5A5F"],"airtable":["Airtable","0 0 24 24",["M11.992 1.966c-.434 0-.87.086-1.28.257L1.779 5.917c-.503.208-.49.908.012 1.116l8.982 3.558a3.266 3.266 0 0 0 2.454 0l8.982-3.558c.503-.196.503-.908.012-1.116l-8.957-3.694a3.255 3.255 0 0 0-1.272-.257zM23.4 8.056a.589.589 0 0 0-.222.045l-10.012 3.877a.612.612 0 0 0-.38.564v8.896a.6.6 0 0 0 .821.552L23.62 18.1a.583.583 0 0 0 .38-.551V8.653a.6.6 0 0 0-.6-.596zM.676 8.095a.644.644 0 0 0-.48.19C.086 8.396 0 8.53 0 8.69v8.355c0 .442.515.737.908.54l6.27-3.006.307-.147 2.969-1.436c.466-.22.43-.908-.061-1.092L.883 8.138a.57.57 0 0 0-.207-.044z"],"#18BFFF"],"akamai":["Akamai","0 0 24 24",["M13.0548 0C6.384 0 .961 5.3802.961 12.0078.961 18.6354 6.3698 24 13.0548 24c.6168 0 .6454-.3572.0859-.5293-4.9349-1.5063-8.5352-6.069-8.5352-11.4629 0-5.4656 3.6725-10.0706 8.6934-11.5195C13.8153.3448 13.6716 0 13.0548 0Zm2.3242 1.8223c-5.2648 0-9.5254 4.2606-9.5254 9.5254 0 1.2193.2285 2.3818.6445 3.4433.1722.459.4454.4584.4024.0137-.0287-.3156-.0567-.6447-.0567-.9746 0-5.2648 4.2606-9.5254 9.5254-9.5254 4.9779 0 6.4698 2.2235 6.6563 2.08.2008-.1577-1.808-4.5624-7.6465-4.5624zm.4687 4.0703c-1.8622.0592-3.651.7168-5.1035 1.8554-.2582.2009-.1567.3284.1445.1993 2.4675-1.076 5.5812-1.1046 8.6368-.043 2.0514.7173 3.2413 1.7364 3.3418 1.6934.1578-.0718-1.1915-2.2226-3.6446-3.1407-1.1135-.4196-2.2576-.6-3.375-.5644z"],"#0096D6"],"amazon":["Amazon","0 0 448 512",["M257.2 162.7c-48.7 1.8-169.5 15.5-169.5 117.5 0 109.5 138.3 114 183.5 43.2 6.5 10.2 35.4 37.5 45.3 46.8l56.8-56S341 288.9 341 261.4V114.3C341 89 316.5 32 228.7 32 140.7 32 94 87 94 136.3l73.5 6.8c16.3-49.5 54.2-49.5 54.2-49.5 40.7-.1 35.5 29.8 35.5 69.1zm0 86.8c0 80-84.2 68-84.2 17.2 0-47.2 50.5-56.7 84.2-57.8v40.6zm136 163.5c-7.7 10-70 67-174.5 67S34.2 408.5 9.7 379c-6.8-7.7 1-11.3 5.5-8.3C88.5 415.2 203 488.5 387.7 401c7.5-3.7 13.3 2 5.5 12zm39.8 2.2c-6.5 15.8-16 26.8-21.2 31-5.5 4.5-9.5 2.7-6.5-3.8s19.3-46.5 12.7-55c-6.5-8.3-37-4.3-48-3.2-10.8 1-13 2-14-.3-2.3-5.7 21.7-15.5 37.5-17.5 15.7-1.8 41-.8 46 5.7 3.7 5.1 0 27.1-6.5 43.1z"],"#FF9900"],"amd":["AMD","0 0 24 24",["M18.324 9.137l1.559 1.56h2.556v2.557L24 14.814V9.137zM2 9.52l-2 4.96h1.309l.37-.982H3.9l.408.982h1.338L3.432 9.52zm4.209 0v4.955h1.238v-3.092l1.338 1.562h.188l1.338-1.556v3.091h1.238V9.52H10.47l-1.592 1.845L7.287 9.52zm6.283 0v4.96h2.057c1.979 0 2.88-1.046 2.88-2.472 0-1.36-.937-2.488-2.747-2.488zm1.237.91h.792c1.17 0 1.63.711 1.63 1.57 0 .728-.372 1.572-1.616 1.572h-.806zm-10.985.273l.791 1.932H2.008zm17.137.307l-1.604 1.603v2.25h2.246l1.604-1.607h-2.246z"],"#ED1C24"],"americanexpress":["American Express","0 0 24 24",["M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-Line truncated
window.OPENMFA_ICON_ALIASES = {"1passwordcom":"1password","agilebits":"1password","airbnbcom":"airbnb","airtablecom":"airtable","akamailinode":"akamai","amazonaws":"aws","amazoncom":"amazon","amazonprime":"amazon","amazonwebservices":"aws","amdcom":"amd","americanexpresscom":"americanexpress","amex":"americanexpress","anthropiccom":"anthropic","aol":"yahoo","appleid":"apple","asanacom":"asana","atlas":"mongodb","atlassianaccount":"atlassian","auth0com":"auth0","awsiam":"aws","azure":"microsoft","b2":"backblaze","backblazecom":"backblaze","basecampcom":"basecamp","battle":"battledotnet","battlenet":"battledotnet","behancenet":"behance","binancecom":"binance","binanceus":"binance","bitbucketorg":"bitbucket","bitwardencom":"bitwarden","blizzard":"battledotnet","blueskysocial":"bluesky","bofa":"bankofamerica","boxcom":"box","bsky":"bluesky","cfaccess":"cloudflare","chasecom":"chase","ciscocom":"cisco","claudeai":"claude","clickupcom":"clickup","cloudflareaccess":"cloudflare","coinbasecom":"coinbase","coinbasepro":"coinbase","confluence":"atlassian","courseraorg":"coursera","datadoghq":"datadog","discordapp":"discord","discordcom":"discord","do":"digitalocean","dockerhub":"docker","dribbblecom":"dribbble","dropboxcom":"dropbox","dsm":"synology","duolingocom":"duolingo","eaaccount":"ea","ebaycom":"ebay","elasticcloud":"elastic","elasticsearch":"elastic","electronicarts":"ea","entra":"microsoft","epic":"epicgames","epicgamesstore":"epicgames","etsycom":"etsy","evernotecom":"evernote","fb":"facebook","figmacom":"figma","fly":"flydotio","flyio":"flydotio","gcp":"googlecloud","geforce":"nvidia","geforcenow":"nvidia","ghostorg":"ghost","githubcom":"github","gitlabcom":"gitlab","godaddycom":"godaddy","gog":"gogdotcom","gogcom":"gogdotcom","googleaccount":"google","googlecloudplatform":"googlecloud","googleworkspace":"google","grafanacloud":"grafana","gsuite":"google","hass":"homeassistant","hetznercloud":"hetzner","hetzneronline":"hetzner","homeassistantio":"homeassistant","hostingercom":"hostinger","hotmail":"microsoft","hotmailcom":"microsoft","hubspotcom":"hubspot","huggingfaceco":"huggingface","humble":"humblebundle","icloudcom":"icloud","iftttcom":"ifttt","ig":"instagram","intelcom":"intel","jirasoftware":"jira","jpmorganchase":"chase","k8s":"kubernetes","keepersecurity":"keeper","kucoincom":"kucoin","lastpasscom":"lastpass","leagueoflegends":"riotgames","limetech":"unraid","linearapp":"linear","linkedincom":"linkedin","linode":"akamai","live":"microsoft","livecom":"microsoft","mailchimpcom":"mailchimp","mastodonsocial":"mastodon","mecom":"icloud","mediumcom":"medium","megaio":"mega","meganz":"mega","meraki":"cisco","microsoft365":"microsoft","microsoftaccount":"microsoft","microsoftazure":"microsoft","mint":"intuit","mirocom":"miro","mongodbatlas":"mongodb","nabucasa":"homeassistant","namecheapcom":"namecheap","netflixcom":"netflix","netlifycom":"netlify","newreliccom":"newrelic","nextcloudcom":"nextcloud","nord":"nordvpn","nordaccount":"nordvpn","notionso":"notion","npmjs":"npm","npmjscom":"npm","nvidiacom":"nvidia","office":"microsoft","office365":"microsoft","oktacom":"okta","onedrive":"microsoft","onepassword":"1password","openseaio":"opensea","origin":"ea","outlook":"microsoft","outlookcom":"microsoft","ovhcloud":"ovh","pagerdutycom":"pagerduty","patreoncom":"patreon","paypalcom":"paypal","playstationnetwork":"playstation","plextv":"plex","porkbuncom":"porkbun","primevideo":"amazon","protondrive":"proton","protonmailcom":"protonmail","protonme":"proton","protonpass":"proton","psn":"playstation","pypiorg":"pypi","quickbooksonline":"quickbooks","railwayapp":"railway","redditcom":"reddit","renderc":"render","revolutcom":"revolut","riot":"riotgames","robloxcom":"roblox","rockstar":"rockstargames","rubygemsorg":"rubygems","salesforcecom":"salesforce","samsungaccount":"samsung","sentryio":"sentry","sfdc":"salesforce","shopifycom":"shopify","signalorg":"signal","slackcom":"slack","snap":"snapchat","socialclub":"rockstargames","sonyaccount":"sony","sonyplaystation":"playstation","spotifycom":"spotify","squareenixaccount":"squareenix","squarespacecom":"squarespace","squareup":"square","steampowered":"steam","stripecom":"stripe","substackcom":"substack","supabasecom":"supabase","synologyaccount":"synology","synologydsm":"synology","tailscalecom":"tailscale","telegramorg":"telegram","tiktokcom":"tiktok","todoistcom":"todoist","transferwise":"wise","trezorio":"trezor","tumblrcom":"tumblr","turbotax":"intuit","tutaio":"tuta","tutanota":"tuta","tutanotacom":"tuta","twitchtv":"twitch","twitter":"x","twittercom":"x","ubercom":"uber","ubisoftconnect":"ubisoft","udemycom":"udemy","ui":"ubiquiti","unifi":"ubiquiti","unificonsole":"ubiquiti","unraidnet":"unraid","uplay":"ubisoft","valorant":"riotgames","valve":"steam","vaultwarden":"bitwarden","vercelcom":"vercel","vimeocom":"vimeo","vultrcom":"vultr","wellsfargocom":"wellsfargo","whatsappbusiness":"whatsapp","wikimedia":"wikipedia","wikimediafoundation":"wikipedia","wisecom":Line truncated
+1
View File
@@ -27,6 +27,7 @@
<body>
<div id="root"></div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="/assets/icons.js?v={{ASSET_VERSION}}"></script>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+8
View File
@@ -32,7 +32,15 @@ pub fn logo_svg() -> &'static str {
LOGO_SVG
}
/// The service-icon pack (generated by `scripts/fetch-icons.py`): brand
/// marks for the account list, matched by issuer name in the browser.
#[must_use]
pub fn icons_js() -> &'static str {
ICONS_JS
}
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
const ICONS_JS: &str = include_str!("icons.js");
+5 -2
View File
@@ -27,8 +27,11 @@
Add accounts by uploading a QR code, scanning with the camera, pasting an
otpauth:// link, or importing a Google Authenticator export (one export QR
carries many accounts; you get a checklist, and duplicates are detected).
The web UI lists every account with its rotating code, the way Google or
Microsoft Authenticator does.
The web UI lists every account behind its service mark, the way Google or
Microsoft Authenticator does. Codes stay covered until you reveal one, and
Settings -> Log records every reveal and sign-in with who and from where.
(In bridge networking the address is Docker's gateway; use a reverse proxy
or host/br0 networking to see real LAN addresses.)
The distinctive part: each account keeps ONE message in your chosen
channel, rewritten in place every time the code rolls. Three accounts means
+7 -7
View File
@@ -21,11 +21,11 @@ Bump `version` in the workspace `Cargo.toml`, update the `IMAGE_TAG` and
README changelog, and commit. Then tag:
```bash
git tag -a v0.1.1 -m "OpenMFA v0.1.1"
git tag -a v0.2 -m "OpenMFA v0.2"
```
Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.1.1`, … — and the image carries
the same number without the `v` (`0.1`, `0.1.1`) plus a moving `latest`.
Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.2`, … — and the image carries
the same number without the `v` (`0.1`, `0.1.1`, `0.2`) plus a moving `latest`.
## 2. Push the code and the tag
@@ -44,7 +44,7 @@ architecture the builder runs on, so this is fast on Apple Silicon — no QEMU.
attestation manifests BuildKit attaches by default.
```bash
docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.1.1 -t gitea.milesward.dev/mward4/openmfa:latest .
docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.2 -t gitea.milesward.dev/mward4/openmfa:latest .
```
## 4. Push the image
@@ -58,7 +58,7 @@ with a bare 401. Write the auth into a throwaway config instead — a config
that already contains an `auths` entry makes the CLI skip the helper:
```bash
export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.1.1 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG
export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.2 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG
```
On a Colima host, `export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"`
@@ -78,7 +78,7 @@ The repo is public, so this needs no credentials — which is exactly how Unraid
will fetch it:
```bash
docker pull gitea.milesward.dev/mward4/openmfa:0.1.1
docker pull gitea.milesward.dev/mward4/openmfa:0.2
```
## 6. Deploy on Unraid
@@ -86,7 +86,7 @@ docker pull gitea.milesward.dev/mward4/openmfa:0.1.1
Add a container with Repository set to:
```
gitea.milesward.dev/mward4/openmfa:0.1.1
gitea.milesward.dev/mward4/openmfa:0.2
```
(or `:latest`). Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`,
+4 -4
View File
@@ -13,9 +13,9 @@
# GITEA_OWNER default: mward4
# GITEA_REPO default: OpenMFA
# IMAGE_NAME default: openmfa
# IMAGE_TAG default: 0.1.1 (also pushed as :latest)
# IMAGE_TAG default: 0.2 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1.1 (tag/branch to build)
# GIT_REF default: v0.2 (tag/branch to build)
# WORKDIR default: /tmp/openmfa-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
@@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-OpenMFA}
IMAGE_NAME=${IMAGE_NAME:-openmfa}
IMAGE_TAG=${IMAGE_TAG:-0.1.1}
IMAGE_TAG=${IMAGE_TAG:-0.2}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1.1}
GIT_REF=${GIT_REF:-v0.2}
WORKDIR=${WORKDIR:-/tmp/openmfa-build}
# OCI distribution rejects uppercase repository paths, so the image name is
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Regenerate crates/webui/src/icons.js — the bundled service-icon pack.
The web UI is fully offline, so the icons are fetched here, once, and
committed as a generated JavaScript file that the binary embeds. Two sources:
* Simple Icons (CC0-1.0) — the bulk of the pack, with brand colours
* Font Awesome Free brands (CC BY 4.0) — a handful of brands that Simple
Icons no longer carries (Amazon, AWS, Microsoft, LinkedIn, Slack, ...)
Brands remain trademarks of their owners; the pack exists so the account list
can show a recognisable mark instead of an initial. Run with no arguments:
python3 scripts/fetch-icons.py
"""
import json
import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SI_VERSION = "16.30.0"
FA_VERSION = "6.7.2"
SI_CDN = f"https://cdn.jsdelivr.net/npm/simple-icons@{SI_VERSION}"
FA_CDN = f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@{FA_VERSION}/svgs/brands"
OUT = Path(__file__).resolve().parent.parent / "crates/webui/src/icons.js"
# Simple Icons slugs. Roughly the services people actually keep in an
# authenticator, weighted towards the homelab / developer crowd.
SIMPLE = """
github google apple facebook meta instagram x discord telegram dropbox box
cloudflare digitalocean netlify vercel gitlab bitbucket atlassian jira trello
notion evernote zoom twitch reddit pinterest snapchat tiktok steam epicgames
playstation ubisoft ea battledotnet riotgames paypal stripe coinbase binance
robinhood venmo wise revolut shopify wordpress squarespace wix godaddy
namecheap porkbun mailchimp hubspot zendesk okta auth0 1password bitwarden
lastpass dashlane keeper nordvpn protonmail proton protonvpn tuta zoho mega
backblaze synology unraid plex nextcloud homeassistant tailscale wireguard
anthropic claude huggingface docker npm pypi rubygems kubernetes akamai vultr
hetzner ovh figma spotify netflix uber airbnb ebay etsy patreon kickstarter
substack medium tumblr mastodon bluesky threads whatsapp signal wikipedia
ubiquiti nvidia intel amd samsung sony cisco mailgun icloud gmail yubico
duolingo coursera udemy ghost hostinger render railway flydotio supabase
firebase mongodb redis elastic datadog newrelic sentry grafana pagerduty
zapier ifttt airtable asana clickup basecamp linear todoist miro dribbble
behance vimeo youtube cashapp americanexpress kucoin trezor opensea
humblebundle rockstargames squareenix roblox gogdotcom googledrive
googlecloud googleplay intuit quickbooks xero square chase bankofamerica
wellsfargo
""".split()
# Font Awesome brand icons filling Simple Icons' gaps: (fa name, title, colour).
FONT_AWESOME = [
("amazon", "Amazon", "#FF9900"),
("aws", "AWS", "#FF9900"),
("microsoft", "Microsoft", "#737373"),
("linkedin", "LinkedIn", "#0A66C2"),
("slack", "Slack", "#4A154B"),
("xbox", "Xbox", "#107C10"),
("salesforce", "Salesforce", "#00A1E0"),
("yahoo", "Yahoo", "#6001D2"),
]
# Extra ways people spell an issuer → slug. Keys are matched after
# normalisation (lower-case, non-alphanumerics dropped), so "Battle.net",
# "battle net" and "BattleNet" all collapse to "battlenet".
ALIASES = {
# Google
"gmail": "gmail", "googleworkspace": "google", "googleaccount": "google",
"gsuite": "google", "googlecloudplatform": "googlecloud", "gcp": "googlecloud",
# Microsoft
"microsoft365": "microsoft", "office365": "microsoft", "office": "microsoft",
"outlook": "microsoft", "outlookcom": "microsoft", "hotmail": "microsoft",
"hotmailcom": "microsoft", "live": "microsoft", "livecom": "microsoft",
"azure": "microsoft", "microsoftazure": "microsoft", "entra": "microsoft",
"onedrive": "microsoft", "xboxlive": "xbox", "microsoftaccount": "microsoft",
# Amazon
"amazonwebservices": "aws", "amazoncom": "amazon", "amazonaws": "aws",
"awsiam": "aws", "amazonprime": "amazon", "primevideo": "amazon",
# Apple
"appleid": "apple", "icloudcom": "icloud", "mecom": "icloud",
# Meta
"fb": "facebook", "ig": "instagram", "twitter": "x", "xcom": "x",
"xtwitter": "x", "twittercom": "x", "whatsappbusiness": "whatsapp",
# Gaming
"blizzard": "battledotnet", "battlenet": "battledotnet", "battle": "battledotnet",
"epic": "epicgames", "epicgamesstore": "epicgames", "riot": "riotgames",
"leagueoflegends": "riotgames", "valorant": "riotgames", "electronicarts": "ea",
"eaaccount": "ea", "origin": "ea", "psn": "playstation", "playstationnetwork": "playstation",
"sonyplaystation": "playstation", "valve": "steam", "steampowered": "steam",
"gog": "gogdotcom", "gogcom": "gogdotcom", "rockstar": "rockstargames",
"socialclub": "rockstargames", "humble": "humblebundle", "ubisoftconnect": "ubisoft",
"uplay": "ubisoft",
# Dev / infra
"githubcom": "github", "gitlabcom": "gitlab", "dockerhub": "docker",
"npmjs": "npm", "npmjscom": "npm", "pypiorg": "pypi", "rubygemsorg": "rubygems",
"k8s": "kubernetes", "do": "digitalocean", "digitalocean": "digitalocean",
"flyio": "flydotio", "fly": "flydotio", "ovhcloud": "ovh", "hetznercloud": "hetzner",
"hetzneronline": "hetzner", "linode": "akamai", "akamailinode": "akamai",
"cloudflareaccess": "cloudflare", "cfaccess": "cloudflare", "huggingfaceco": "huggingface",
"supabasecom": "supabase", "mongodbatlas": "mongodb", "atlas": "mongodb",
"elasticcloud": "elastic", "elasticsearch": "elastic", "grafanacloud": "grafana",
"sentryio": "sentry", "confluence": "atlassian", "atlassianaccount": "atlassian",
"jirasoftware": "jira", "bitbucketorg": "bitbucket", "pagerdutycom": "pagerduty",
"wpcom": "wordpress", "wordpresscom": "wordpress", "wordpressorg": "wordpress",
"namecheapcom": "namecheap", "godaddycom": "godaddy", "porkbuncom": "porkbun",
"netlifycom": "netlify", "vercelcom": "vercel", "renderc": "render", "railwayapp": "railway",
# Homelab
"hass": "homeassistant", "homeassistantio": "homeassistant", "nabucasa": "homeassistant",
"unifi": "ubiquiti", "ui": "ubiquiti", "unificonsole": "ubiquiti", "plextv": "plex",
"synologyaccount": "synology", "synologydsm": "synology", "dsm": "synology",
"nextcloudcom": "nextcloud", "unraidnet": "unraid", "limetech": "unraid",
"tailscalecom": "tailscale",
# Security
"protonme": "proton", "protonmailcom": "protonmail", "protonmail": "protonmail",
"protonpass": "proton", "protondrive": "proton", "tutanota": "tuta", "tutanotacom": "tuta",
"tutaio": "tuta", "1passwordcom": "1password", "onepassword": "1password",
"agilebits": "1password", "bitwardencom": "bitwarden", "vaultwarden": "bitwarden",
"lastpasscom": "lastpass", "keepersecurity": "keeper", "nord": "nordvpn",
"nordaccount": "nordvpn", "yubikey": "yubico", "oktacom": "okta", "auth0com": "auth0",
# Money
"coinbasecom": "coinbase", "coinbasepro": "coinbase", "binancecom": "binance",
"binanceus": "binance", "paypalcom": "paypal", "stripecom": "stripe",
"transferwise": "wise", "wisecom": "wise", "revolutcom": "revolut",
"amex": "americanexpress", "americanexpresscom": "americanexpress",
"jpmorganchase": "chase", "chasecom": "chase", "bofa": "bankofamerica",
"wellsfargocom": "wellsfargo", "cashapp": "cashapp", "squareup": "square",
"quickbooksonline": "quickbooks", "turbotax": "intuit", "mint": "intuit",
# Comms / social
"slackcom": "slack", "discordcom": "discord", "discordapp": "discord",
"zoomus": "zoom", "telegramorg": "telegram", "linkedincom": "linkedin",
"redditcom": "reddit", "twitchtv": "twitch", "tiktokcom": "tiktok", "snap": "snapchat",
"bsky": "bluesky", "blueskysocial": "bluesky", "mastodonsocial": "mastodon",
"signalorg": "signal", "wikimedia": "wikipedia", "wikimediafoundation": "wikipedia",
# Mail hosts (matched from the label's domain when the issuer is unknown)
"yahoocom": "yahoo", "ymail": "yahoo", "ymailcom": "yahoo", "aol": "yahoo",
"zohomail": "zoho", "zohocom": "zoho", "megaio": "mega", "meganz": "mega",
"backblazecom": "backblaze", "b2": "backblaze",
# Misc
"dropboxcom": "dropbox", "boxcom": "box", "notionso": "notion", "evernotecom": "evernote",
"figmacom": "figma", "spotifycom": "spotify", "netflixcom": "netflix",
"ubercom": "uber", "airbnbcom": "airbnb", "ebaycom": "ebay", "etsycom": "etsy",
"patreoncom": "patreon", "substackcom": "substack", "mediumcom": "medium",
"tumblrcom": "tumblr", "shopifycom": "shopify", "squarespacecom": "squarespace",
"wixcom": "wix", "mailchimpcom": "mailchimp", "hubspotcom": "hubspot",
"zendeskcom": "zendesk", "duolingocom": "duolingo", "courseraorg": "coursera",
"udemycom": "udemy", "ghostorg": "ghost", "hostingercom": "hostinger",
"airtablecom": "airtable", "asanacom": "asana", "clickupcom": "clickup",
"basecampcom": "basecamp", "linearapp": "linear", "todoistcom": "todoist",
"mirocom": "miro", "dribbblecom": "dribbble", "behancenet": "behance",
"vimeocom": "vimeo", "youtubecom": "youtube", "yt": "youtube",
"openseaio": "opensea", "trezorio": "trezor", "kucoincom": "kucoin",
"salesforcecom": "salesforce", "sfdc": "salesforce", "nvidiacom": "nvidia",
"geforce": "nvidia", "geforcenow": "nvidia", "samsungaccount": "samsung",
"sonyaccount": "sony", "ciscocom": "cisco", "meraki": "cisco", "intelcom": "intel",
"amdcom": "amd",
"squareenixaccount": "squareenix", "robloxcom": "roblox", "zapiercom": "zapier",
"iftttcom": "ifttt", "vultrcom": "vultr", "datadoghq": "datadog", "newreliccom": "newrelic",
"clickup": "clickup", "anthropiccom": "anthropic", "claudeai": "claude",
}
def norm(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "", s.lower().replace("&", "and"))
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "openmfa-fetch-icons/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8")
def parse_svg(svg: str):
vb = re.search(r'viewBox="([^"]+)"', svg)
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
if not vb or not paths:
raise ValueError("no viewBox/path")
return vb.group(1), paths
def main() -> int:
index = json.loads(fetch(f"{SI_CDN}/data/simple-icons.json"))
by_slug = {i["slug"]: i for i in (index["icons"] if isinstance(index, dict) else index)}
missing = [s for s in SIMPLE if s not in by_slug]
if missing:
print("not in simple-icons:", " ".join(missing), file=sys.stderr)
return 1
jobs = [(s, "si") for s in SIMPLE] + [(name, "fa") for name, _, _ in FONT_AWESOME]
def load(job):
name, src = job
url = f"{SI_CDN}/icons/{name}.svg" if src == "si" else f"{FA_CDN}/{name}.svg"
return job, parse_svg(fetch(url))
icons = {}
aliases = {}
with ThreadPoolExecutor(max_workers=12) as pool:
for (name, src), (vb, paths) in pool.map(load, jobs):
if src == "si":
meta = by_slug[name]
title, hexcol = meta["title"], "#" + meta["hex"]
for aka in (meta.get("aliases") or {}).get("aka", []):
aliases.setdefault(norm(aka), name)
else:
title, hexcol = next((t, c) for n, t, c in FONT_AWESOME if n == name)
icons[name] = [title, vb, paths, hexcol]
aliases.setdefault(norm(title), name)
for k, v in ALIASES.items():
if v not in icons:
print(f"alias {k!r} points at unknown icon {v!r}", file=sys.stderr)
return 1
aliases[norm(k)] = v
# A key that is itself an icon needs no alias entry.
aliases = {k: v for k, v in sorted(aliases.items()) if k not in icons and k}
body = (
"// GENERATED by scripts/fetch-icons.py — do not edit by hand.\n"
f"// Simple Icons {SI_VERSION} (CC0-1.0) and Font Awesome Free {FA_VERSION} brands\n"
"// (CC BY 4.0). Brand marks are trademarks of their respective owners.\n"
"// Shape: slug -> [title, viewBox, [path d, ...], brand colour].\n"
"window.OPENMFA_ICONS = "
+ json.dumps(dict(sorted(icons.items())), separators=(",", ":"), ensure_ascii=False)
+ ";\n"
"window.OPENMFA_ICON_ALIASES = "
+ json.dumps(aliases, separators=(",", ":"), ensure_ascii=False)
+ ";\n"
)
OUT.write_text(body)
print(f"wrote {OUT} — {len(icons)} icons, {len(aliases)} aliases, {len(body)//1024} KiB")
return 0
if __name__ == "__main__":
sys.exit(main())