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]]
|
||||
name = "da-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base32",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-discord"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"da-core",
|
||||
@@ -478,9 +478,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-http-api"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"da-core",
|
||||
"da-discord",
|
||||
"da-render",
|
||||
@@ -499,7 +500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-render"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"resvg",
|
||||
@@ -511,7 +512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-webui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
@@ -541,7 +542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "discord-authenticator"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
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
|
||||
messages that rewrite themselves** as each code rolls.
|
||||
<h1 align="center">Discord Authenticator</h1>
|
||||
|
||||
<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
|
||||
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)
|
||||
- [Configuration](#configuration)
|
||||
- [Architecture](#architecture)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [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`
|
||||
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 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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Pushing a version tag builds, smoke-tests, pushes and releases — see
|
||||
[Automation](#automation) below. To do it by hand instead:
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -169,20 +242,57 @@ Settings.
|
||||
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
|
||||
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
|
||||
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
|
||||
HTTPS or on `localhost`.
|
||||
- **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
|
||||
and `=`-padded secrets, and where the issuer in the path disagrees with the
|
||||
`?issuer=` parameter — which Microsoft does — it takes the query parameter
|
||||
rather than refusing the account.
|
||||
|
||||
**In the list**, click any code to copy it. `/` focuses the search box. The
|
||||
gear on a row renames the account or pauses its Discord push; pausing removes
|
||||
its tile from the channel.
|
||||
**In the list**, click any code to copy it. `/` focuses the search box.
|
||||
|
||||
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/
|
||||
core/ TOTP, vault, crypto, settings, admin auth — no network
|
||||
(incl. Google Authenticator export parsing)
|
||||
render/ SVG → WebP tile rendering (resvg + embedded fonts)
|
||||
discord/ Webhook client, rate limiter, live-tile push engine
|
||||
webui/ Embedded HTML/CSS/JS, compiled into the binary
|
||||
@@ -241,14 +352,26 @@ JetBrains Mono. Light and dark both ship.
|
||||
|
||||
### Testing
|
||||
|
||||
101 tests, including:
|
||||
143 tests, including:
|
||||
|
||||
- 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
|
||||
`?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
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
+22
-1
@@ -10,16 +10,37 @@
|
||||
pub mod auth;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod migration;
|
||||
pub mod settings;
|
||||
pub mod store_io;
|
||||
pub mod totp;
|
||||
pub mod vault;
|
||||
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
pub use crypto::{Sealed, VaultKey};
|
||||
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 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.
|
||||
///
|
||||
|
||||
@@ -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-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(
|
||||
"this is a Google Authenticator export link, which contains several \
|
||||
accounts at once. Export them individually, or add each account's \
|
||||
own QR code.",
|
||||
"this is a Google Authenticator export link — use the import flow, \
|
||||
which reads all of its accounts at once.",
|
||||
));
|
||||
}
|
||||
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 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());
|
||||
tokio::spawn(engine.clone().run());
|
||||
|
||||
@@ -97,6 +101,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.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");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ impl std::fmt::Debug for 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 {
|
||||
Self {
|
||||
vault,
|
||||
@@ -81,6 +87,14 @@ impl PushEngine {
|
||||
/// One pass: bring every tile up to date and tidy away any that shouldn't
|
||||
/// exist. Returns how many messages were written.
|
||||
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
|
||||
// seconds once the rate limiter spaces things out, which is easily
|
||||
// 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());
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn pausing_an_account_removes_its_tile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -29,3 +29,4 @@ workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
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/logout", post(auth::api_logout))
|
||||
.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/import", post(import_accounts))
|
||||
.route(
|
||||
"/api/accounts/{id}",
|
||||
axum::routing::patch(update_account).delete(delete_account),
|
||||
@@ -114,27 +115,59 @@ struct PreviewBody {
|
||||
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
|
||||
/// confirmation before anything touches the vault.
|
||||
async fn preview_account(Json(body): Json<PreviewBody>) -> Response {
|
||||
match da_core::parse_otpauth(&body.uri) {
|
||||
Ok(a) => Json(json!({
|
||||
"issuer": a.issuer,
|
||||
"label": a.label,
|
||||
"algorithm": a.totp.algorithm.as_str(),
|
||||
"digits": a.totp.digits,
|
||||
"period": a.totp.period,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(e) => crate::error_response(&e),
|
||||
}
|
||||
/// Handles both a plain `otpauth://` URI (one account) and a Google
|
||||
/// Authenticator `otpauth-migration://` export (many), so the UI has a single
|
||||
/// code path. Each entry is flagged if it is already in the vault, letting the
|
||||
/// import screen pre-uncheck it rather than failing later.
|
||||
async fn preview_account(State(state): State<AppState>, Json(body): Json<PreviewBody>) -> Response {
|
||||
let batch = match da_core::parse_import(&body.uri) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return crate::error_response(&e),
|
||||
};
|
||||
|
||||
let accounts: Vec<_> = batch
|
||||
.accounts
|
||||
.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)]
|
||||
struct AddBody {
|
||||
uri: String,
|
||||
struct ImportItem {
|
||||
/// Index into the preview's `accounts` array.
|
||||
index: usize,
|
||||
#[serde(default)]
|
||||
issuer: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -143,25 +176,44 @@ struct AddBody {
|
||||
push_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImportBody {
|
||||
uri: String,
|
||||
items: Vec<ImportItem>,
|
||||
}
|
||||
|
||||
async fn add_account(State(state): State<AppState>, Json(body): Json<AddBody>) -> Response {
|
||||
let parsed = match da_core::parse_otpauth(&body.uri) {
|
||||
Ok(p) => p,
|
||||
/// Import the selected accounts from a link.
|
||||
///
|
||||
/// 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),
|
||||
};
|
||||
match state.vault.add(
|
||||
&parsed,
|
||||
body.issuer.as_deref(),
|
||||
body.label.as_deref(),
|
||||
body.push_enabled,
|
||||
) {
|
||||
// The push loop picks the new account up on its next tick and posts
|
||||
// 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(),
|
||||
|
||||
let mut queued = Vec::with_capacity(body.items.len());
|
||||
for item in &body.items {
|
||||
let Some(parsed) = batch.accounts.get(item.index) else {
|
||||
return bad_request(&format!(
|
||||
"selection {} is out of range for this link — reload and try again",
|
||||
item.index
|
||||
));
|
||||
};
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -207,6 +259,10 @@ async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ── Codes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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) {
|
||||
Ok(found) => {
|
||||
// An image can hold several codes; prefer an otpauth one over
|
||||
// whatever else happens to be in frame (a Wi-Fi QR, a URL).
|
||||
let best = found
|
||||
// An image can hold several codes — a Google export screenshot
|
||||
// legitimately does. Return them all, OTP links first, so the
|
||||
// caller can import every account in one pass instead of only
|
||||
// whichever happened to be detected first.
|
||||
let mut uris: Vec<String> = found
|
||||
.iter()
|
||||
.find(|s| s.to_ascii_lowercase().starts_with("otpauth"))
|
||||
.or_else(|| found.first());
|
||||
match best {
|
||||
Some(uri) => Json(json!({ "uri": uri })).into_response(),
|
||||
.filter(|s| {
|
||||
let l = s.to_ascii_lowercase();
|
||||
l.starts_with("otpauth://") || l.starts_with("otpauth-migration://")
|
||||
})
|
||||
.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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,104 @@ mod tests {
|
||||
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]
|
||||
fn empty_input_is_rejected() {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let svg = build_svg(tile);
|
||||
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; }
|
||||
|
||||
/* 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 {
|
||||
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);
|
||||
padding: 0 16px;
|
||||
background: color-mix(in oklab, var(--page) 82%, transparent);
|
||||
backdrop-filter: saturate(1.6) blur(12px);
|
||||
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 svg { width: 26px; height: 26px; display: block; }
|
||||
@@ -192,8 +216,9 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
justify-self: center;
|
||||
}
|
||||
.search svg {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
@@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: max(20px, calc(50vw - 344px));
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 25;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── 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 ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.toasts {
|
||||
|
||||
+230
-45
@@ -89,6 +89,7 @@ const ICON = {
|
||||
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'],
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -349,27 +350,31 @@ function renderShell() {
|
||||
|
||||
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', {}, [
|
||||
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
|
||||
logoImg, h('strong', { text: 'Authenticator' }),
|
||||
logoImg, h('strong', { text: 'Discord Authenticator' }),
|
||||
]),
|
||||
searchWrap,
|
||||
h('div.spacer'),
|
||||
h('button.icon-btn.theme-toggle', {
|
||||
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
||||
onclick: toggleTheme,
|
||||
}, [
|
||||
icon(ICON.sun, { className: 't-sun', width: 2 }),
|
||||
icon(ICON.moon, { className: 't-moon', width: 2 }),
|
||||
h('div.actions', {}, [
|
||||
h('button.icon-btn.theme-toggle', {
|
||||
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
||||
onclick: toggleTheme,
|
||||
}, [
|
||||
icon(ICON.sun, { className: 't-sun', 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' })]));
|
||||
@@ -480,9 +485,9 @@ function acctRow(a) {
|
||||
codeBtn,
|
||||
ring,
|
||||
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),
|
||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
||||
}, [icon(ICON.edit, { width: 1.9 })]),
|
||||
]),
|
||||
]);
|
||||
return row;
|
||||
@@ -697,7 +702,7 @@ function openAddSheet() {
|
||||
]));
|
||||
try {
|
||||
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) {
|
||||
panel.replaceChildren(
|
||||
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
|
||||
@@ -777,7 +782,7 @@ function openAddSheet() {
|
||||
} catch (_) { /* transient decode error — keep going */ }
|
||||
timer = setTimeout(scan, detector ? 180 : 650);
|
||||
};
|
||||
const done = (uri) => { stop(); confirmPane(uri); };
|
||||
const done = (uri) => { stop(); previewUris([uri]); };
|
||||
scan();
|
||||
})();
|
||||
|
||||
@@ -809,13 +814,13 @@ function openAddSheet() {
|
||||
onclick: () => {
|
||||
err.classList.add('hidden');
|
||||
const uri = ta.value.trim();
|
||||
if (uri) return confirmPane(uri);
|
||||
if (uri) return previewUris([uri]);
|
||||
if (mSecret.value.trim()) {
|
||||
const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') });
|
||||
if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim());
|
||||
const path = encodeURIComponent(
|
||||
(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.classList.remove('hidden');
|
||||
@@ -824,36 +829,105 @@ function openAddSheet() {
|
||||
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. */
|
||||
async function confirmPane(uri) {
|
||||
/** Fetch a preview for every link found, then show the import list. */
|
||||
async function previewUris(uris) {
|
||||
panel.replaceChildren(h('div.dropzone', {}, [
|
||||
icon(ICON.search, { width: 1.7, className: 'spin' }),
|
||||
h('strong', { text: 'Checking…' }),
|
||||
h('strong', { text: 'Reading…' }),
|
||||
]));
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
|
||||
} catch (e) {
|
||||
|
||||
const sources = [];
|
||||
const errors = [];
|
||||
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(
|
||||
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(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const issuer = h('input.input', { value: parsed.issuer || '' });
|
||||
const label = h('input.input', { value: parsed.label || '' });
|
||||
renderImport(sources, errors);
|
||||
}
|
||||
|
||||
/** 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 save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' });
|
||||
|
||||
save.addEventListener('click', async () => {
|
||||
save.disabled = true; save.textContent = 'Adding…';
|
||||
try {
|
||||
await api('/api/accounts', {
|
||||
const out = await api('/api/accounts/import', {
|
||||
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();
|
||||
toast('Account added');
|
||||
toast(out.added.length ? 'Account added' : 'That account is already in your vault',
|
||||
out.added.length ? 'ok' : 'err');
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
save.disabled = false; save.textContent = 'Add account';
|
||||
@@ -862,21 +936,123 @@ function openAddSheet() {
|
||||
});
|
||||
|
||||
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: 'Account' }), label]),
|
||||
h('dl', {}, [
|
||||
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: parsed.algorithm })]),
|
||||
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(parsed.digits) })]),
|
||||
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: parsed.period + 's' })]),
|
||||
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: acct.algorithm })]),
|
||||
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(acct.digits) })]),
|
||||
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: acct.period + 's' })]),
|
||||
]),
|
||||
h('label.switch', { style: 'margin:16px 0' }, [
|
||||
push, h('span.track'),
|
||||
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
||||
]),
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -886,15 +1062,21 @@ function openAccountSheet(a) {
|
||||
const label = h('input.input', { value: a.label || '' });
|
||||
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,
|
||||
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
|
||||
h('div.field', {}, [h('label', { text: 'Account' }), label]),
|
||||
h('div.field', {}, [
|
||||
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' }, [
|
||||
push, h('span.track'),
|
||||
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 () => {
|
||||
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'); }
|
||||
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
|
||||
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 () => {
|
||||
try {
|
||||
await api('/api/accounts/' + encodeURIComponent(a.id), {
|
||||
method: 'PATCH',
|
||||
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'); }
|
||||
}, text: 'Save' }),
|
||||
]);
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
Self-hosted TOTP authenticator with live Discord delivery.
|
||||
|
||||
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,
|
||||
the way Google or Microsoft Authenticator does.
|
||||
an otpauth:// link. Google Authenticator exports are supported too: one
|
||||
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
|
||||
channel, delivered through an incoming webhook and rewritten in place
|
||||
@@ -42,7 +45,7 @@
|
||||
<Category>Tools:Utilities Security:</Category>
|
||||
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
|
||||
<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/>
|
||||
<PostArgs/>
|
||||
<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_REPO 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)
|
||||
# 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)
|
||||
#
|
||||
# After this, set the Unraid template's Repository to:
|
||||
@@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
|
||||
GITEA_OWNER=${GITEA_OWNER:-mward4}
|
||||
GITEA_REPO=${GITEA_REPO:-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}
|
||||
GIT_REF=${GIT_REF:-v0.1}
|
||||
GIT_REF=${GIT_REF:-v0.1.3}
|
||||
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
|
||||
|
||||
# OCI distribution rejects uppercase repository paths, so the image name is
|
||||
|
||||
Reference in new issue
Block a user