Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7f09095f | ||
|
|
72b9cbcab8 | ||
|
|
1b652a4ba2 | ||
|
|
316248c28d | ||
|
|
3677c977a2 | ||
|
|
1b9f3251d3 | ||
|
|
85e3a08e52 | ||
|
|
1f456cad48 | ||
|
|
451cdd19da | ||
|
|
3288b8314e | ||
|
|
0dcf760f13 | ||
|
|
ef7ae7116f | ||
|
|
1f4697a885 | ||
|
|
60348b8133 | ||
|
|
b21ade05eb | ||
|
|
7751ebe9b3 | ||
|
|
eecdacd890 |
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
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
name: Build and release image
|
||||||
|
|
||||||
|
# Builds the container image and publishes a Gitea release whenever a version
|
||||||
|
# tag is pushed:
|
||||||
|
#
|
||||||
|
# git tag -a v0.1.3 -m "..." && git push origin v0.1.3
|
||||||
|
#
|
||||||
|
# The image is built, started, and checked for a working /healthz before it is
|
||||||
|
# pushed, so a broken build cannot overwrite a tag that already works. The image
|
||||||
|
# is pushed before the release is created: a release announcing an image that
|
||||||
|
# isn't in the registry yet is worse than no release.
|
||||||
|
#
|
||||||
|
# Pushing to main with a Dockerfile change rebuilds and repushes the current
|
||||||
|
# version so :latest tracks the fix, but deliberately does not touch releases.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'deploy/docker/**'
|
||||||
|
- '.gitea/workflows/release.yml'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to build (defaults to the version in Cargo.toml)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
# One release at a time regardless of ref. A tag push and a main push can land
|
||||||
|
# together and both want to build the same version and move the same `:latest`;
|
||||||
|
# serializing them is the only way to avoid two runs pushing identical tags
|
||||||
|
# concurrently.
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.milesward.dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Everything downstream needs the version and the image name, so both are
|
||||||
|
# resolved once here.
|
||||||
|
#
|
||||||
|
# A tag is the source of truth when there is one; otherwise the workspace
|
||||||
|
# version in Cargo.toml, which is what a main-branch rebuild should ship.
|
||||||
|
# The two are asserted to agree, because a tag that disagrees with the
|
||||||
|
# binary's own version is a support conversation waiting to happen.
|
||||||
|
#
|
||||||
|
# The image name comes from the repository, lowercased because an OCI
|
||||||
|
# registry rejects uppercase paths. That derivation is what lets this file
|
||||||
|
# be copied to a sibling repo unchanged.
|
||||||
|
- name: Resolve version and image
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
MANIFEST=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
if [ "$VERSION" != "$MANIFEST" ]; then
|
||||||
|
echo "::error::tag ${TAG} says ${VERSION} but Cargo.toml says ${MANIFEST}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
[ -n "$VERSION" ] || VERSION="$MANIFEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OWNER=$(printf '%s' "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
NAME=$(printf '%s' "${{ github.repository }}" | sed 's#.*/##' | tr '[:upper:]' '[:lower:]')
|
||||||
|
IMAGE="${OWNER}/${NAME}"
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ref=${REGISTRY}/${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "building ${REGISTRY}/${IMAGE}:${VERSION}"
|
||||||
|
|
||||||
|
# The port the health check below probes. Read from the Dockerfile rather
|
||||||
|
# than hard-coded so this workflow travels with the repo.
|
||||||
|
- name: Resolve health port
|
||||||
|
id: port
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
PORT=$(grep -E '^EXPOSE ' deploy/docker/Dockerfile | head -1 | awk '{print $2}' | cut -d/ -f1)
|
||||||
|
[ -n "$PORT" ] || { echo "::error::no EXPOSE in deploy/docker/Dockerfile"; exit 1; }
|
||||||
|
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "health port ${PORT}"
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker build \
|
||||||
|
--pull \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--file deploy/docker/Dockerfile \
|
||||||
|
--tag "${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}" \
|
||||||
|
--tag "${{ steps.version.outputs.ref }}:latest" \
|
||||||
|
--build-arg "GIT_SHA=$(git rev-parse HEAD)" \
|
||||||
|
.
|
||||||
|
|
||||||
|
# The image is only as good as its ability to boot. This runs the real
|
||||||
|
# entrypoint with a real (root-owned, empty) data volume — the state a
|
||||||
|
# fresh Unraid appdata directory is in — and waits for /healthz.
|
||||||
|
- name: Smoke test
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
REF="${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}"
|
||||||
|
PORT="${{ steps.port.outputs.port }}"
|
||||||
|
NAME="da-smoke-${{ github.run_id }}"
|
||||||
|
VOL="da-smoke-data-${{ github.run_id }}"
|
||||||
|
cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; docker volume rm -f "$VOL" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker run -d --name "$NAME" \
|
||||||
|
--mount "type=volume,src=${VOL},dst=/var/lib/discord-authenticator" \
|
||||||
|
"$REF"
|
||||||
|
|
||||||
|
OK=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$NAME" wget -qO- "http://127.0.0.1:${PORT}/healthz" 2>/dev/null | grep -qx ok; then
|
||||||
|
OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$OK" ]; then
|
||||||
|
echo "::error::the image did not become healthy"
|
||||||
|
docker logs "$NAME" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The binary must report the version we tagged — proof that the tag,
|
||||||
|
# the Cargo.toml bump and the image all agree. A wrong version here
|
||||||
|
# means someone tagged without bumping the workspace version.
|
||||||
|
docker exec "$NAME" /usr/local/bin/discord-authenticator --version | tee /tmp/smoke-version
|
||||||
|
grep -q "${{ steps.version.outputs.version }}" /tmp/smoke-version \
|
||||||
|
|| { echo "::error::the image reports a different version than the tag"; exit 1; }
|
||||||
|
|
||||||
|
# The entrypoint's chown must have made the volume writable by the
|
||||||
|
# service user. A fresh boot only mints vault.key — vault.json and
|
||||||
|
# push-state.json appear once there is something to store — so key
|
||||||
|
# creation is the honest proof that a first run succeeded.
|
||||||
|
docker exec "$NAME" sh -ec '
|
||||||
|
test -f /var/lib/discord-authenticator/vault.key
|
||||||
|
test -s /var/lib/discord-authenticator/vault.key
|
||||||
|
' || { echo "::error::the vault key was not created — check volume permissions"; exit 1; }
|
||||||
|
|
||||||
|
docker logs "$NAME"
|
||||||
|
|
||||||
|
# LOGIN uses the repository owner rather than a fixed name: Gitea's
|
||||||
|
# registry authenticates against the account that owns the token.
|
||||||
|
- name: Push to the registry
|
||||||
|
id: push
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
[ -n "${GITEA_TOKEN:-}" ] || {
|
||||||
|
echo "::error::no REGISTRY_TOKEN secret. Add one under Settings -> Actions -> Secrets"
|
||||||
|
echo "::error::with the write:package scope, then re-run this workflow."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "$GITEA_TOKEN" | docker login "$REGISTRY" --username "${{ github.repository_owner }}" --password-stdin
|
||||||
|
docker push "${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}"
|
||||||
|
docker push "${{ steps.version.outputs.ref }}:latest"
|
||||||
|
docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
# Record what shipped, so the release body can name the exact digest.
|
||||||
|
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' \
|
||||||
|
"${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}" | sed 's/.*@//')
|
||||||
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "pushed ${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }} @ ${DIGEST}"
|
||||||
|
|
||||||
|
# Read the release notes from the tag object itself.
|
||||||
|
#
|
||||||
|
# This is not paranoia about the notes file. Across six runs of this
|
||||||
|
# workflow, act_runner produced worktrees where `git ls-files docs` and
|
||||||
|
# `ls docs/releases` both showed docs/releases/v0.1.3.md while the file
|
||||||
|
# itself was absent — intermittently, from the same commit — and then
|
||||||
|
# `git show HEAD:...` came back empty too, because the shallow fetch had
|
||||||
|
# not brought that blob across. v0.1.3 shipped a placeholder body four
|
||||||
|
# times before this landed.
|
||||||
|
#
|
||||||
|
# There is a second trap, which cost two more runs: `actions/checkout`
|
||||||
|
# fetches the tag *by commit sha* into refs/tags/<tag> (see the
|
||||||
|
# `+<sha>:refs/tags/v0.1.3` in its fetch line). That overwrites the
|
||||||
|
# annotated tag with a lightweight one pointing at the commit, so
|
||||||
|
# `git tag -l` happily reports the commit message as the tag message. The
|
||||||
|
# annotation is still in the object database; it just is not what
|
||||||
|
# `refs/tags/v0.1.3` names any more. Hence the explicit fetch by tag
|
||||||
|
# object sha below, into a namespace checkout cannot collide with.
|
||||||
|
- name: Collect release notes
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
|
||||||
|
NOTES=""
|
||||||
|
TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ -n "$TAG_SHA" ]; then
|
||||||
|
git fetch -q --depth=1 origin "+${TAG_SHA}:refs/release-notes/${TAG}" 2>/dev/null || true
|
||||||
|
if [ "$(git cat-file -t "refs/release-notes/${TAG}" 2>/dev/null)" = "tag" ]; then
|
||||||
|
# cat-file prints the object verbatim: three header lines, a
|
||||||
|
# blank, then the message. `sed '1,/^$/d'` drops the headers,
|
||||||
|
# `tail -n +2` the one-line subject, and the last sed any blank
|
||||||
|
# line left between them. Format specifiers like %(contents:body)
|
||||||
|
# are avoided because they also treat a `#` line as a comment,
|
||||||
|
# which would delete every heading in these notes.
|
||||||
|
NOTES=$(git cat-file -p "refs/release-notes/${TAG}" \
|
||||||
|
| sed '1,/^$/d' | tail -n +2 | sed '/./,$!d' || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$NOTES" ]; then
|
||||||
|
NOTES=$(git show "HEAD:docs/releases/${VERSION}.md" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ -n "$NOTES" ]; then
|
||||||
|
echo "notes: ${#NOTES} bytes from the ${TAG} annotation"
|
||||||
|
else
|
||||||
|
NOTES=$(printf 'Release %s.\n\nSee the repository README for deployment instructions.' "$VERSION")
|
||||||
|
echo "::warning::tag ${TAG} carries no notes body; using a placeholder"
|
||||||
|
fi
|
||||||
|
printf '%s' "$NOTES" > /tmp/notes.md
|
||||||
|
|
||||||
|
# Releases are created only for tags. A main-branch rebuild ships the same
|
||||||
|
# version again and must not rewrite the notes of an existing release.
|
||||||
|
- name: Publish the release
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
DIGEST: ${{ steps.push.outputs.digest }}
|
||||||
|
IMAGE: ${{ steps.version.outputs.image }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
API="https://${REGISTRY}/api/v1"
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
|
||||||
|
# The tag points at this commit, so reading the version here agrees
|
||||||
|
# with the tag by construction — no dependency on an earlier step's
|
||||||
|
# output surviving intact.
|
||||||
|
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
echo "publishing release for ${VERSION} (tag ${TAG})"
|
||||||
|
|
||||||
|
VERSION="$VERSION" NOTES="$(cat /tmp/notes.md)" node -e '
|
||||||
|
const { NOTES, VERSION, IMAGE, DIGEST, REGISTRY } = process.env;
|
||||||
|
const ref = REGISTRY + "/" + IMAGE + ":" + VERSION;
|
||||||
|
require("fs").writeFileSync("/tmp/release.json", JSON.stringify({
|
||||||
|
body: NOTES +
|
||||||
|
"\n\n---\n\n" +
|
||||||
|
"**Image:** `" + ref + "`\n\n" +
|
||||||
|
"**Digest:** `" + DIGEST + "`\n\n" +
|
||||||
|
"```\n" + ref + "\n```\n",
|
||||||
|
name: "Discord Authenticator " + VERSION,
|
||||||
|
tag_name: "v" + VERSION,
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
}));'
|
||||||
|
|
||||||
|
# A re-run of the same tag updates the release in place rather than
|
||||||
|
# failing on the duplicate.
|
||||||
|
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/releases/tags/${TAG}" > /tmp/existing.json || echo '{}' > /tmp/existing.json
|
||||||
|
EXISTING=$(node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(fs.readFileSync("/tmp/existing.json", "utf8"));
|
||||||
|
process.stdout.write(r && r.id ? String(r.id) : "");
|
||||||
|
} catch { process.stdout.write(""); }
|
||||||
|
')
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" --data-binary @/tmp/release.json \
|
||||||
|
"${API}/repos/${REPO}/releases/${EXISTING}" > /dev/null
|
||||||
|
echo "updated release ${TAG}"
|
||||||
|
else
|
||||||
|
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" --data-binary @/tmp/release.json \
|
||||||
|
"${API}/repos/${REPO}/releases" > /dev/null
|
||||||
|
echo "created release ${TAG}"
|
||||||
|
fi
|
||||||
Generated
+7
-6
@@ -436,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-core"
|
name = "da-core"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base32",
|
"base32",
|
||||||
@@ -461,7 +461,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-discord"
|
name = "da-discord"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"da-core",
|
"da-core",
|
||||||
@@ -478,9 +478,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-http-api"
|
name = "da-http-api"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"da-core",
|
"da-core",
|
||||||
"da-discord",
|
"da-discord",
|
||||||
"da-render",
|
"da-render",
|
||||||
@@ -499,7 +500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-render"
|
name = "da-render"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"resvg",
|
"resvg",
|
||||||
@@ -511,7 +512,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-webui"
|
name = "da-webui"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
@@ -541,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "discord-authenticator"
|
name = "discord-authenticator"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# Discord Authenticator
|
<p align="center">
|
||||||
|
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted TOTP authenticator whose codes appear in Discord as **live
|
<h1 align="center">Discord Authenticator</h1>
|
||||||
messages that rewrite themselves** as each code rolls.
|
|
||||||
|
<p align="center">
|
||||||
|
A self-hosted TOTP authenticator whose codes appear in Discord as
|
||||||
|
<strong>live messages that rewrite themselves</strong> as each code rolls.
|
||||||
|
</p>
|
||||||
|
|
||||||
The web UI is what you'd expect from Google or Microsoft Authenticator: a
|
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, each with its six digits and a countdown ring.
|
||||||
@@ -22,6 +28,7 @@ pull), no database, no external services beyond Discord itself.
|
|||||||
- [Using it](#using-it)
|
- [Using it](#using-it)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -107,6 +114,12 @@ What the app does hold up its end on:
|
|||||||
|
|
||||||
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
|
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
|
||||||
contains ciphertext only, and the metadata beside it is deliberately readable.
|
contains ciphertext only, and the metadata beside it is deliberately readable.
|
||||||
|
- `vault.json` is written atomically *and* durably — new bytes are fsynced
|
||||||
|
before the rename that publishes them, and the directory is fsynced after —
|
||||||
|
so an unclean shutdown can't leave a truncated file behind. The previous
|
||||||
|
contents are kept in `vault.json.bak`, and startup falls back to it if
|
||||||
|
`vault.json` is ever unreadable, moving the damaged file aside as
|
||||||
|
`vault.json.corrupt-<timestamp>` rather than refusing to boot.
|
||||||
- The admin password is hashed with **Argon2id**.
|
- The admin password is hashed with **Argon2id**.
|
||||||
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
|
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
|
||||||
keep it off the data volume entirely — then a leaked copy of that volume is
|
keep it off the data volume entirely — then a leaked copy of that volume is
|
||||||
@@ -148,12 +161,72 @@ Bridge networking is correct — this makes outbound HTTPS calls and serves one
|
|||||||
port. Map `/var/lib/discord-authenticator` to somewhere on `appdata` and **back
|
port. Map `/var/lib/discord-authenticator` to somewhere on `appdata` and **back
|
||||||
that path up**: losing it loses every stored secret.
|
that path up**: losing it loses every stored secret.
|
||||||
|
|
||||||
|
### What lives in the data directory
|
||||||
|
|
||||||
|
| File | Holds | If it's lost |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vault.json` | Every account, with its secret sealed | Everything — this is the file to back up |
|
||||||
|
| `vault.json.bak` | The same accounts, as of the last change | Nothing, unless `vault.json` is damaged |
|
||||||
|
| `vault.key` | The vault key (unless `DA_MASTER_KEY` is set) | Every secret becomes unreadable |
|
||||||
|
| `push-state.json` | Discord message ids and last-known push errors | Nothing — tiles are reposted on the next tick |
|
||||||
|
| `auth.json`, `settings.json` | Admin login and webhook config | You redo first-run setup |
|
||||||
|
|
||||||
|
`push-state.json` is deliberately separate from the vault. Codes roll every 30
|
||||||
|
seconds, so the bookkeeping changes constantly while the accounts themselves
|
||||||
|
change only when you edit them; keeping them together meant every rotation
|
||||||
|
rewrote all the secrets.
|
||||||
|
|
||||||
### Building and publishing the image
|
### Building and publishing the image
|
||||||
|
|
||||||
|
Pushing a version tag builds, smoke-tests, pushes and releases — see
|
||||||
|
[Automation](#automation) below. To do it by hand instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
|
||||||
|
Two workflows under `.gitea/workflows/`, both needing a repository secret named
|
||||||
|
`REGISTRY_TOKEN` with the `write:package` scope (Settings → Actions → Secrets).
|
||||||
|
Gitea reserves the `GITEA_` prefix for secret names and rejects it, which is why
|
||||||
|
the secret is not called `GITEA_TOKEN`.
|
||||||
|
|
||||||
|
**`release.yml`** runs when a `vX.Y.Z` tag is pushed. It builds the image,
|
||||||
|
starts it on a fresh root-owned volume, waits for `/healthz`, checks the binary
|
||||||
|
reports the tagged version, pushes to the registry, and only then creates the
|
||||||
|
release. The order matters: a release announcing an image that isn't in the
|
||||||
|
registry yet is worse than no release, and a broken build must not overwrite a
|
||||||
|
tag that already works. The workflow also asserts the tag and `Cargo.toml`
|
||||||
|
agree, which is what catches a tag pushed without a version bump.
|
||||||
|
|
||||||
|
Release notes live in `docs/releases/<version>.md` and are carried into the
|
||||||
|
release through the tag annotation, which is the one thing the runner provably
|
||||||
|
receives — Gitea's shallow checkout has intermittently omitted files from the
|
||||||
|
worktree. So tag with `--cleanup=verbatim`, which stops git treating every `#`
|
||||||
|
line in the notes as a comment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# cut a release
|
||||||
|
sed -i 's/^version = "0.1.3"/version = "0.1.4"/' Cargo.toml # and Cargo.lock
|
||||||
|
git commit -am "v0.1.4: ..."
|
||||||
|
NOTES=docs/releases/v0.1.4.md
|
||||||
|
{ echo "v0.1.4: one-line summary"; echo; cat "$NOTES"; } > /tmp/tagmsg
|
||||||
|
git tag -a v0.1.4 -F /tmp/tagmsg --cleanup=verbatim
|
||||||
|
git push origin main v0.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
**`dependency-check.yml`** runs every six hours and reports drifted crates, the
|
||||||
|
Rust pin and the Docker base images in a single issue titled "Dependency
|
||||||
|
updates" — rewritten in place rather than commented on, and closed
|
||||||
|
automatically when nothing is out of date. `.gitea/scripts/dependency-report.mjs`
|
||||||
|
does the work and is shared verbatim with the sibling Rust repos.
|
||||||
|
|
||||||
|
Nothing is upgraded automatically: the report separates what `cargo update`
|
||||||
|
covers from what crosses a breaking boundary, and leaves the decision to a
|
||||||
|
human. A service holding someone's second factor is the wrong place for
|
||||||
|
unreviewed dependency bumps.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
@@ -169,20 +242,57 @@ Settings.
|
|||||||
photos of a screen, tiny crops and rotated images all work; the decoder walks
|
photos of a screen, tiny crops and rotated images all work; the decoder walks
|
||||||
a ladder of rescale, contrast and rotation attempts before giving up. (HEIC
|
a ladder of rescale, contrast and rotation attempts before giving up. (HEIC
|
||||||
is rejected with a note to screenshot instead — iPhone screenshots are PNG.)
|
is rejected with a note to screenshot instead — iPhone screenshots are PNG.)
|
||||||
|
If the image holds several QR codes, all of them are read.
|
||||||
- **Camera** scanning, using the browser's native barcode detector where it
|
- **Camera** scanning, using the browser's native barcode detector where it
|
||||||
exists and falling back to server-side decoding where it doesn't, which
|
exists and falling back to server-side decoding where it doesn't, which
|
||||||
covers Safari and Firefox. Note that browsers only grant camera access over
|
covers Safari and Firefox. Note that browsers only grant camera access over
|
||||||
HTTPS or on `localhost`.
|
HTTPS or on `localhost`.
|
||||||
- **Paste** an `otpauth://` link, or type a secret by hand.
|
- **Paste** an `otpauth://` link, or type a secret by hand.
|
||||||
|
|
||||||
|
### Importing from Google Authenticator
|
||||||
|
|
||||||
|
Google Authenticator's **Transfer accounts → Export accounts** produces a QR
|
||||||
|
code holding *many* accounts at once, not one — it's an
|
||||||
|
`otpauth-migration://` link wrapping a base64 protobuf. Upload or paste it and
|
||||||
|
you get a checklist of everything inside, with each entry's algorithm, digit
|
||||||
|
count and period shown, and an **Add N accounts** button.
|
||||||
|
|
||||||
|
Details worth knowing:
|
||||||
|
|
||||||
|
- **Large exports are split across several QR codes.** Google shows them as
|
||||||
|
separate screens. Each one reports its position, so the UI tells you
|
||||||
|
"Part 1 of 2" and prompts you to upload the rest. Import them in any order.
|
||||||
|
- **Re-importing is safe.** Anything already in your vault is detected and
|
||||||
|
pre-unticked, so scanning an export twice adds nothing. Duplicates are
|
||||||
|
matched on the secret *and* the naming, so deliberately keeping one secret
|
||||||
|
under two labels still works.
|
||||||
|
- **Entries that can't be imported are named, not silently dropped** — a
|
||||||
|
counter-based (HOTP) account or an MD5 one is listed with the reason.
|
||||||
|
- **Secrets never reach the browser.** The preview returns names and
|
||||||
|
parameters only; on import the client sends back the link plus the indices
|
||||||
|
it selected, and the server re-parses it.
|
||||||
|
|
||||||
|
Two traps this format sets, both of which silently produce *plausible but
|
||||||
|
wrong* codes if you get them backwards: the protobuf `secret` field is **raw
|
||||||
|
bytes**, not base32, and the payload carries **no period field** — every Google
|
||||||
|
export is 30-second TOTP.
|
||||||
|
|
||||||
The parser is deliberately forgiving of real-world URIs: it accepts lowercase
|
The parser is deliberately forgiving of real-world URIs: it accepts lowercase
|
||||||
and `=`-padded secrets, and where the issuer in the path disagrees with the
|
and `=`-padded secrets, and where the issuer in the path disagrees with the
|
||||||
`?issuer=` parameter — which Microsoft does — it takes the query parameter
|
`?issuer=` parameter — which Microsoft does — it takes the query parameter
|
||||||
rather than refusing the account.
|
rather than refusing the account.
|
||||||
|
|
||||||
**In the list**, click any code to copy it. `/` focuses the search box. The
|
**In the list**, click any code to copy it. `/` focuses the search box.
|
||||||
gear on a row renames the account or pauses its Discord push; pausing removes
|
|
||||||
its tile from the channel.
|
The pencil on a row opens **Edit account**. The *Name* is what shows in the
|
||||||
|
list and on the Discord tile; the *Account* field underneath (usually the
|
||||||
|
username or email the code belongs to) is left alone unless you change it —
|
||||||
|
it's editable, since providers sometimes put something unhelpful there. The
|
||||||
|
same sheet pauses Discord push for that account, or removes it entirely.
|
||||||
|
|
||||||
|
A rename reaches Discord within a second or two. It doesn't wait for the code
|
||||||
|
to roll: the push loop normally skips any account whose current code it has
|
||||||
|
already sent, so renaming clears that marker to force one refresh.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,6 +328,7 @@ want the traffic minimal, Embed is the frugal choice.
|
|||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
core/ TOTP, vault, crypto, settings, admin auth — no network
|
core/ TOTP, vault, crypto, settings, admin auth — no network
|
||||||
|
(incl. Google Authenticator export parsing)
|
||||||
render/ SVG → WebP tile rendering (resvg + embedded fonts)
|
render/ SVG → WebP tile rendering (resvg + embedded fonts)
|
||||||
discord/ Webhook client, rate limiter, live-tile push engine
|
discord/ Webhook client, rate limiter, live-tile push engine
|
||||||
webui/ Embedded HTML/CSS/JS, compiled into the binary
|
webui/ Embedded HTML/CSS/JS, compiled into the binary
|
||||||
@@ -241,14 +352,26 @@ JetBrains Mono. Light and dark both ship.
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
101 tests, including:
|
143 tests, including:
|
||||||
|
|
||||||
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
||||||
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG.
|
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
|
||||||
|
and two codes stacked in one screenshot.
|
||||||
|
- **Google export decoding** driven from real protobuf wire bytes: multi-account
|
||||||
|
payloads, batch metadata, every algorithm and digit variant, all four base64
|
||||||
|
spellings, unknown-field forward compatibility, and truncated input. One test
|
||||||
|
asserts the raw-bytes secret handling against the RFC 6238 vector, because
|
||||||
|
that is the mistake that would otherwise ship working-looking wrong codes.
|
||||||
- The **live push cycle against a mock Discord**: that the first call carries
|
- The **live push cycle against a mock Discord**: that the first call carries
|
||||||
`?wait=true`, that later passes PATCH the same message rather than posting
|
`?wait=true`, that later passes PATCH the same message rather than posting
|
||||||
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
||||||
instead of hammered, and that concurrent rollovers are paced apart.
|
instead of hammered, that concurrent rollovers are paced apart, and that a
|
||||||
|
rename refreshes the tile immediately without re-posting it or looping.
|
||||||
|
- **Crash recovery**: that a zero-length `vault.json` is recovered from the
|
||||||
|
backup, that the damaged file is kept rather than deleted, that the recovered
|
||||||
|
accounts are written back so the next boot is ordinary, that an unusable
|
||||||
|
backup still refuses to boot rather than starting empty, and that a code
|
||||||
|
rotation does not rewrite the file holding the secrets.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
@@ -257,6 +380,56 @@ cargo clippy --workspace --all-targets
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### The container won't start: "could not load the vault"
|
||||||
|
|
||||||
|
The app refuses to start on an unreadable `vault.json` because the alternative
|
||||||
|
— booting with an empty vault — invites you to re-add accounts you already have.
|
||||||
|
|
||||||
|
Since v0.1.3 it recovers on its own first: if `vault.json.bak` parses, the
|
||||||
|
accounts are loaded from it, the damaged file is moved to
|
||||||
|
`vault.json.corrupt-<timestamp>`, and a fresh `vault.json` is written. You will
|
||||||
|
see one `ERROR` line saying so, then normal startup.
|
||||||
|
|
||||||
|
If it still refuses, the message names exactly which situation you are in,
|
||||||
|
because they need different responses:
|
||||||
|
|
||||||
|
- **"there is no backup at …"** — no `.bak` was ever created, which means this
|
||||||
|
vault has not completed a single write since backups were introduced. Nothing
|
||||||
|
on disk is recoverable; the unreadable file is all there is.
|
||||||
|
- **"exists but could not be parsed either"** — both copies are gone.
|
||||||
|
- **"is empty as well"** — both are zero bytes.
|
||||||
|
|
||||||
|
Either way, the service will not boot with an empty vault on its own, because
|
||||||
|
that looks identical to a vault you emptied on purpose. Two ways forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. by hand
|
||||||
|
cd /mnt/user/appdata/discord-authenticator
|
||||||
|
mv vault.json "vault.json.broken-$(date +%s)"
|
||||||
|
docker restart Discord-Authenticator
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set `DA_VAULT_RESET=1` in the Unraid template (Docker → the container → Add
|
||||||
|
another Path, Variable, Port or Label → Variable, Key `DA_VAULT_RESET`, Value
|
||||||
|
`1`) and restart. The service boots empty and moves the unreadable file to
|
||||||
|
`vault.json.corrupt-<timestamp>` before writing a fresh vault, so nothing is
|
||||||
|
overwritten. **Remove the variable afterwards** — it only has an effect on the
|
||||||
|
failure path, but leaving it set means the next genuinely-broken vault boots
|
||||||
|
empty instead of telling you about it.
|
||||||
|
|
||||||
|
Then re-import your accounts from your own backups (`otpauth://` links or a
|
||||||
|
Google Authenticator export). Keep `vault.key` — without it, nothing in an old
|
||||||
|
vault file can be decrypted even if the bytes turn up later.
|
||||||
|
|
||||||
|
**If you hit this, upgrading matters more than the workaround.** The cause was
|
||||||
|
a rename being journalled ahead of the data it published, which is fixed by
|
||||||
|
fsyncing before the rename; see `crates/core/src/store_io.rs` for the ordering
|
||||||
|
and why it is the whole trick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+22
-1
@@ -10,16 +10,37 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod migration;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod store_io;
|
||||||
pub mod totp;
|
pub mod totp;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
pub use crypto::{Sealed, VaultKey};
|
pub use crypto::{Sealed, VaultKey};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
pub use migration::{is_migration_uri, parse_migration, ImportBatch, Skipped};
|
||||||
pub use settings::{validate_webhook_url, PushMode, Settings, SettingsStore};
|
pub use settings::{validate_webhook_url, PushMode, Settings, SettingsStore};
|
||||||
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
|
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
|
||||||
pub use vault::{Account, AccountPublic, DiscordState, PushTarget, Vault};
|
pub use vault::{
|
||||||
|
Account, AccountPublic, DiscordState, ImportOutcome, NewAccount, PushTarget, Vault,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse any link the "add an account" flow accepts.
|
||||||
|
///
|
||||||
|
/// A plain `otpauth://` URI yields a batch of one; a Google Authenticator
|
||||||
|
/// `otpauth-migration://` export yields however many accounts it carries. The
|
||||||
|
/// callers upstream — preview and import — therefore only ever deal in
|
||||||
|
/// batches and need no special case for the single-account path.
|
||||||
|
pub fn parse_import(uri: &str) -> Result<ImportBatch> {
|
||||||
|
if is_migration_uri(uri) {
|
||||||
|
return parse_migration(uri);
|
||||||
|
}
|
||||||
|
Ok(ImportBatch {
|
||||||
|
accounts: vec![parse_otpauth(uri)?],
|
||||||
|
..ImportBatch::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Seconds since the Unix epoch.
|
/// Seconds since the Unix epoch.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,733 @@
|
|||||||
|
//! Google Authenticator export parsing (`otpauth-migration://offline?data=…`).
|
||||||
|
//!
|
||||||
|
//! The payload is a base64 protobuf holding many accounts at once. Google
|
||||||
|
//! splits large exports across several QR codes, each carrying `batch_index`
|
||||||
|
//! and `batch_size` so the user knows how many screens to scan.
|
||||||
|
//!
|
||||||
|
//! ## Why the protobuf is hand-decoded
|
||||||
|
//!
|
||||||
|
//! Pulling `prost` would mean either a `protoc` build dependency or a
|
||||||
|
//! generated file checked in, for a schema that is four messages long and has
|
||||||
|
//! not changed since 2020. The wire format we need is two wire types (varint
|
||||||
|
//! and length-delimited), so the reader below is short enough to audit.
|
||||||
|
//! Unknown fields are skipped properly, so a future Google addition won't
|
||||||
|
//! break parsing.
|
||||||
|
//!
|
||||||
|
//! Schema (Google Authenticator 5.10, reverse-engineered but stable):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! MigrationPayload { otp_parameters=1 (repeated), version=2,
|
||||||
|
//! batch_size=3, batch_index=4, batch_id=5 }
|
||||||
|
//! OtpParameters { secret=1 (bytes), name=2, issuer=3,
|
||||||
|
//! algorithm=4, digits=5, type=6, counter=7 }
|
||||||
|
//! Algorithm { UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4 }
|
||||||
|
//! DigitCount { UNSPECIFIED=0, SIX=1, EIGHT=2 }
|
||||||
|
//! OtpType { UNSPECIFIED=0, HOTP=1, TOTP=2 }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Two traps worth naming, because both silently produce wrong codes:
|
||||||
|
//!
|
||||||
|
//! * **`secret` is raw bytes, not base32.** It has to be base32-encoded to
|
||||||
|
//! become the secret an `otpauth://` URI would carry.
|
||||||
|
//! * **There is no period field.** Google only ever exports 30-second TOTP,
|
||||||
|
//! so 30 is assumed rather than guessed at.
|
||||||
|
|
||||||
|
use crate::totp::{Algorithm, OtpAuth, Totp};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
/// Google never exports a period; every entry is the 30-second default.
|
||||||
|
const ASSUMED_PERIOD: u64 = 30;
|
||||||
|
|
||||||
|
/// An account present in the export that we deliberately did not import.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Skipped {
|
||||||
|
pub name: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded export QR code.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ImportBatch {
|
||||||
|
pub accounts: Vec<OtpAuth>,
|
||||||
|
pub skipped: Vec<Skipped>,
|
||||||
|
/// 0-based index of this QR within the export, when present.
|
||||||
|
pub batch_index: Option<i32>,
|
||||||
|
/// Total number of QR codes in the export, when present.
|
||||||
|
pub batch_size: Option<i32>,
|
||||||
|
/// Identifies which export a QR belongs to, so codes from two different
|
||||||
|
/// export sessions aren't mistaken for parts of one.
|
||||||
|
pub batch_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportBatch {
|
||||||
|
/// Human-readable "Part 2 of 3", when the export was split.
|
||||||
|
pub fn part_label(&self) -> Option<String> {
|
||||||
|
match (self.batch_index, self.batch_size) {
|
||||||
|
(Some(i), Some(n)) if n > 1 => Some(format!("Part {} of {n}", i + 1)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this a Google Authenticator export link?
|
||||||
|
pub fn is_migration_uri(uri: &str) -> bool {
|
||||||
|
uri.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("otpauth-migration://")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `otpauth-migration://offline?data=…` link into its accounts.
|
||||||
|
pub fn parse_migration(uri: &str) -> Result<ImportBatch> {
|
||||||
|
let uri = uri.trim();
|
||||||
|
let parsed = url::Url::parse(uri)
|
||||||
|
.map_err(|e| Error::invalid(format!("not a valid export link: {e}")))?;
|
||||||
|
|
||||||
|
if !parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
|
||||||
|
return Err(Error::invalid(
|
||||||
|
"that is not a Google Authenticator export link",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `url` percent-decodes query values for us. Google's base64 contains
|
||||||
|
// `+` and `/`, which arrive percent-encoded in a well-formed URI.
|
||||||
|
let data = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case("data"))
|
||||||
|
.map(|(_, v)| v.into_owned())
|
||||||
|
.ok_or_else(|| Error::invalid("the export link has no ?data= parameter"))?;
|
||||||
|
|
||||||
|
if data.trim().is_empty() {
|
||||||
|
return Err(Error::invalid("the export link's data parameter is empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = decode_base64_lenient(&data)?;
|
||||||
|
decode_payload(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode base64 across the variants that turn up in the wild.
|
||||||
|
///
|
||||||
|
/// Google emits standard-alphabet padded base64, but the link is often
|
||||||
|
/// copied through tools that swap in the URL-safe alphabet or strip padding.
|
||||||
|
/// Trying all four costs nothing and avoids an unhelpful failure on a link
|
||||||
|
/// that is perfectly recoverable.
|
||||||
|
fn decode_base64_lenient(s: &str) -> Result<Vec<u8>> {
|
||||||
|
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
// Whitespace can be introduced by line-wrapping when a link is pasted.
|
||||||
|
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||||
|
|
||||||
|
// A macro rather than a slice of trait objects: base64's `Engine` is not
|
||||||
|
// dyn-compatible, and the alternatives are all zero-sized consts anyway.
|
||||||
|
macro_rules! try_decode {
|
||||||
|
($engine:expr) => {
|
||||||
|
if let Ok(bytes) = $engine.decode(&cleaned) {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try_decode!(STANDARD);
|
||||||
|
try_decode!(STANDARD_NO_PAD);
|
||||||
|
try_decode!(URL_SAFE);
|
||||||
|
try_decode!(URL_SAFE_NO_PAD);
|
||||||
|
Err(Error::invalid(
|
||||||
|
"the export link's data is not valid base64 — copy the whole link, \
|
||||||
|
including everything after data=",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Minimal protobuf reader ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
buf: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(buf: &'a [u8]) -> Self {
|
||||||
|
Self { buf, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done(&self) -> bool {
|
||||||
|
self.pos >= self.buf.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn varint(&mut self) -> Option<u64> {
|
||||||
|
let mut value: u64 = 0;
|
||||||
|
let mut shift = 0u32;
|
||||||
|
loop {
|
||||||
|
let byte = *self.buf.get(self.pos)?;
|
||||||
|
self.pos += 1;
|
||||||
|
// 10 groups of 7 bits is the most a u64 can hold; more than that
|
||||||
|
// is a malformed (or hostile) message rather than a big number.
|
||||||
|
if shift > 63 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
value |= u64::from(byte & 0x7f) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `(field_number, wire_type)`.
|
||||||
|
fn tag(&mut self) -> Option<(u32, u8)> {
|
||||||
|
let key = self.varint()?;
|
||||||
|
let field = u32::try_from(key >> 3).ok()?;
|
||||||
|
let wire = (key & 0x07) as u8;
|
||||||
|
if field == 0 {
|
||||||
|
return None; // field 0 is illegal; treat as corruption
|
||||||
|
}
|
||||||
|
Some((field, wire))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn length_delimited(&mut self) -> Option<&'a [u8]> {
|
||||||
|
let len = usize::try_from(self.varint()?).ok()?;
|
||||||
|
let end = self.pos.checked_add(len)?;
|
||||||
|
let slice = self.buf.get(self.pos..end)?;
|
||||||
|
self.pos = end;
|
||||||
|
Some(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance past a field we don't care about, so unknown additions to the
|
||||||
|
/// schema don't desynchronise the parse.
|
||||||
|
fn skip(&mut self, wire: u8) -> Option<()> {
|
||||||
|
match wire {
|
||||||
|
0 => {
|
||||||
|
self.varint()?;
|
||||||
|
}
|
||||||
|
1 => self.pos = self.pos.checked_add(8)?,
|
||||||
|
2 => {
|
||||||
|
self.length_delimited()?;
|
||||||
|
}
|
||||||
|
5 => self.pos = self.pos.checked_add(4)?,
|
||||||
|
// 3/4 are the deprecated start/end-group types, which this schema
|
||||||
|
// never uses. Anything else is corruption.
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
if self.pos > self.buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt() -> Error {
|
||||||
|
Error::invalid(
|
||||||
|
"the export data could not be decoded — re-export from Google \
|
||||||
|
Authenticator and scan the fresh QR code",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_payload(raw: &[u8]) -> Result<ImportBatch> {
|
||||||
|
let mut out = ImportBatch::default();
|
||||||
|
let mut r = Reader::new(raw);
|
||||||
|
|
||||||
|
while !r.done() {
|
||||||
|
let (field, wire) = r.tag().ok_or_else(corrupt)?;
|
||||||
|
match (field, wire) {
|
||||||
|
(1, 2) => {
|
||||||
|
let entry = r.length_delimited().ok_or_else(corrupt)?;
|
||||||
|
match decode_entry(entry) {
|
||||||
|
Ok(Some(account)) => out.accounts.push(account),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(skipped) => out.skipped.push(skipped),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(3, 0) => out.batch_size = r.varint().ok_or_else(corrupt)?.try_into().ok(),
|
||||||
|
(4, 0) => out.batch_index = r.varint().ok_or_else(corrupt)?.try_into().ok(),
|
||||||
|
(5, 0) => out.batch_id = r.varint().ok_or_else(corrupt)?.try_into().ok(),
|
||||||
|
// version (2) and anything Google adds later.
|
||||||
|
_ => r.skip(wire).ok_or_else(corrupt)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.accounts.is_empty() && out.skipped.is_empty() {
|
||||||
|
return Err(Error::invalid("that export link contains no accounts"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one `OtpParameters`. `Err(Skipped)` means "present but not
|
||||||
|
/// importable", which the UI reports rather than silently dropping.
|
||||||
|
fn decode_entry(buf: &[u8]) -> std::result::Result<Option<OtpAuth>, Skipped> {
|
||||||
|
let mut secret: Vec<u8> = Vec::new();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut issuer = String::new();
|
||||||
|
let mut algorithm = Algorithm::Sha1;
|
||||||
|
let mut digits: u8 = 6;
|
||||||
|
let mut otp_type: u64 = 2; // default to TOTP when unspecified
|
||||||
|
let mut bad_algorithm: Option<u64> = None;
|
||||||
|
|
||||||
|
let mut r = Reader::new(buf);
|
||||||
|
while !r.done() {
|
||||||
|
let Some((field, wire)) = r.tag() else { break };
|
||||||
|
match (field, wire) {
|
||||||
|
(1, 2) => secret = r.length_delimited().unwrap_or_default().to_vec(),
|
||||||
|
(2, 2) => {
|
||||||
|
name =
|
||||||
|
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
|
||||||
|
}
|
||||||
|
(3, 2) => {
|
||||||
|
issuer =
|
||||||
|
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
|
||||||
|
}
|
||||||
|
(4, 0) => match r.varint().unwrap_or(1) {
|
||||||
|
0 | 1 => algorithm = Algorithm::Sha1, // unspecified means SHA1
|
||||||
|
2 => algorithm = Algorithm::Sha256,
|
||||||
|
3 => algorithm = Algorithm::Sha512,
|
||||||
|
other => bad_algorithm = Some(other), // MD5 and anything new
|
||||||
|
},
|
||||||
|
(5, 0) => {
|
||||||
|
digits = match r.varint().unwrap_or(1) {
|
||||||
|
2 => 8,
|
||||||
|
_ => 6, // unspecified and SIX both mean 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(6, 0) => otp_type = r.varint().unwrap_or(2),
|
||||||
|
_ => {
|
||||||
|
if r.skip(wire).is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split "Issuer:account", the same shape an otpauth:// label uses. The
|
||||||
|
// dedicated issuer field wins when both are present.
|
||||||
|
let (label_issuer, account) = match name.split_once(':') {
|
||||||
|
Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()),
|
||||||
|
None => (None, name.trim().to_string()),
|
||||||
|
};
|
||||||
|
let issuer_final = if issuer.trim().is_empty() {
|
||||||
|
label_issuer.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
issuer.trim().to_string()
|
||||||
|
};
|
||||||
|
let display = if account.is_empty() && issuer_final.is_empty() {
|
||||||
|
"(unnamed)".to_string()
|
||||||
|
} else if account.is_empty() {
|
||||||
|
issuer_final.clone()
|
||||||
|
} else {
|
||||||
|
account.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if otp_type == 1 {
|
||||||
|
return Err(Skipped {
|
||||||
|
name: display,
|
||||||
|
reason: "counter-based (HOTP) codes aren't supported".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(alg) = bad_algorithm {
|
||||||
|
let which = if alg == 4 {
|
||||||
|
"MD5"
|
||||||
|
} else {
|
||||||
|
"an unknown algorithm"
|
||||||
|
};
|
||||||
|
return Err(Skipped {
|
||||||
|
name: display,
|
||||||
|
reason: format!("uses {which}, which isn't supported"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if secret.is_empty() {
|
||||||
|
return Err(Skipped {
|
||||||
|
name: display,
|
||||||
|
reason: "has no secret".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The protobuf carries raw secret bytes; Totp expects the same, so no
|
||||||
|
// base32 round-trip is needed here (only for display/export).
|
||||||
|
match Totp::new(secret, algorithm, digits, ASSUMED_PERIOD) {
|
||||||
|
Ok(totp) => Ok(Some(OtpAuth {
|
||||||
|
totp,
|
||||||
|
issuer: if issuer_final.is_empty() {
|
||||||
|
account.clone()
|
||||||
|
} else {
|
||||||
|
issuer_final
|
||||||
|
},
|
||||||
|
label: account,
|
||||||
|
})),
|
||||||
|
Err(e) => Err(Skipped {
|
||||||
|
name: display,
|
||||||
|
reason: e.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::totp::encode_secret;
|
||||||
|
|
||||||
|
// Minimal protobuf *encoder*, so these tests drive the parser with real
|
||||||
|
// wire bytes rather than a mock.
|
||||||
|
|
||||||
|
fn put_varint(v: u64, out: &mut Vec<u8>) {
|
||||||
|
let mut v = v;
|
||||||
|
loop {
|
||||||
|
let mut byte = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
if v != 0 {
|
||||||
|
byte |= 0x80;
|
||||||
|
}
|
||||||
|
out.push(byte);
|
||||||
|
if v == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_tag(field: u32, wire: u8, out: &mut Vec<u8>) {
|
||||||
|
put_varint((u64::from(field) << 3) | u64::from(wire), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
put_tag(field, 2, out);
|
||||||
|
put_varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
|
||||||
|
put_tag(field, 0, out);
|
||||||
|
put_varint(v, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Entry {
|
||||||
|
secret: Vec<u8>,
|
||||||
|
name: &'static str,
|
||||||
|
issuer: &'static str,
|
||||||
|
algorithm: u64,
|
||||||
|
digits: u64,
|
||||||
|
otp_type: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Entry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
secret: b"12345678901234567890".to_vec(),
|
||||||
|
name: "[email protected]",
|
||||||
|
issuer: "Cloudflare",
|
||||||
|
algorithm: 1, // SHA1
|
||||||
|
digits: 1, // SIX
|
||||||
|
otp_type: 2, // TOTP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_entry(e: &Entry) -> Vec<u8> {
|
||||||
|
let mut b = Vec::new();
|
||||||
|
put_bytes(1, &e.secret, &mut b);
|
||||||
|
put_bytes(2, e.name.as_bytes(), &mut b);
|
||||||
|
put_bytes(3, e.issuer.as_bytes(), &mut b);
|
||||||
|
put_uint(4, e.algorithm, &mut b);
|
||||||
|
put_uint(5, e.digits, &mut b);
|
||||||
|
put_uint(6, e.otp_type, &mut b);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_payload(entries: &[Entry], batch: Option<(i32, i32, i32)>) -> Vec<u8> {
|
||||||
|
let mut b = Vec::new();
|
||||||
|
for e in entries {
|
||||||
|
put_bytes(1, &encode_entry(e), &mut b);
|
||||||
|
}
|
||||||
|
put_uint(2, 1, &mut b); // version
|
||||||
|
if let Some((size, index, id)) = batch {
|
||||||
|
put_uint(3, size as u64, &mut b);
|
||||||
|
put_uint(4, index as u64, &mut b);
|
||||||
|
put_uint(5, id as u64, &mut b);
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a link the way Google does: standard base64, percent-encoded
|
||||||
|
/// into the query string.
|
||||||
|
fn make_uri(payload: &[u8]) -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||||
|
let escaped = b64
|
||||||
|
.replace('+', "%2B")
|
||||||
|
.replace('/', "%2F")
|
||||||
|
.replace('=', "%3D");
|
||||||
|
format!("otpauth-migration://offline?data={escaped}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uri_for(entries: &[Entry]) -> String {
|
||||||
|
make_uri(&encode_payload(entries, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_migration_links() {
|
||||||
|
assert!(is_migration_uri("otpauth-migration://offline?data=abc"));
|
||||||
|
assert!(is_migration_uri("OTPAUTH-MIGRATION://offline?data=abc"));
|
||||||
|
assert!(!is_migration_uri("otpauth://totp/X?secret=A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_a_single_account() {
|
||||||
|
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
|
||||||
|
assert_eq!(b.accounts.len(), 1);
|
||||||
|
assert!(b.skipped.is_empty());
|
||||||
|
let a = &b.accounts[0];
|
||||||
|
assert_eq!(a.issuer, "Cloudflare");
|
||||||
|
assert_eq!(a.label, "[email protected]");
|
||||||
|
assert_eq!(a.totp.digits, 6);
|
||||||
|
assert_eq!(a.totp.period, 30, "Google exports are always 30s");
|
||||||
|
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_is_raw_bytes_not_base32() {
|
||||||
|
// The single most dangerous trap: treating the protobuf secret as if
|
||||||
|
// it were already base32 yields plausible-looking but wrong codes.
|
||||||
|
// The RFC 6238 vector secret is ASCII "12345678901234567890".
|
||||||
|
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
|
||||||
|
let totp = &b.accounts[0].totp;
|
||||||
|
assert_eq!(totp.secret, b"12345678901234567890");
|
||||||
|
// And it base32-encodes to the well-known test value.
|
||||||
|
assert_eq!(
|
||||||
|
encode_secret(&totp.secret),
|
||||||
|
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||||
|
);
|
||||||
|
// Which means it produces the official RFC 6238 code at t=59.
|
||||||
|
assert_eq!(totp.code_at(59), "287082");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_several_accounts_from_one_qr() {
|
||||||
|
let entries = vec![
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
issuer: "Cloudflare",
|
||||||
|
..Entry::default()
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
issuer: "Plex",
|
||||||
|
..Entry::default()
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
issuer: "Ubisoft",
|
||||||
|
..Entry::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let b = parse_migration(&uri_for(&entries)).unwrap();
|
||||||
|
assert_eq!(b.accounts.len(), 3);
|
||||||
|
let issuers: Vec<_> = b.accounts.iter().map(|a| a.issuer.as_str()).collect();
|
||||||
|
assert_eq!(issuers, ["Cloudflare", "Plex", "Ubisoft"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_batch_position() {
|
||||||
|
let uri = make_uri(&encode_payload(&[Entry::default()], Some((3, 1, 42))));
|
||||||
|
let b = parse_migration(&uri).unwrap();
|
||||||
|
assert_eq!(b.batch_size, Some(3));
|
||||||
|
assert_eq!(b.batch_index, Some(1));
|
||||||
|
assert_eq!(b.batch_id, Some(42));
|
||||||
|
// batch_index is 0-based; humans count from one.
|
||||||
|
assert_eq!(b.part_label().as_deref(), Some("Part 2 of 3"));
|
||||||
|
|
||||||
|
// A single-QR export shouldn't claim to be part of a series.
|
||||||
|
let solo = parse_migration(&uri_for(&[Entry::default()])).unwrap();
|
||||||
|
assert!(solo.part_label().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn honours_algorithm_and_digit_variants() {
|
||||||
|
for (code, want) in [(2, Algorithm::Sha256), (3, Algorithm::Sha512)] {
|
||||||
|
let e = Entry {
|
||||||
|
algorithm: code,
|
||||||
|
..Entry::default()
|
||||||
|
};
|
||||||
|
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||||
|
assert_eq!(b.accounts[0].totp.algorithm, want);
|
||||||
|
}
|
||||||
|
let e = Entry {
|
||||||
|
digits: 2,
|
||||||
|
..Entry::default()
|
||||||
|
}; // EIGHT
|
||||||
|
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||||
|
assert_eq!(b.accounts[0].totp.digits, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unspecified_enums_fall_back_to_the_common_defaults() {
|
||||||
|
let e = Entry {
|
||||||
|
algorithm: 0,
|
||||||
|
digits: 0,
|
||||||
|
otp_type: 0,
|
||||||
|
..Entry::default()
|
||||||
|
};
|
||||||
|
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||||
|
assert_eq!(b.accounts[0].totp.algorithm, Algorithm::Sha1);
|
||||||
|
assert_eq!(b.accounts[0].totp.digits, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_unsupported_entries_but_keeps_the_rest() {
|
||||||
|
let entries = vec![
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
..Entry::default()
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
otp_type: 1,
|
||||||
|
..Entry::default()
|
||||||
|
}, // HOTP
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
algorithm: 4,
|
||||||
|
..Entry::default()
|
||||||
|
}, // MD5
|
||||||
|
Entry {
|
||||||
|
name: "[email protected]",
|
||||||
|
secret: vec![],
|
||||||
|
..Entry::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let b = parse_migration(&uri_for(&entries)).unwrap();
|
||||||
|
|
||||||
|
// One good account survives; the other three are reported, not dropped.
|
||||||
|
assert_eq!(b.accounts.len(), 1);
|
||||||
|
assert_eq!(b.accounts[0].label, "[email protected]");
|
||||||
|
assert_eq!(b.skipped.len(), 3);
|
||||||
|
|
||||||
|
let reason = |n: &str| {
|
||||||
|
b.skipped
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == n)
|
||||||
|
.map(|s| s.reason.clone())
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert!(reason("[email protected]").contains("HOTP"));
|
||||||
|
assert!(reason("[email protected]").contains("MD5"));
|
||||||
|
assert!(reason("[email protected]").contains("no secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_may_carry_the_issuer_prefix() {
|
||||||
|
let e = Entry {
|
||||||
|
name: "Cloudflare:[email protected]",
|
||||||
|
issuer: "",
|
||||||
|
..Entry::default()
|
||||||
|
};
|
||||||
|
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||||
|
assert_eq!(b.accounts[0].issuer, "Cloudflare");
|
||||||
|
assert_eq!(b.accounts[0].label, "[email protected]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issuer_field_wins_over_the_name_prefix() {
|
||||||
|
let e = Entry {
|
||||||
|
name: "Stale:[email protected]",
|
||||||
|
issuer: "Microsoft",
|
||||||
|
..Entry::default()
|
||||||
|
};
|
||||||
|
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||||
|
assert_eq!(b.accounts[0].issuer, "Microsoft");
|
||||||
|
assert_eq!(b.accounts[0].label, "[email protected]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_every_base64_variant() {
|
||||||
|
use base64::engine::general_purpose::{
|
||||||
|
STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD,
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
// A payload whose base64 is guaranteed to contain +/ (or -_).
|
||||||
|
let entries: Vec<Entry> = (0..4)
|
||||||
|
.map(|i| Entry {
|
||||||
|
secret: vec![0xfb, 0xff, 0xbf, i],
|
||||||
|
..Entry::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let raw = encode_payload(&entries, None);
|
||||||
|
|
||||||
|
for (name, encoded) in [
|
||||||
|
("standard", STANDARD.encode(&raw)),
|
||||||
|
("standard-nopad", STANDARD_NO_PAD.encode(&raw)),
|
||||||
|
("urlsafe", URL_SAFE.encode(&raw)),
|
||||||
|
("urlsafe-nopad", URL_SAFE_NO_PAD.encode(&raw)),
|
||||||
|
] {
|
||||||
|
let escaped = encoded
|
||||||
|
.replace('+', "%2B")
|
||||||
|
.replace('/', "%2F")
|
||||||
|
.replace('=', "%3D");
|
||||||
|
let uri = format!("otpauth-migration://offline?data={escaped}");
|
||||||
|
let b = parse_migration(&uri).unwrap_or_else(|e| panic!("{name} should decode: {e}"));
|
||||||
|
assert_eq!(b.accounts.len(), 4, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_fields_do_not_break_parsing() {
|
||||||
|
// Forward compatibility: if Google adds a field, we must skip it
|
||||||
|
// rather than desynchronise and mangle everything after it.
|
||||||
|
let mut entry = encode_entry(&Entry::default());
|
||||||
|
put_bytes(99, b"some future string", &mut entry);
|
||||||
|
put_uint(98, 1234, &mut entry);
|
||||||
|
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
put_bytes(1, &entry, &mut payload);
|
||||||
|
put_bytes(77, b"future payload field", &mut payload);
|
||||||
|
put_uint(76, 5, &mut payload);
|
||||||
|
|
||||||
|
let b = parse_migration(&make_uri(&payload)).unwrap();
|
||||||
|
assert_eq!(b.accounts.len(), 1);
|
||||||
|
assert_eq!(b.accounts[0].issuer, "Cloudflare");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_input_clearly() {
|
||||||
|
assert!(parse_migration("otpauth-migration://offline").is_err());
|
||||||
|
assert!(parse_migration("otpauth-migration://offline?data=").is_err());
|
||||||
|
assert!(parse_migration("otpauth-migration://offline?data=!!!not base64!!!").is_err());
|
||||||
|
assert!(parse_migration("otpauth://totp/X?secret=JBSWY3DPEHPK3PXP").is_err());
|
||||||
|
assert!(parse_migration("nonsense").is_err());
|
||||||
|
|
||||||
|
// Truncated protobuf: a length prefix that runs past the buffer.
|
||||||
|
let uri = make_uri(&[0x0a, 0x7f, 0x01, 0x02]);
|
||||||
|
assert!(parse_migration(&uri).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_with_no_accounts_is_an_error() {
|
||||||
|
// Version field only, no entries.
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
put_uint(2, 1, &mut payload);
|
||||||
|
let e = parse_migration(&make_uri(&payload))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(e.contains("no accounts"), "got: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_export_round_trips() {
|
||||||
|
// Google splits at roughly ten per QR; make sure nothing chokes on a
|
||||||
|
// full batch, and that every secret survives intact.
|
||||||
|
let entries: Vec<Entry> = (0..10)
|
||||||
|
.map(|i| Entry {
|
||||||
|
secret: vec![i; 20],
|
||||||
|
name: "[email protected]",
|
||||||
|
..Entry::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let b = parse_migration(&uri_for(&entries)).unwrap();
|
||||||
|
assert_eq!(b.accounts.len(), 10);
|
||||||
|
for (i, a) in b.accounts.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
a.totp.secret,
|
||||||
|
vec![i as u8; 20],
|
||||||
|
"entry {i} secret corrupted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Crash-safe JSON persistence.
|
||||||
|
//!
|
||||||
|
//! Every store in this crate used to do its own
|
||||||
|
//! `fs::write(tmp)` → `fs::rename(tmp, path)`. The rename is atomic, so a
|
||||||
|
//! reader never sees a half-written file — but *durability* is a separate
|
||||||
|
//! promise, and that pattern does not make it.
|
||||||
|
//!
|
||||||
|
//! Without an `fsync` before the rename, the new file's data blocks are still
|
||||||
|
//! only in the page cache when the rename is journalled. On ext4 (`data=ordered`,
|
||||||
|
//! the default) a crash or hard power-off can therefore commit the rename while
|
||||||
|
//! the data never lands, leaving the destination **present and zero-length**.
|
||||||
|
//! That is exactly the failure this module exists to prevent: a `vault.json`
|
||||||
|
//! that exists, parses as nothing, and refuses to let the service start.
|
||||||
|
//!
|
||||||
|
//! To make the rename durable the *data* must be on disk before it happens, and
|
||||||
|
//! the rename itself must then be flushed by syncing the containing directory.
|
||||||
|
//! That ordering is the whole trick:
|
||||||
|
//!
|
||||||
|
//! 1. write the new bytes to a temp file in the same directory
|
||||||
|
//! 2. `fsync` the temp file — now its contents survive a power cut
|
||||||
|
//! 3. `rename` it over the destination — atomic *and* durable
|
||||||
|
//! 4. `fsync` the directory — persists the rename itself
|
||||||
|
//!
|
||||||
|
//! The temp file lives in the destination's own directory so the rename stays
|
||||||
|
//! within one filesystem; across filesystems `rename` degrades to copy-then-
|
||||||
|
//! delete and is no longer atomic.
|
||||||
|
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Write `bytes` to `path` atomically and durably.
|
||||||
|
///
|
||||||
|
/// See the module docs for the ordering this relies on. `path` may name a file
|
||||||
|
/// that does not exist yet, but its parent directory must.
|
||||||
|
///
|
||||||
|
/// The temp name is derived from the destination, so concurrent calls for the
|
||||||
|
/// same destination must be serialized by the caller — every store here holds
|
||||||
|
/// a lock across its write for that reason. Nothing stops a *second process*
|
||||||
|
/// from being pointed at the same data directory; one writer per directory
|
||||||
|
/// remains the assumption.
|
||||||
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||||
|
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let tmp = temp_path(path);
|
||||||
|
|
||||||
|
// Scope the file handle so it is closed before the rename. Renaming an
|
||||||
|
// open file is fine on Unix, but closing first keeps the ordering obvious
|
||||||
|
// and behaves identically on platforms where it is not.
|
||||||
|
write_and_sync(&tmp, bytes)?;
|
||||||
|
|
||||||
|
if let Err(e) = fs::rename(&tmp, path) {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
sync_dir(dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `vault.json` → `vault.json.tmp`.
|
||||||
|
///
|
||||||
|
/// `with_extension` rather than `with_file_name` so a path that has no
|
||||||
|
/// extension still gains one instead of being replaced outright.
|
||||||
|
fn temp_path(path: &Path) -> std::path::PathBuf {
|
||||||
|
path.with_extension("json.tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the temp file, write the bytes, and flush them all the way to the
|
||||||
|
/// device.
|
||||||
|
///
|
||||||
|
/// The mode is set at creation rather than chmod-ed afterwards, so the bytes
|
||||||
|
/// are never briefly readable by anyone the umask would have allowed.
|
||||||
|
fn write_and_sync(tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||||
|
let mut opts = fs::OpenOptions::new();
|
||||||
|
opts.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
opts.mode(0o600);
|
||||||
|
}
|
||||||
|
let mut f = opts.open(tmp)?;
|
||||||
|
f.write_all(bytes)?;
|
||||||
|
// The point of the whole exercise: without this the rename below can be
|
||||||
|
// journalled ahead of the data it is supposed to be publishing.
|
||||||
|
f.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a directory entry change. Best effort — a filesystem that refuses
|
||||||
|
/// `fsync` on a directory does not make the write above any less atomic, so
|
||||||
|
/// this must never turn a successful save into an error.
|
||||||
|
fn sync_dir(dir: &Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Ok(d) = File::open(dir) {
|
||||||
|
let _ = d.sync_all();
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_and_replaces_a_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("thing.json");
|
||||||
|
|
||||||
|
write_atomic(&path, b"first").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"first");
|
||||||
|
|
||||||
|
// Replacing an existing file must leave the new bytes, not append and
|
||||||
|
// not leave the old length.
|
||||||
|
write_atomic(&path, b"second").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"second");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_a_file_that_does_not_exist_yet() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("fresh.json");
|
||||||
|
assert!(!path.exists());
|
||||||
|
write_atomic(&path, b"{}").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_no_temp_file_behind() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("thing.json");
|
||||||
|
write_atomic(&path, b"body").unwrap();
|
||||||
|
|
||||||
|
let leftovers: Vec<_> = fs::read_dir(dir.path())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.contains(".tmp"))
|
||||||
|
.collect();
|
||||||
|
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_an_error_rather_than_panicking_when_the_directory_is_missing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("nope").join("thing.json");
|
||||||
|
assert!(write_atomic(&path, b"body").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,10 +178,12 @@ pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
|
|||||||
|
|
||||||
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
|
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
|
||||||
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
|
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
|
||||||
|
// Callers should route these to `crate::parse_import`, which
|
||||||
|
// understands them. Reaching here means a single-URI code path
|
||||||
|
// was handed a multi-account link.
|
||||||
return Err(Error::invalid(
|
return Err(Error::invalid(
|
||||||
"this is a Google Authenticator export link, which contains several \
|
"this is a Google Authenticator export link — use the import flow, \
|
||||||
accounts at once. Export them individually, or add each account's \
|
which reads all of its accounts at once.",
|
||||||
own QR code.",
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Err(Error::invalid("expected a link starting with otpauth://"));
|
return Err(Error::invalid("expected a link starting with otpauth://"));
|
||||||
|
|||||||
+1073
-28
File diff suppressed because it is too large.
Load diff
@@ -61,6 +61,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let admin = AdminStore::load_or_default(&args.work_dir);
|
let admin = AdminStore::load_or_default(&args.work_dir);
|
||||||
let settings = SettingsStore::load_or_default(&args.work_dir);
|
let settings = SettingsStore::load_or_default(&args.work_dir);
|
||||||
|
|
||||||
|
// Held separately from `state` so the shutdown path below can still reach
|
||||||
|
// the vault after the router has taken ownership.
|
||||||
|
let vault_on_shutdown = vault.clone();
|
||||||
|
|
||||||
let engine = PushEngine::new(vault.clone(), settings.clone());
|
let engine = PushEngine::new(vault.clone(), settings.clone());
|
||||||
tokio::spawn(engine.clone().run());
|
tokio::spawn(engine.clone().run());
|
||||||
|
|
||||||
@@ -97,6 +101,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("the HTTP server stopped unexpectedly")?;
|
.context("the HTTP server stopped unexpectedly")?;
|
||||||
|
|
||||||
|
// The push loop only flushes bookkeeping when a few seconds have piled up.
|
||||||
|
// On a clean stop there is no next tick, so write whatever is still
|
||||||
|
// pending rather than leaving the last rotation to be rediscovered.
|
||||||
|
vault_on_shutdown.flush_state_now();
|
||||||
|
|
||||||
tracing::info!(target: "da", "shutting down");
|
tracing::info!(target: "da", "shutting down");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ impl std::fmt::Debug for PushEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PushEngine {
|
impl PushEngine {
|
||||||
|
/// The vault this engine is driving, so a caller shutting down can flush
|
||||||
|
/// the bookkeeping it has been batching.
|
||||||
|
pub fn vault(&self) -> &Vault {
|
||||||
|
&self.vault
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
|
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
vault,
|
vault,
|
||||||
@@ -81,6 +87,14 @@ impl PushEngine {
|
|||||||
/// One pass: bring every tile up to date and tidy away any that shouldn't
|
/// One pass: bring every tile up to date and tidy away any that shouldn't
|
||||||
/// exist. Returns how many messages were written.
|
/// exist. Returns how many messages were written.
|
||||||
pub async fn sync_once(&self) -> usize {
|
pub async fn sync_once(&self) -> usize {
|
||||||
|
let written = self.sync_tiles().await;
|
||||||
|
// Bookkeeping changes are batched in memory; this is the tick that
|
||||||
|
// decides whether enough of them have piled up to be worth a write.
|
||||||
|
self.vault.flush_state();
|
||||||
|
written
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_tiles(&self) -> usize {
|
||||||
// Held for the whole pass: see `sync_lock`. A pass can take several
|
// Held for the whole pass: see `sync_lock`. A pass can take several
|
||||||
// seconds once the rate limiter spaces things out, which is easily
|
// seconds once the rate limiter spaces things out, which is easily
|
||||||
// long enough for the next tick to arrive.
|
// long enough for the next tick to arrive.
|
||||||
|
|||||||
@@ -385,6 +385,87 @@ async fn a_dead_webhook_is_surfaced_and_not_hammered() {
|
|||||||
assert!(vault.push_targets()[0].message_id.is_some());
|
assert!(vault.push_targets()[0].message_id.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renaming_an_account_refreshes_its_tile_without_waiting_for_the_code() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (url, mock) = spawn_mock().await;
|
||||||
|
// A 30-second period: the code will NOT roll during this test, so any
|
||||||
|
// update we see must have been triggered by the rename itself.
|
||||||
|
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
|
||||||
|
let engine = PushEngine::new(vault.clone(), settings);
|
||||||
|
|
||||||
|
engine.sync_once().await;
|
||||||
|
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
|
||||||
|
assert_eq!(mock.log.lock().unwrap().len(), 1);
|
||||||
|
|
||||||
|
// Nothing changed yet, so a second pass must stay silent.
|
||||||
|
engine.sync_once().await;
|
||||||
|
assert_eq!(
|
||||||
|
mock.log.lock().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"an idle pass must cost nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rename, then run one pass — no sleeping, no code rollover.
|
||||||
|
let id = vault.list()[0].id.clone();
|
||||||
|
vault
|
||||||
|
.update(&id, Some("Cloudflare Work"), Some("[email protected]"), None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
engine.sync_once().await,
|
||||||
|
1,
|
||||||
|
"the rename should trigger a push"
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let log = mock.log.lock().unwrap();
|
||||||
|
assert_eq!(log.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
log[1].method, "PATCH",
|
||||||
|
"the same tile is edited, not replaced"
|
||||||
|
);
|
||||||
|
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
|
||||||
|
|
||||||
|
// The edited message must carry the new naming.
|
||||||
|
let body = log[1].json();
|
||||||
|
assert_eq!(body["embeds"][0]["title"], "Cloudflare Work");
|
||||||
|
let desc = body["embeds"][0]["description"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
desc.contains("[email protected]"),
|
||||||
|
"new label missing: {desc}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!desc.contains("[email protected]"),
|
||||||
|
"old label still present: {desc}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it settles: a further pass sends nothing more.
|
||||||
|
engine.sync_once().await;
|
||||||
|
assert_eq!(
|
||||||
|
mock.log.lock().unwrap().len(),
|
||||||
|
2,
|
||||||
|
"rename must not re-push forever"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn toggling_push_alone_does_not_rewrite_the_tile() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (url, mock) = spawn_mock().await;
|
||||||
|
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
|
||||||
|
let engine = PushEngine::new(vault.clone(), settings);
|
||||||
|
|
||||||
|
engine.sync_once().await;
|
||||||
|
let id = vault.list()[0].id.clone();
|
||||||
|
|
||||||
|
// Re-affirming the same push state is not a rename and changes nothing
|
||||||
|
// the tile displays, so it should not cost a request.
|
||||||
|
vault.update(&id, None, None, Some(true)).unwrap();
|
||||||
|
engine.sync_once().await;
|
||||||
|
assert_eq!(mock.log.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pausing_an_account_removes_its_tile() {
|
async fn pausing_an_account_removes_its_tile() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
qrcode = { version = "0.14", default-features = false, features = ["image"] }
|
qrcode = { version = "0.14", default-features = false, features = ["image"] }
|
||||||
|
base64.workspace = true
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Emit a Google Authenticator export QR for manual testing:
|
||||||
|
//! `cargo run -p da-http-api --example make-google-export -- out.png 8`
|
||||||
|
fn put_varint(v: u64, out: &mut Vec<u8>) {
|
||||||
|
let mut v = v;
|
||||||
|
loop {
|
||||||
|
let mut b = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
if v != 0 {
|
||||||
|
b |= 0x80;
|
||||||
|
}
|
||||||
|
out.push(b);
|
||||||
|
if v == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
put_varint((u64::from(field) << 3) | 2, out);
|
||||||
|
put_varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
|
||||||
|
put_varint(u64::from(field) << 3, out);
|
||||||
|
put_varint(v, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut a = std::env::args().skip(1);
|
||||||
|
let out = a
|
||||||
|
.next()
|
||||||
|
.expect("usage: make-google-export <out.png> [count]");
|
||||||
|
let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap();
|
||||||
|
|
||||||
|
let names = [
|
||||||
|
"Cloudflare",
|
||||||
|
"Plex",
|
||||||
|
"Ubisoft",
|
||||||
|
"GitHub",
|
||||||
|
"Steam",
|
||||||
|
"Proton",
|
||||||
|
"Fastmail",
|
||||||
|
"Namecheap",
|
||||||
|
"Vercel",
|
||||||
|
"Backblaze",
|
||||||
|
];
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let mut e = Vec::new();
|
||||||
|
put_bytes(1, &[0x41 + i as u8; 20], &mut e);
|
||||||
|
put_bytes(2, b"[email protected]", &mut e);
|
||||||
|
put_bytes(3, names[i % names.len()].as_bytes(), &mut e);
|
||||||
|
put_uint(4, 1, &mut e);
|
||||||
|
// Give one entry 8 digits so the UI's variant chips get exercised.
|
||||||
|
put_uint(5, if i == 2 { 2 } else { 1 }, &mut e);
|
||||||
|
// And make one HOTP so the "skipped" path shows up.
|
||||||
|
put_uint(6, if i == 4 { 1 } else { 2 }, &mut e);
|
||||||
|
put_bytes(1, &e, &mut payload);
|
||||||
|
}
|
||||||
|
put_uint(2, 1, &mut payload);
|
||||||
|
put_uint(3, 2, &mut payload); // batch_size
|
||||||
|
put_uint(4, 0, &mut payload); // batch_index
|
||||||
|
put_uint(5, 77, &mut payload);
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||||
|
let escaped = b64
|
||||||
|
.replace('+', "%2B")
|
||||||
|
.replace('/', "%2F")
|
||||||
|
.replace('=', "%3D");
|
||||||
|
let uri = format!("otpauth-migration://offline?data={escaped}");
|
||||||
|
|
||||||
|
let code = qrcode::QrCode::new(uri.as_bytes()).unwrap();
|
||||||
|
let img = code
|
||||||
|
.render::<image::Luma<u8>>()
|
||||||
|
.module_dimensions(6, 6)
|
||||||
|
.quiet_zone(true)
|
||||||
|
.build();
|
||||||
|
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
|
||||||
|
println!(
|
||||||
|
"wrote {out} ({n} entries, {} bytes of payload)",
|
||||||
|
payload.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
+111
-40
@@ -24,8 +24,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/login", post(auth::api_login))
|
.route("/api/login", post(auth::api_login))
|
||||||
.route("/api/logout", post(auth::api_logout))
|
.route("/api/logout", post(auth::api_logout))
|
||||||
.route("/api/credentials", post(auth::api_update_credentials))
|
.route("/api/credentials", post(auth::api_update_credentials))
|
||||||
.route("/api/accounts", get(list_accounts).post(add_account))
|
.route("/api/accounts", get(list_accounts))
|
||||||
.route("/api/accounts/preview", post(preview_account))
|
.route("/api/accounts/preview", post(preview_account))
|
||||||
|
.route("/api/accounts/import", post(import_accounts))
|
||||||
.route(
|
.route(
|
||||||
"/api/accounts/{id}",
|
"/api/accounts/{id}",
|
||||||
axum::routing::patch(update_account).delete(delete_account),
|
axum::routing::patch(update_account).delete(delete_account),
|
||||||
@@ -114,27 +115,59 @@ struct PreviewBody {
|
|||||||
uri: String,
|
uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an `otpauth://` URI and report what it contains, without storing it.
|
/// Describe what a link contains, without storing anything.
|
||||||
///
|
///
|
||||||
/// Lets the "add account" flow show the issuer, algorithm and digits for
|
/// Handles both a plain `otpauth://` URI (one account) and a Google
|
||||||
/// confirmation before anything touches the vault.
|
/// Authenticator `otpauth-migration://` export (many), so the UI has a single
|
||||||
async fn preview_account(Json(body): Json<PreviewBody>) -> Response {
|
/// code path. Each entry is flagged if it is already in the vault, letting the
|
||||||
match da_core::parse_otpauth(&body.uri) {
|
/// import screen pre-uncheck it rather than failing later.
|
||||||
Ok(a) => Json(json!({
|
async fn preview_account(State(state): State<AppState>, Json(body): Json<PreviewBody>) -> Response {
|
||||||
"issuer": a.issuer,
|
let batch = match da_core::parse_import(&body.uri) {
|
||||||
"label": a.label,
|
Ok(b) => b,
|
||||||
"algorithm": a.totp.algorithm.as_str(),
|
Err(e) => return crate::error_response(&e),
|
||||||
"digits": a.totp.digits,
|
};
|
||||||
"period": a.totp.period,
|
|
||||||
}))
|
let accounts: Vec<_> = batch
|
||||||
.into_response(),
|
.accounts
|
||||||
Err(e) => crate::error_response(&e),
|
.iter()
|
||||||
}
|
.enumerate()
|
||||||
|
.map(|(index, a)| {
|
||||||
|
json!({
|
||||||
|
"index": index,
|
||||||
|
"issuer": a.issuer,
|
||||||
|
"label": a.label,
|
||||||
|
"algorithm": a.totp.algorithm.as_str(),
|
||||||
|
"digits": a.totp.digits,
|
||||||
|
"period": a.totp.period,
|
||||||
|
"duplicate": state.vault.contains(&a.totp, &a.issuer, &a.label),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let skipped: Vec<_> = batch
|
||||||
|
.skipped
|
||||||
|
.iter()
|
||||||
|
.map(|s| json!({ "name": s.name, "reason": s.reason }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(
|
||||||
|
// Names and parameters of the user's accounts — never cache.
|
||||||
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
|
Json(json!({
|
||||||
|
"accounts": accounts,
|
||||||
|
"skipped": skipped,
|
||||||
|
"part": batch.part_label(),
|
||||||
|
"batch_id": batch.batch_id,
|
||||||
|
"is_export": da_core::is_migration_uri(&body.uri),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AddBody {
|
struct ImportItem {
|
||||||
uri: String,
|
/// Index into the preview's `accounts` array.
|
||||||
|
index: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
issuer: Option<String>,
|
issuer: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -143,25 +176,44 @@ struct AddBody {
|
|||||||
push_enabled: bool,
|
push_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
#[derive(Debug, Deserialize)]
|
||||||
true
|
struct ImportBody {
|
||||||
|
uri: String,
|
||||||
|
items: Vec<ImportItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_account(State(state): State<AppState>, Json(body): Json<AddBody>) -> Response {
|
/// Import the selected accounts from a link.
|
||||||
let parsed = match da_core::parse_otpauth(&body.uri) {
|
///
|
||||||
Ok(p) => p,
|
/// The client sends back the original link plus the indices it wants, and the
|
||||||
|
/// 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 {
|
||||||
|
let batch = match da_core::parse_import(&body.uri) {
|
||||||
|
Ok(b) => b,
|
||||||
Err(e) => return crate::error_response(&e),
|
Err(e) => return crate::error_response(&e),
|
||||||
};
|
};
|
||||||
match state.vault.add(
|
|
||||||
&parsed,
|
let mut queued = Vec::with_capacity(body.items.len());
|
||||||
body.issuer.as_deref(),
|
for item in &body.items {
|
||||||
body.label.as_deref(),
|
let Some(parsed) = batch.accounts.get(item.index) else {
|
||||||
body.push_enabled,
|
return bad_request(&format!(
|
||||||
) {
|
"selection {} is out of range for this link — reload and try again",
|
||||||
// The push loop picks the new account up on its next tick and posts
|
item.index
|
||||||
// the tile; we deliberately don't kick a sync from here, so there is
|
));
|
||||||
// only ever one pass in flight.
|
};
|
||||||
Ok(a) => (StatusCode::CREATED, Json(a)).into_response(),
|
queued.push(da_core::NewAccount {
|
||||||
|
parsed: parsed.clone(),
|
||||||
|
issuer: item.issuer.clone(),
|
||||||
|
label: item.label.clone(),
|
||||||
|
push_enabled: item.push_enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
Err(e) => crate::error_response(&e),
|
Err(e) => crate::error_response(&e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +259,10 @@ async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
// ── Codes ─────────────────────────────────────────────────────────────────
|
// ── Codes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Current and next code for every account, plus the server clock.
|
/// Current and next code for every account, plus the server clock.
|
||||||
@@ -275,14 +331,29 @@ async fn decode_qr(mut multipart: Multipart) -> Response {
|
|||||||
|
|
||||||
match crate::qr::decode(&bytes) {
|
match crate::qr::decode(&bytes) {
|
||||||
Ok(found) => {
|
Ok(found) => {
|
||||||
// An image can hold several codes; prefer an otpauth one over
|
// An image can hold several codes — a Google export screenshot
|
||||||
// whatever else happens to be in frame (a Wi-Fi QR, a URL).
|
// legitimately does. Return them all, OTP links first, so the
|
||||||
let best = found
|
// caller can import every account in one pass instead of only
|
||||||
|
// whichever happened to be detected first.
|
||||||
|
let mut uris: Vec<String> = found
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| s.to_ascii_lowercase().starts_with("otpauth"))
|
.filter(|s| {
|
||||||
.or_else(|| found.first());
|
let l = s.to_ascii_lowercase();
|
||||||
match best {
|
l.starts_with("otpauth://") || l.starts_with("otpauth-migration://")
|
||||||
Some(uri) => Json(json!({ "uri": uri })).into_response(),
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if uris.is_empty() {
|
||||||
|
// Nothing OTP-shaped; hand back whatever was there so the UI
|
||||||
|
// can say what it actually found.
|
||||||
|
uris = found;
|
||||||
|
}
|
||||||
|
match uris.first() {
|
||||||
|
Some(first) => (
|
||||||
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
|
Json(json!({ "uri": first, "uris": uris })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
None => bad_request("no QR code found in that image"),
|
None => bad_request("no QR code found in that image"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,104 @@ mod tests {
|
|||||||
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
|
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a Google Authenticator export QR the way the real app emits
|
||||||
|
/// one, so the upload path is exercised end to end: image bytes -> QR
|
||||||
|
/// decode -> protobuf parse -> accounts.
|
||||||
|
fn google_export_png(n: usize, module_px: u32) -> Vec<u8> {
|
||||||
|
fn put_varint(v: u64, out: &mut Vec<u8>) {
|
||||||
|
let mut v = v;
|
||||||
|
loop {
|
||||||
|
let mut b = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
if v != 0 {
|
||||||
|
b |= 0x80;
|
||||||
|
}
|
||||||
|
out.push(b);
|
||||||
|
if v == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
put_varint((u64::from(field) << 3) | 2, out);
|
||||||
|
put_varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
|
||||||
|
put_varint(u64::from(field) << 3, out);
|
||||||
|
put_varint(v, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let mut e = Vec::new();
|
||||||
|
put_bytes(1, &[0x41 + i as u8; 20], &mut e); // secret
|
||||||
|
put_bytes(2, format!("user{i}@example.com").as_bytes(), &mut e);
|
||||||
|
put_bytes(3, format!("Service{i}").as_bytes(), &mut e);
|
||||||
|
put_uint(4, 1, &mut e); // SHA1
|
||||||
|
put_uint(5, 1, &mut e); // 6 digits
|
||||||
|
put_uint(6, 2, &mut e); // TOTP
|
||||||
|
put_bytes(1, &e, &mut payload);
|
||||||
|
}
|
||||||
|
put_uint(2, 1, &mut payload);
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||||
|
let escaped = b64
|
||||||
|
.replace('+', "%2B")
|
||||||
|
.replace('/', "%2F")
|
||||||
|
.replace('=', "%3D");
|
||||||
|
qr_png(
|
||||||
|
&format!("otpauth-migration://offline?data={escaped}"),
|
||||||
|
module_px,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_a_google_export_qr_end_to_end() {
|
||||||
|
let png = google_export_png(5, 4);
|
||||||
|
let found = decode(&png).unwrap();
|
||||||
|
assert_eq!(found.len(), 1, "one QR, one payload");
|
||||||
|
assert!(found[0].starts_with("otpauth-migration://"));
|
||||||
|
|
||||||
|
// And that payload really does carry five importable accounts.
|
||||||
|
let batch = da_core::parse_import(&found[0]).unwrap();
|
||||||
|
assert_eq!(batch.accounts.len(), 5);
|
||||||
|
assert_eq!(batch.accounts[0].issuer, "Service0");
|
||||||
|
assert_eq!(batch.accounts[4].label, "[email protected]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_several_qr_codes_from_one_screenshot() {
|
||||||
|
// A screenshot of a multi-part export can contain more than one code.
|
||||||
|
// Stacking two here proves `decode` returns both rather than the
|
||||||
|
// first, which is what lets the UI import every account in one pass.
|
||||||
|
let a = image::load_from_memory(&qr_png(URI, 5)).unwrap().to_luma8();
|
||||||
|
let b = image::load_from_memory(&google_export_png(2, 5))
|
||||||
|
.unwrap()
|
||||||
|
.to_luma8();
|
||||||
|
|
||||||
|
let w = a.width().max(b.width());
|
||||||
|
let mut canvas =
|
||||||
|
GrayImage::from_pixel(w + 40, a.height() + b.height() + 60, image::Luma([255]));
|
||||||
|
image::imageops::replace(&mut canvas, &a, 20, 20);
|
||||||
|
image::imageops::replace(&mut canvas, &b, 20, i64::from(a.height()) + 40);
|
||||||
|
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
DynamicImage::ImageLuma8(canvas)
|
||||||
|
.write_to(&mut buf, image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = decode(&buf.into_inner()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
found.len(),
|
||||||
|
2,
|
||||||
|
"both codes should be returned, got {found:?}"
|
||||||
|
);
|
||||||
|
assert!(found.iter().any(|u| u.starts_with("otpauth://")));
|
||||||
|
assert!(found.iter().any(|u| u.starts_with("otpauth-migration://")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_input_is_rejected() {
|
fn empty_input_is_rejected() {
|
||||||
assert!(decode(&[]).is_err());
|
assert!(decode(&[]).is_err());
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo
|
||||||
|
//! avatar and the Unraid template need:
|
||||||
|
//! `cargo run -p da-render --example brand -- docs/brand`
|
||||||
|
fn main() {
|
||||||
|
let dir = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "docs/brand".into());
|
||||||
|
let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg");
|
||||||
|
for size in [512u32, 256, 128] {
|
||||||
|
let png = da_render::rasterise_svg(&svg, size, size).expect("render");
|
||||||
|
let path = format!("{dir}/icon-{size}.png");
|
||||||
|
std::fs::write(&path, &png).expect("write");
|
||||||
|
println!("wrote {path} ({} bytes)", png.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
|||||||
Ok(out.into_inner())
|
Ok(out.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rasterise arbitrary SVG source to a PNG at a given size.
|
||||||
|
///
|
||||||
|
/// Exposed so the brand assets (README image, Gitea avatar, Unraid template
|
||||||
|
/// icon) are generated from the same `icon.svg` the app ships, rather than
|
||||||
|
/// being hand-exported and drifting out of sync with it.
|
||||||
|
pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
|
||||||
|
let tree = usvg::Tree::from_str(svg, options())?;
|
||||||
|
let mut pixmap =
|
||||||
|
tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?;
|
||||||
|
|
||||||
|
// Scale the document to fill the requested pixmap.
|
||||||
|
let size = tree.size();
|
||||||
|
let scale = (width as f32 / size.width()).min(height as f32 / size.height());
|
||||||
|
resvg::render(
|
||||||
|
&tree,
|
||||||
|
tiny_skia::Transform::from_scale(scale, scale),
|
||||||
|
&mut pixmap.as_mut(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||||
|
for px in pixmap.pixels() {
|
||||||
|
let c = px.demultiply();
|
||||||
|
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = std::io::Cursor::new(Vec::new());
|
||||||
|
image::codecs::png::PngEncoder::new(&mut out).write_image(
|
||||||
|
&rgba,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
image::ExtendedColorType::Rgba8,
|
||||||
|
)?;
|
||||||
|
Ok(out.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
||||||
let svg = build_svg(tile);
|
let svg = build_svg(tile);
|
||||||
let tree = usvg::Tree::from_str(&svg, options())?;
|
let tree = usvg::Tree::from_str(&svg, options())?;
|
||||||
|
|||||||
@@ -160,15 +160,39 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
.shell { display: flex; flex-direction: column; min-height: 100vh; }
|
.shell { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* Three columns: the search occupies the middle one, so it lands on the same
|
||||||
|
centre line as the account tiles below no matter how wide the brand or the
|
||||||
|
action cluster get. The outer columns are equal-width and allowed to
|
||||||
|
shrink, which keeps the centring exact rather than approximate. */
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky; top: 0; z-index: 30;
|
position: sticky; top: 0; z-index: 30;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
|
||||||
|
align-items: center; gap: 12px;
|
||||||
height: var(--topbar-h);
|
height: var(--topbar-h);
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: color-mix(in oklab, var(--page) 82%, transparent);
|
background: color-mix(in oklab, var(--page) 82%, transparent);
|
||||||
backdrop-filter: saturate(1.6) blur(12px);
|
backdrop-filter: saturate(1.6) blur(12px);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.topbar .brand { justify-self: start; min-width: 0; }
|
||||||
|
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.topbar .actions {
|
||||||
|
justify-self: end;
|
||||||
|
display: flex; align-items: center; gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* True centring needs room for all three: the brand wordmark (~210px), the
|
||||||
|
420px search, the action cluster (~110px), plus gaps and padding — about
|
||||||
|
800px in total. Below that the grid starts clipping "Discord Authenticator",
|
||||||
|
so fall back to a plain row where the brand keeps its space and the search
|
||||||
|
takes whatever is left. */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.topbar { display: flex; }
|
||||||
|
.topbar .brand { flex: 0 0 auto; }
|
||||||
|
.topbar .search { flex: 1 1 auto; justify-self: auto; }
|
||||||
|
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
|
||||||
|
}
|
||||||
|
|
||||||
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
|
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
|
||||||
.brand svg { width: 26px; height: 26px; display: block; }
|
.brand svg { width: 26px; height: 26px; display: block; }
|
||||||
@@ -192,8 +216,9 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
justify-self: center;
|
||||||
}
|
}
|
||||||
.search svg {
|
.search svg {
|
||||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||||
@@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
|
|||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: max(20px, calc(50vw - 344px));
|
right: 24px;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
@@ -607,6 +632,59 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
|
|||||||
0 0 0 9999px oklch(0% 0 0 / .45);
|
0 0 0 9999px oklch(0% 0 0 / .45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Import list (Google export / multi-account) ───────────────────── */
|
||||||
|
|
||||||
|
.import-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 10px; margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Capped so a 20-account export scrolls inside the modal rather than
|
||||||
|
pushing the Import button off the bottom of the screen. */
|
||||||
|
.import-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
background: var(--inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 30px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s var(--ease);
|
||||||
|
}
|
||||||
|
.import-row + .import-row { border-top: 1px solid var(--line-soft); }
|
||||||
|
.import-row:hover { background: var(--hover); }
|
||||||
|
|
||||||
|
.import-row input[type="checkbox"] {
|
||||||
|
width: 16px; height: 16px; margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row .avatar {
|
||||||
|
width: 30px; height: 30px; border-radius: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.import-row .meta { min-width: 0; }
|
||||||
|
.import-row .issuer { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.import-row .label { font-size: 11.5px; color: var(--ink-2);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.import-row .tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.import-row .tags .chip { height: 19px; padding: 0 7px; font-size: 10.5px; }
|
||||||
|
|
||||||
|
/* An account already in the vault is de-emphasised but still selectable. */
|
||||||
|
.import-row:has(input:not(:checked)) .meta { opacity: .55; }
|
||||||
|
|
||||||
/* ── Toast ────────────────────────────────────────────────────────────── */
|
/* ── Toast ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.toasts {
|
.toasts {
|
||||||
|
|||||||
+230
-45
@@ -89,6 +89,7 @@ const ICON = {
|
|||||||
back: 'M19 12H5m7 7-7-7 7-7',
|
back: 'M19 12H5m7 7-7-7 7-7',
|
||||||
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
|
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'],
|
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'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
@@ -349,27 +350,31 @@ function renderShell() {
|
|||||||
|
|
||||||
const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 });
|
const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 });
|
||||||
|
|
||||||
|
// Three columns so the search sits on the page's centre line, matching the
|
||||||
|
// centred account tiles below it, regardless of how wide the brand or the
|
||||||
|
// action cluster happen to be.
|
||||||
const topbar = h('header.topbar', {}, [
|
const topbar = h('header.topbar', {}, [
|
||||||
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
|
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
|
||||||
logoImg, h('strong', { text: 'Authenticator' }),
|
logoImg, h('strong', { text: 'Discord Authenticator' }),
|
||||||
]),
|
]),
|
||||||
searchWrap,
|
searchWrap,
|
||||||
h('div.spacer'),
|
h('div.actions', {}, [
|
||||||
h('button.icon-btn.theme-toggle', {
|
h('button.icon-btn.theme-toggle', {
|
||||||
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
||||||
onclick: toggleTheme,
|
onclick: toggleTheme,
|
||||||
}, [
|
}, [
|
||||||
icon(ICON.sun, { className: 't-sun', width: 2 }),
|
icon(ICON.sun, { className: 't-sun', width: 2 }),
|
||||||
icon(ICON.moon, { className: 't-moon', width: 2 }),
|
icon(ICON.moon, { className: 't-moon', width: 2 }),
|
||||||
|
]),
|
||||||
|
h('button.icon-btn', {
|
||||||
|
type: 'button', 'aria-label': 'Settings', title: 'Settings',
|
||||||
|
onclick: () => go('settings'),
|
||||||
|
}, [icon(ICON.settings, { width: 1.7 })]),
|
||||||
|
h('button.icon-btn', {
|
||||||
|
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
|
||||||
|
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
|
||||||
|
}, [icon(ICON.logout, { width: 2 })]),
|
||||||
]),
|
]),
|
||||||
h('button.icon-btn', {
|
|
||||||
type: 'button', 'aria-label': 'Settings', title: 'Settings',
|
|
||||||
onclick: () => go('settings'),
|
|
||||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
|
||||||
h('button.icon-btn', {
|
|
||||||
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
|
|
||||||
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
|
|
||||||
}, [icon(ICON.logout, { width: 2 })]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })]));
|
root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })]));
|
||||||
@@ -480,9 +485,9 @@ function acctRow(a) {
|
|||||||
codeBtn,
|
codeBtn,
|
||||||
ring,
|
ring,
|
||||||
h('button.icon-btn', {
|
h('button.icon-btn', {
|
||||||
type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options',
|
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
|
||||||
onclick: () => openAccountSheet(a),
|
onclick: () => openAccountSheet(a),
|
||||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
}, [icon(ICON.edit, { width: 1.9 })]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
return row;
|
return row;
|
||||||
@@ -697,7 +702,7 @@ function openAddSheet() {
|
|||||||
]));
|
]));
|
||||||
try {
|
try {
|
||||||
const res = await api('/api/qr/decode', { method: 'POST', body: fd });
|
const res = await api('/api/qr/decode', { method: 'POST', body: fd });
|
||||||
confirmPane(res.uri);
|
previewUris(res.uris && res.uris.length ? res.uris : [res.uri]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
panel.replaceChildren(
|
panel.replaceChildren(
|
||||||
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
|
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
|
||||||
@@ -777,7 +782,7 @@ function openAddSheet() {
|
|||||||
} catch (_) { /* transient decode error — keep going */ }
|
} catch (_) { /* transient decode error — keep going */ }
|
||||||
timer = setTimeout(scan, detector ? 180 : 650);
|
timer = setTimeout(scan, detector ? 180 : 650);
|
||||||
};
|
};
|
||||||
const done = (uri) => { stop(); confirmPane(uri); };
|
const done = (uri) => { stop(); previewUris([uri]); };
|
||||||
scan();
|
scan();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -809,13 +814,13 @@ function openAddSheet() {
|
|||||||
onclick: () => {
|
onclick: () => {
|
||||||
err.classList.add('hidden');
|
err.classList.add('hidden');
|
||||||
const uri = ta.value.trim();
|
const uri = ta.value.trim();
|
||||||
if (uri) return confirmPane(uri);
|
if (uri) return previewUris([uri]);
|
||||||
if (mSecret.value.trim()) {
|
if (mSecret.value.trim()) {
|
||||||
const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') });
|
const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') });
|
||||||
if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim());
|
if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim());
|
||||||
const path = encodeURIComponent(
|
const path = encodeURIComponent(
|
||||||
(mIssuer.value.trim() ? mIssuer.value.trim() + ':' : '') + (mLabel.value.trim() || 'account'));
|
(mIssuer.value.trim() ? mIssuer.value.trim() + ':' : '') + (mLabel.value.trim() || 'account'));
|
||||||
return confirmPane(`otpauth://totp/${path}?${q}`);
|
return previewUris([`otpauth://totp/${path}?${q}`]);
|
||||||
}
|
}
|
||||||
err.replaceChildren(icon(ICON.alert), h('span', { text: 'Paste an otpauth:// link or fill in the manual fields.' }));
|
err.replaceChildren(icon(ICON.alert), h('span', { text: 'Paste an otpauth:// link or fill in the manual fields.' }));
|
||||||
err.classList.remove('hidden');
|
err.classList.remove('hidden');
|
||||||
@@ -824,36 +829,105 @@ function openAddSheet() {
|
|||||||
return h('div', {}, [err, h('div.field', {}, [h('label', { text: 'otpauth:// link' }), ta]), manual, go]);
|
return h('div', {}, [err, h('div.field', {}, [h('label', { text: 'otpauth:// link' }), ta]), manual, go]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preview what we parsed, let the user rename, then commit. */
|
/** Fetch a preview for every link found, then show the import list. */
|
||||||
async function confirmPane(uri) {
|
async function previewUris(uris) {
|
||||||
panel.replaceChildren(h('div.dropzone', {}, [
|
panel.replaceChildren(h('div.dropzone', {}, [
|
||||||
icon(ICON.search, { width: 1.7, className: 'spin' }),
|
icon(ICON.search, { width: 1.7, className: 'spin' }),
|
||||||
h('strong', { text: 'Checking…' }),
|
h('strong', { text: 'Reading…' }),
|
||||||
]));
|
]));
|
||||||
let parsed;
|
|
||||||
try {
|
const sources = [];
|
||||||
parsed = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
|
const errors = [];
|
||||||
} catch (e) {
|
for (const uri of uris) {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
|
||||||
|
sources.push(Object.assign({ uri: uri }, res));
|
||||||
|
} catch (e) { errors.push(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sources.length) {
|
||||||
panel.replaceChildren(
|
panel.replaceChildren(
|
||||||
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
|
h('div.alert.err', {}, [
|
||||||
|
icon(ICON.alert),
|
||||||
|
h('span', { text: errors[0] || 'Nothing importable was found in that.' }),
|
||||||
|
]),
|
||||||
pastePane(),
|
pastePane(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const issuer = h('input.input', { value: parsed.issuer || '' });
|
renderImport(sources, errors);
|
||||||
const label = h('input.input', { value: parsed.label || '' });
|
}
|
||||||
|
|
||||||
|
/** One account gets the detail form; several get a checklist. */
|
||||||
|
function renderImport(sources, errors) {
|
||||||
|
const rows = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
for (const acct of src.accounts) rows.push({ src: src, acct: acct });
|
||||||
|
}
|
||||||
|
const skipped = sources.reduce((all, s) => all.concat(s.skipped || []), []);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
panel.replaceChildren(
|
||||||
|
h('div.alert.warn', {}, [
|
||||||
|
icon(ICON.alert),
|
||||||
|
h('span', { text: 'No importable accounts in that link.' }),
|
||||||
|
]),
|
||||||
|
skipped.length ? skippedList(skipped) : null,
|
||||||
|
pastePane(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.length === 1) return singleForm(rows[0], skipped);
|
||||||
|
return multiForm(rows, sources, skipped, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skippedList(skipped) {
|
||||||
|
return h('div', { style: 'margin-top:14px' }, [
|
||||||
|
h('div.small.muted', {
|
||||||
|
style: 'margin-bottom:6px',
|
||||||
|
text: skipped.length + ' entr' + (skipped.length === 1 ? 'y was' : 'ies were') + ' skipped:',
|
||||||
|
}),
|
||||||
|
h('div.stack-s', {}, skipped.map((s) =>
|
||||||
|
h('div.small.muted', { text: '• ' + s.name + ' — ' + s.reason }))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google splits large exports across several QR codes; say so. */
|
||||||
|
function partNotice(sources) {
|
||||||
|
const part = sources.map((s) => s.part).find(Boolean);
|
||||||
|
if (!part) return null;
|
||||||
|
return h('div.alert.info', {}, [
|
||||||
|
icon(ICON.info),
|
||||||
|
h('span', { text: part + ' of a Google Authenticator export. Import this one, then upload the remaining QR codes to add the rest.' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The familiar single-account confirm screen. */
|
||||||
|
function singleForm(row, skipped) {
|
||||||
|
const src = row.src, acct = row.acct;
|
||||||
|
const issuer = h('input.input', { value: acct.issuer || '' });
|
||||||
|
const label = h('input.input', { value: acct.label || '' });
|
||||||
const push = h('input', { type: 'checkbox', checked: true });
|
const push = h('input', { type: 'checkbox', checked: true });
|
||||||
const save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' });
|
const save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' });
|
||||||
|
|
||||||
save.addEventListener('click', async () => {
|
save.addEventListener('click', async () => {
|
||||||
save.disabled = true; save.textContent = 'Adding…';
|
save.disabled = true; save.textContent = 'Adding…';
|
||||||
try {
|
try {
|
||||||
await api('/api/accounts', {
|
const out = await api('/api/accounts/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { uri, issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
|
body: {
|
||||||
|
uri: src.uri,
|
||||||
|
items: [{
|
||||||
|
index: acct.index,
|
||||||
|
issuer: issuer.value.trim(),
|
||||||
|
label: label.value.trim(),
|
||||||
|
push_enabled: push.checked,
|
||||||
|
}],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
dlg.close();
|
dlg.close();
|
||||||
toast('Account added');
|
toast(out.added.length ? 'Account added' : 'That account is already in your vault',
|
||||||
|
out.added.length ? 'ok' : 'err');
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
save.disabled = false; save.textContent = 'Add account';
|
save.disabled = false; save.textContent = 'Add account';
|
||||||
@@ -862,21 +936,123 @@ function openAddSheet() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
panel.replaceChildren(h('div', {}, [
|
panel.replaceChildren(h('div', {}, [
|
||||||
h('div.alert.info', {}, [icon(ICON.check), h('span', { text: 'Found a valid one-time-password secret.' })]),
|
acct.duplicate
|
||||||
|
? h('div.alert.warn', {}, [icon(ICON.alert),
|
||||||
|
h('span', { text: 'This account is already in your vault. Adding it again creates a second copy.' })])
|
||||||
|
: h('div.alert.info', {}, [icon(ICON.check),
|
||||||
|
h('span', { text: 'Found a valid one-time-password secret.' })]),
|
||||||
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
|
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
|
||||||
h('div.field', {}, [h('label', { text: 'Account' }), label]),
|
h('div.field', {}, [h('label', { text: 'Account' }), label]),
|
||||||
h('dl', {}, [
|
h('dl', {}, [
|
||||||
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: parsed.algorithm })]),
|
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: acct.algorithm })]),
|
||||||
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(parsed.digits) })]),
|
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(acct.digits) })]),
|
||||||
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: parsed.period + 's' })]),
|
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: acct.period + 's' })]),
|
||||||
]),
|
]),
|
||||||
h('label.switch', { style: 'margin:16px 0' }, [
|
h('label.switch', { style: 'margin:16px 0' }, [
|
||||||
push, h('span.track'),
|
push, h('span.track'),
|
||||||
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
||||||
]),
|
]),
|
||||||
save,
|
save,
|
||||||
|
skipped.length ? skippedList(skipped) : null,
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Multi-account import — where a Google export lands. */
|
||||||
|
function multiForm(rows, sources, skipped, errors) {
|
||||||
|
// Anything already in the vault starts unticked, so re-scanning an export
|
||||||
|
// you partly imported doesn't quietly create duplicates.
|
||||||
|
const boxes = rows.map((r) => h('input', { type: 'checkbox', checked: !r.acct.duplicate }));
|
||||||
|
const push = h('input', { type: 'checkbox', checked: true });
|
||||||
|
const go = h('button.btn.btn-primary.btn-block');
|
||||||
|
|
||||||
|
const selected = () => boxes.reduce((n, b) => n + (b.checked ? 1 : 0), 0);
|
||||||
|
const sync = () => {
|
||||||
|
const n = selected();
|
||||||
|
go.textContent = n ? 'Add ' + n + ' account' + (n === 1 ? '' : 's') : 'Select accounts to add';
|
||||||
|
go.disabled = n === 0;
|
||||||
|
};
|
||||||
|
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) });
|
||||||
|
av.setAttribute('style', avatarStyle(r.acct.issuer || r.acct.label || String(i)));
|
||||||
|
return h('label.import-row', {}, [
|
||||||
|
boxes[i],
|
||||||
|
av,
|
||||||
|
h('div.meta', {}, [
|
||||||
|
h('div.issuer', { text: r.acct.issuer || 'Unnamed' }),
|
||||||
|
h('div.label', { text: r.acct.label || '—' }),
|
||||||
|
]),
|
||||||
|
h('div.tags', {}, [
|
||||||
|
r.acct.duplicate ? h('span.chip.warn', { text: 'already added' }) : null,
|
||||||
|
r.acct.digits !== 6 ? h('span.chip', { text: r.acct.digits + ' digits' }) : null,
|
||||||
|
r.acct.algorithm !== 'SHA1' ? h('span.chip', { text: r.acct.algorithm }) : null,
|
||||||
|
r.acct.period !== 30 ? h('span.chip', { text: r.acct.period + 's' }) : null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setAll = (v) => { boxes.forEach((b) => { b.checked = v; }); sync(); };
|
||||||
|
|
||||||
|
go.addEventListener('click', async () => {
|
||||||
|
go.disabled = true;
|
||||||
|
const original = go.textContent;
|
||||||
|
go.textContent = 'Adding…';
|
||||||
|
// Group by source link: each import call re-parses exactly one link
|
||||||
|
// server-side, so secrets never travel back out to the browser.
|
||||||
|
const bySource = new Map();
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
if (!boxes[i].checked) return;
|
||||||
|
if (!bySource.has(r.src.uri)) bySource.set(r.src.uri, []);
|
||||||
|
bySource.get(r.src.uri).push({ index: r.acct.index, push_enabled: push.checked });
|
||||||
|
});
|
||||||
|
let added = 0, dupes = 0;
|
||||||
|
try {
|
||||||
|
for (const entry of bySource) {
|
||||||
|
const out = await api('/api/accounts/import', {
|
||||||
|
method: 'POST', body: { uri: entry[0], items: entry[1] },
|
||||||
|
});
|
||||||
|
added += out.added.length;
|
||||||
|
dupes += out.duplicates.length;
|
||||||
|
}
|
||||||
|
dlg.close();
|
||||||
|
toast('Added ' + added + ' account' + (added === 1 ? '' : 's') +
|
||||||
|
(dupes ? ' · ' + dupes + ' already present' : ''));
|
||||||
|
await reloadAll();
|
||||||
|
} catch (e) {
|
||||||
|
go.disabled = false; go.textContent = original;
|
||||||
|
toast(e.message, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dupeCount = rows.filter((r) => r.acct.duplicate).length;
|
||||||
|
|
||||||
|
panel.replaceChildren(h('div', {}, [
|
||||||
|
partNotice(sources),
|
||||||
|
h('div.alert.info', {}, [icon(ICON.check),
|
||||||
|
h('span', { text: 'Found ' + rows.length + ' accounts.' +
|
||||||
|
(dupeCount ? ' ' + dupeCount + ' already in your vault ' + (dupeCount === 1 ? 'is' : 'are') + ' unticked.' : '') })]),
|
||||||
|
errors && errors.length
|
||||||
|
? h('div.alert.warn', {}, [icon(ICON.alert),
|
||||||
|
h('span', { text: errors.length + ' code in that image could not be read.' })])
|
||||||
|
: null,
|
||||||
|
h('div.import-head', {}, [
|
||||||
|
h('span.small.muted', { text: 'Select accounts to import' }),
|
||||||
|
h('div', {}, [
|
||||||
|
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(true), text: 'All' }),
|
||||||
|
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(false), text: 'None' }),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
list,
|
||||||
|
h('label.switch', { style: 'margin:16px 0' }, [
|
||||||
|
push, h('span.track'),
|
||||||
|
h('span.switch-label', { text: 'Stream these codes to Discord' }),
|
||||||
|
]),
|
||||||
|
go,
|
||||||
|
skipped.length ? skippedList(skipped) : null,
|
||||||
|
]));
|
||||||
|
sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-account sheet ──────────────────────────────────────────────────────
|
// ── Per-account sheet ──────────────────────────────────────────────────────
|
||||||
@@ -886,15 +1062,21 @@ function openAccountSheet(a) {
|
|||||||
const label = h('input.input', { value: a.label || '' });
|
const label = h('input.input', { value: a.label || '' });
|
||||||
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
|
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
|
||||||
|
|
||||||
const dlg = openModal(a.issuer || 'Account', [
|
const dlg = openModal('Edit account', [
|
||||||
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
|
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
|
||||||
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
|
h('div.field', {}, [
|
||||||
h('div.field', {}, [h('label', { text: 'Account' }), label]),
|
h('label', { text: 'Name' }), issuer,
|
||||||
|
h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }),
|
||||||
|
]),
|
||||||
|
h('div.field', {}, [
|
||||||
|
h('label', { text: 'Account' }), label,
|
||||||
|
h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }),
|
||||||
|
]),
|
||||||
h('label.switch', { style: 'margin:16px 0 4px' }, [
|
h('label.switch', { style: 'margin:16px 0 4px' }, [
|
||||||
push, h('span.track'),
|
push, h('span.track'),
|
||||||
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
||||||
]),
|
]),
|
||||||
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }),
|
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }),
|
||||||
], [
|
], [
|
||||||
h('button.btn.btn-danger', { onclick: async () => {
|
h('button.btn.btn-danger', { onclick: async () => {
|
||||||
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
|
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
|
||||||
@@ -904,14 +1086,17 @@ function openAccountSheet(a) {
|
|||||||
} catch (e) { toast(e.message, 'err'); }
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
|
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
|
||||||
h('div', { style: 'flex:1' }),
|
h('div', { style: 'flex:1' }),
|
||||||
h('button.btn', { onclick: dlg.close, text: 'Cancel' }),
|
// Deferred: `dlg` is still in its temporal dead zone while these
|
||||||
|
// arguments are being built, so `onclick: dlg.close` throws and the whole
|
||||||
|
// sheet fails to open. Every other handler here is already a closure.
|
||||||
|
h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }),
|
||||||
h('button.btn.btn-primary', { onclick: async () => {
|
h('button.btn.btn-primary', { onclick: async () => {
|
||||||
try {
|
try {
|
||||||
await api('/api/accounts/' + encodeURIComponent(a.id), {
|
await api('/api/accounts/' + encodeURIComponent(a.id), {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
|
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
|
||||||
});
|
});
|
||||||
dlg.close(); toast('Saved'); await reloadAll();
|
dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll();
|
||||||
} catch (e) { toast(e.message, 'err'); }
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
}, text: 'Save' }),
|
}, text: 'Save' }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -24,8 +24,11 @@
|
|||||||
Self-hosted TOTP authenticator with live Discord delivery.
|
Self-hosted TOTP authenticator with live Discord delivery.
|
||||||
|
|
||||||
Add accounts by uploading a QR code, scanning with the camera, or pasting
|
Add accounts by uploading a QR code, scanning with the camera, or pasting
|
||||||
an otpauth:// link. The web UI lists every account with its rotating code,
|
an otpauth:// link. Google Authenticator exports are supported too: one
|
||||||
the way Google or Microsoft Authenticator does.
|
export QR carries many accounts, and you get a checklist to pick which to
|
||||||
|
import, with duplicates detected automatically. The web UI lists every
|
||||||
|
account with its rotating code, the way Google or Microsoft Authenticator
|
||||||
|
does.
|
||||||
|
|
||||||
The distinctive part: each account keeps ONE message in your Discord
|
The distinctive part: each account keeps ONE message in your Discord
|
||||||
channel, delivered through an incoming webhook and rewritten in place
|
channel, delivered through an incoming webhook and rewritten in place
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
<Category>Tools:Utilities Security:</Category>
|
<Category>Tools:Utilities Security:</Category>
|
||||||
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
|
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
|
||||||
<TemplateURL/>
|
<TemplateURL/>
|
||||||
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
|
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
|
||||||
<ExtraParams/>
|
<ExtraParams/>
|
||||||
<PostArgs/>
|
<PostArgs/>
|
||||||
<CPUset/>
|
<CPUset/>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
|
||||||
|
role="img" aria-label="Discord Authenticator">
|
||||||
|
<!-- App icon: the shield-and-arc mark on the product's own dark surface.
|
||||||
|
Colours are literal rather than currentColor so this renders correctly
|
||||||
|
as a favicon, a Gitea repo avatar and an Unraid template icon, none of
|
||||||
|
which supply a CSS context. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#232833"/>
|
||||||
|
<stop offset="1" stop-color="#161a22"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="mark" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6ea3ff"/>
|
||||||
|
<stop offset="1" stop-color="#3d78e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
|
||||||
|
<rect x="1" y="1" width="510" height="510" rx="113"
|
||||||
|
fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
|
||||||
|
|
||||||
|
<g transform="translate(96 96) scale(10)">
|
||||||
|
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||||
|
fill="url(#mark)" fill-opacity=".18"/>
|
||||||
|
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||||
|
fill="none" stroke="url(#mark)" stroke-width="1.9" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
|
||||||
|
fill="none" stroke="url(#mark)" stroke-width="2.1" stroke-linecap="round"/>
|
||||||
|
<circle cx="16" cy="15.9" r="1.85" fill="url(#mark)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
This release fixes the failure that could leave the container in a restart loop
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: could not load the vault
|
||||||
|
|
||||||
|
Caused by:
|
||||||
|
/var/lib/discord-authenticator/vault.json exists but could not be parsed
|
||||||
|
(EOF while parsing a value at line 1 column 0). Refusing to start with an
|
||||||
|
empty vault — move the file aside to start fresh.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What was wrong
|
||||||
|
|
||||||
|
The vault was written as `fs::write(tmp)` → `rename(tmp, path)`. The rename is
|
||||||
|
atomic, so a reader never saw a half-written file — but **atomicity is not
|
||||||
|
durability**. Nothing fsynced the new bytes before the rename, so on ext4
|
||||||
|
(`data=ordered`, the default) a crash or hard power-off could commit the rename
|
||||||
|
while the data it published never landed. The result is a file that exists and
|
||||||
|
is zero bytes long, which is exactly what the message above describes.
|
||||||
|
|
||||||
|
Two things made it far more likely. The write happened on **every** push tick,
|
||||||
|
so a ten-account vault was rewriting all of its secrets thousands of times a
|
||||||
|
day, and there was no backup to fall back on — one damaged file meant a crash
|
||||||
|
loop, with the web UI that could have fixed it locked behind the service that
|
||||||
|
would not start.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
**Durable writes.** `store_io::write_atomic` fsyncs the new bytes, renames, then
|
||||||
|
fsyncs the directory. The rename now cannot reach the journal ahead of the data
|
||||||
|
it publishes.
|
||||||
|
|
||||||
|
**A backup, and recovery from it.** Every write refreshes `vault.json.bak`. The
|
||||||
|
backup is taken *after* the new file is safely in place, so the two always hold
|
||||||
|
the same accounts — backing up first would leave the backup a full change
|
||||||
|
behind and quietly lose your most recent edit. At startup, an unparseable
|
||||||
|
`vault.json` now falls back to that backup, logs one `ERROR` explaining what
|
||||||
|
happened, moves the damaged file to `vault.json.corrupt-<timestamp>` instead of
|
||||||
|
deleting it, and writes the recovered accounts back so the next boot is
|
||||||
|
ordinary. With no usable backup it still refuses to start, exactly as before.
|
||||||
|
|
||||||
|
**The secrets are no longer rewritten on every code rotation.** Discord
|
||||||
|
bookkeeping — message ids, last-sent codes, last errors — moved to its own
|
||||||
|
`push-state.json`, batched and flushed at most every five seconds, with a final
|
||||||
|
flush on clean shutdown. It is a cache by construction: losing or corrupting it
|
||||||
|
costs one reposted Discord tile, never a TOTP secret, so it is never a reason to
|
||||||
|
refuse to boot.
|
||||||
|
|
||||||
|
### Upgrading from v0.1.2
|
||||||
|
|
||||||
|
Nothing to do. The vault format is unchanged and no accounts need re-importing.
|
||||||
|
|
||||||
|
On Unraid, either wait for the Docker page to notice `:latest`, or force it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull gitea.milesward.dev/mward4/discord-authenticator:0.1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
then **Force Update** on the container. Pin the image to `0.1.3` rather than
|
||||||
|
`:latest` in the Unraid template if you would rather updates be deliberate.
|
||||||
|
|
||||||
|
Your data directory gains two files: `vault.json.bak` (a copy of the vault —
|
||||||
|
include it in backups alongside `vault.json`) and `push-state.json` (safe to
|
||||||
|
delete at any time; the tiles are simply reposted).
|
||||||
|
|
||||||
|
### If you already hit this
|
||||||
|
|
||||||
|
If your `vault.json` is currently zero bytes, check for a `.bak`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata/discord-authenticator
|
||||||
|
ls -la vault* && head -c 100 vault.json.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
`v0.1.3` recovers automatically when that backup parses — just update the image
|
||||||
|
and start it. If there is no usable backup, the bytes are gone and the accounts
|
||||||
|
must be re-added from your own `otpauth://` links or a Google Authenticator
|
||||||
|
export. **Keep `vault.key`**: without it, nothing in an old vault file can be
|
||||||
|
decrypted even if the bytes turn up later.
|
||||||
|
|
||||||
|
### Verifying the fix
|
||||||
|
|
||||||
|
143 tests cover this, including recovery from a zero-length vault, retention of
|
||||||
|
the damaged file, write-back on recovery, refusal when both files are bad,
|
||||||
|
reload of tile ids across a restart, batching, and an assertion that a code
|
||||||
|
rotation leaves `vault.json` byte-identical.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## An accurate message, and a way out
|
||||||
|
|
||||||
|
No functional change to how the vault is written or recovered. This fixes what
|
||||||
|
the service tells you when recovery is not possible, and adds an escape hatch
|
||||||
|
for the case it is not.
|
||||||
|
|
||||||
|
### The message said the wrong thing
|
||||||
|
|
||||||
|
When neither `vault.json` nor `vault.json.bak` could be loaded, the error read:
|
||||||
|
|
||||||
|
> ...and the backup at /var/lib/discord-authenticator/vault.json.bak could not
|
||||||
|
> be used either.
|
||||||
|
|
||||||
|
That is true for three quite different situations, and it reads as though a
|
||||||
|
backup exists. The case that actually bites is the one where **no backup was
|
||||||
|
ever created**: a vault that has not completed a single write since v0.1.3
|
||||||
|
introduced backups has no `.bak` at all, so there is nothing on disk to recover
|
||||||
|
and no file to go looking for. The old wording sent you looking anyway.
|
||||||
|
|
||||||
|
It now says which situation you are in:
|
||||||
|
|
||||||
|
- `there is no backup at … — this vault has not been written since backups were
|
||||||
|
introduced, so none was ever created`
|
||||||
|
- `the backup at … exists but could not be parsed either`
|
||||||
|
- `the backup at … is empty as well, so there is nothing to recover`
|
||||||
|
|
||||||
|
It also lists any `vault.json.corrupt-<timestamp>` files left by earlier
|
||||||
|
recoveries, since those are the only other thing worth inspecting.
|
||||||
|
|
||||||
|
### DA_VAULT_RESET
|
||||||
|
|
||||||
|
Refusing to start on an unreadable vault is still the default — booting empty
|
||||||
|
would look identical to a vault you had emptied on purpose, and the whole point
|
||||||
|
is that the service cannot tell the difference. But refusing must not be a dead
|
||||||
|
end, and having to edit files by hand inside a container that will not start is
|
||||||
|
a poor answer at 2am.
|
||||||
|
|
||||||
|
Set `DA_VAULT_RESET=1` and the service boots with an empty vault instead. It
|
||||||
|
moves the unreadable file to `vault.json.corrupt-<timestamp>` *before* writing
|
||||||
|
the fresh one, so nothing is overwritten, and the log line names where it went.
|
||||||
|
Remove the variable afterwards: it has no effect on a vault that loads, but
|
||||||
|
leaving it set means the next genuinely-broken vault boots empty rather than
|
||||||
|
telling you about it.
|
||||||
|
|
||||||
|
In the Unraid template: Docker → the container → **Add another Path, Variable,
|
||||||
|
Port or Label** → Variable, Key `DA_VAULT_RESET`, Value `1`.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Nothing to do. Vault format unchanged, no accounts to re-import. If you are
|
||||||
|
currently stuck on the old message, this release does not recover the accounts
|
||||||
|
either — it just tells you why, and lets you start clean without leaving the UI.
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
# GITEA_OWNER default: mward4
|
# GITEA_OWNER default: mward4
|
||||||
# GITEA_REPO default: Discord-Authenticator
|
# GITEA_REPO default: Discord-Authenticator
|
||||||
# IMAGE_NAME default: discord-authenticator
|
# IMAGE_NAME default: discord-authenticator
|
||||||
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
|
# IMAGE_TAG default: 0.1.3 (also pushed as :latest)
|
||||||
# PLATFORM default: linux/amd64 (Unraid is x86_64)
|
# PLATFORM default: linux/amd64 (Unraid is x86_64)
|
||||||
# GIT_REF default: v0.1 (tag/branch to build)
|
# GIT_REF default: v0.1.3 (tag/branch to build)
|
||||||
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
|
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
|
||||||
#
|
#
|
||||||
# After this, set the Unraid template's Repository to:
|
# 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_OWNER=${GITEA_OWNER:-mward4}
|
||||||
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
|
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
|
||||||
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
|
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
|
||||||
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
|
IMAGE_TAG=${IMAGE_TAG:-0.1.3}
|
||||||
PLATFORM=${PLATFORM:-linux/amd64}
|
PLATFORM=${PLATFORM:-linux/amd64}
|
||||||
GIT_REF=${GIT_REF:-v0.1}
|
GIT_REF=${GIT_REF:-v0.1.3}
|
||||||
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
|
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
|
||||||
|
|
||||||
# OCI distribution rejects uppercase repository paths, so the image name is
|
# OCI distribution rejects uppercase repository paths, so the image name is
|
||||||
|
|||||||
Reference in new issue
Block a user