Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4d899119 | ||
|
|
9fcc59fbc9 | ||
|
|
04a86ead95 |
No files matched your search
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+238
@@ -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())
|
||||
Reference in new issue
Block a user