Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7f09095f | ||
|
|
72b9cbcab8 | ||
|
|
1b652a4ba2 | ||
|
|
316248c28d | ||
|
|
3677c977a2 | ||
|
|
1b9f3251d3 | ||
|
|
85e3a08e52 | ||
|
|
1f456cad48 | ||
|
|
451cdd19da | ||
|
|
3288b8314e | ||
|
|
0dcf760f13 | ||
|
|
ef7ae7116f | ||
|
|
1f4697a885 | ||
|
|
60348b8133 | ||
|
|
b21ade05eb |
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
+6
-6
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-core"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base32",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-discord"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"da-core",
|
||||
@@ -478,7 +478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-http-api"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -500,7 +500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-render"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"resvg",
|
||||
@@ -512,7 +512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-webui"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
@@ -542,7 +542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "discord-authenticator"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -28,6 +28,7 @@ pull), no database, no external services beyond Discord itself.
|
||||
- [Using it](#using-it)
|
||||
- [Configuration](#configuration)
|
||||
- [Architecture](#architecture)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Development](#development)
|
||||
|
||||
---
|
||||
@@ -113,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
|
||||
@@ -154,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
|
||||
@@ -285,7 +352,7 @@ JetBrains Mono. Light and dark both ship.
|
||||
|
||||
### Testing
|
||||
|
||||
126 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,
|
||||
@@ -300,6 +367,11 @@ JetBrains Mono. Light and dark both ship.
|
||||
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
||||
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
|
||||
@@ -308,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
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod migration;
|
||||
pub mod settings;
|
||||
pub mod store_io;
|
||||
pub mod totp;
|
||||
pub mod vault;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+807
-28
@@ -11,16 +11,50 @@
|
||||
//! Everywhere else falling back to defaults is the friendly move; here it
|
||||
//! would silently present an empty vault and invite the operator to re-add
|
||||
//! accounts they already have, so we refuse to start instead.
|
||||
//!
|
||||
//! ## Why there are two files
|
||||
//!
|
||||
//! `vault.json` holds the accounts — the irreplaceable part. `push-state.json`
|
||||
//! holds the Discord bookkeeping: message ids, last-sent codes, last error.
|
||||
//! They are split because they change at wildly different rates. One code
|
||||
//! rotation is one push per account, so at a 5-second tick a ten-account vault
|
||||
//! rewrites its bookkeeping thousands of times a day, while the accounts
|
||||
//! themselves change only when the operator adds, renames or removes one.
|
||||
//!
|
||||
//! Keeping them in one file meant every one of those ticks rewrote 100% of the
|
||||
//! secrets. Two files mean the secret-bearing file is touched only on real
|
||||
//! edits, and the file that *is* written constantly is a pure cache: losing it
|
||||
//! costs a duplicate Discord tile, never a TOTP secret.
|
||||
//!
|
||||
//! ## Surviving a power cut
|
||||
//!
|
||||
//! Both files go through [`crate::store_io::write_atomic`], which fsyncs the
|
||||
//! new bytes before renaming them into place and then fsyncs the directory.
|
||||
//! That ordering matters more than the atomicity: a rename can reach the
|
||||
//! journal before the data it publishes, which on ext4 is exactly how you end
|
||||
//! up with a file that exists and is zero bytes long. `vault.json.bak` keeps
|
||||
//! the previous good copy, and [`Vault::load`] falls back to it rather than
|
||||
//! leaving the operator with a service that will not boot.
|
||||
|
||||
use crate::crypto::{Sealed, VaultKey};
|
||||
use crate::store_io::write_atomic;
|
||||
use crate::totp::{Algorithm, OtpAuth, Totp};
|
||||
use crate::{Error, Result};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// How long a Discord bookkeeping change may sit unflushed in memory.
|
||||
///
|
||||
/// The push loop calls [`Vault::flush_state`] on every pass, and a pass
|
||||
/// happens every few seconds; this bounds the write rate independently of how
|
||||
/// many accounts are pushing. Nothing here is worth a synchronous disk write,
|
||||
/// because losing it only costs one re-created Discord message.
|
||||
const STATE_FLUSH_AFTER: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Live-message bookkeeping for one account's Discord tile.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -149,11 +183,51 @@ struct VaultFile {
|
||||
accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
/// The Discord bookkeeping for every account, kept apart from the secrets.
|
||||
///
|
||||
/// Keyed by account id so the vault file never has to be rewritten when a code
|
||||
/// rotates, and so an entry for an account that has since been removed is
|
||||
/// simply ignored on load rather than needing a cleanup pass.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct PushStateFile {
|
||||
#[serde(default)]
|
||||
accounts: HashMap<String, DiscordState>,
|
||||
}
|
||||
|
||||
/// Bookkeeping changes that have not reached disk yet.
|
||||
#[derive(Debug, Default)]
|
||||
struct PendingState {
|
||||
dirty: bool,
|
||||
last_flush: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl PendingState {
|
||||
/// True when this change is due to be written: something is dirty *and*
|
||||
/// the last write is old enough. The first change after a quiet period
|
||||
/// flushes immediately, so a fresh message id is on disk within the tick
|
||||
/// that created it rather than waiting out a timer.
|
||||
fn due(&self) -> bool {
|
||||
self.dirty
|
||||
&& self
|
||||
.last_flush
|
||||
.is_none_or(|t| t.elapsed() >= STATE_FLUSH_AFTER)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Vault {
|
||||
path: Arc<PathBuf>,
|
||||
/// Where the previous good `vault.json` is kept, so a file damaged by a
|
||||
/// crash can be recovered instead of ending the service's life.
|
||||
backup_path: Arc<PathBuf>,
|
||||
state_path: Arc<PathBuf>,
|
||||
key: Arc<VaultKey>,
|
||||
inner: Arc<RwLock<Vec<Account>>>,
|
||||
/// Serializes whole-file writes. Two threads persisting at once would
|
||||
/// otherwise interleave on the same temp path and could rename a partial
|
||||
/// file into place.
|
||||
write_lock: Arc<parking_lot::Mutex<()>>,
|
||||
pending: Arc<parking_lot::Mutex<PendingState>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_fields_in_debug)] // secrets must never reach a log line
|
||||
@@ -169,30 +243,89 @@ impl Vault {
|
||||
/// Load `<work_dir>/vault.json`, or start empty if it doesn't exist yet.
|
||||
///
|
||||
/// A file that exists but won't parse is an error, not a fallback — see
|
||||
/// the module docs.
|
||||
/// the module docs. Before giving up we try `vault.json.bak`, the previous
|
||||
/// good copy, because refusing to boot is only the right answer when there
|
||||
/// is genuinely nothing left to load: the operator is otherwise locked out
|
||||
/// of every account they hold, with the web UI that could fix it being the
|
||||
/// very thing that won't start.
|
||||
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
|
||||
let path = work_dir.join("vault.json");
|
||||
let accounts = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
serde_json::from_str::<VaultFile>(&text)
|
||||
.map_err(|e| {
|
||||
Error::invalid(format!(
|
||||
"{} exists but could not be parsed ({e}). Refusing to start \
|
||||
with an empty vault — move the file aside to start fresh.",
|
||||
path.display()
|
||||
))
|
||||
})?
|
||||
.accounts
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let backup_path = backup_of(&path);
|
||||
// `load_accounts` decides on DA_VAULT_RESET itself, because that is
|
||||
// where the description of what went wrong is built.
|
||||
let (accounts, recovered) = load_accounts(&path, &backup_path)?;
|
||||
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
|
||||
Ok(Self {
|
||||
|
||||
let state_path = work_dir.join("push-state.json");
|
||||
let v = Self {
|
||||
path: Arc::new(path),
|
||||
backup_path: Arc::new(backup_path),
|
||||
state_path: Arc::new(state_path.clone()),
|
||||
key: Arc::new(key),
|
||||
inner: Arc::new(RwLock::new(accounts)),
|
||||
})
|
||||
write_lock: Arc::new(parking_lot::Mutex::new(())),
|
||||
pending: Arc::new(parking_lot::Mutex::new(PendingState::default())),
|
||||
};
|
||||
v.restore_push_state(&state_path);
|
||||
if recovered {
|
||||
// Put the recovered accounts back where a vault belongs. Until
|
||||
// this happens the only copy on disk is the `.bak`, so a second
|
||||
// unclean stop would leave nothing to recover from; doing it now
|
||||
// also means the next boot takes the ordinary path.
|
||||
v.persist();
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Re-attach the Discord bookkeeping to the loaded accounts.
|
||||
///
|
||||
/// Entirely best effort: this file is a cache, so a missing, unreadable or
|
||||
/// stale one costs a re-posted tile and nothing more. Deliberately *not* a
|
||||
/// reason to refuse to start, which is the whole point of keeping it
|
||||
/// separate from the vault.
|
||||
fn restore_push_state(&self, state_path: &Path) {
|
||||
let text = match std::fs::read_to_string(state_path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "da::vault",
|
||||
path = %state_path.display(),
|
||||
"could not read push state, tiles will be re-created: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parsed = match serde_json::from_str::<PushStateFile>(&text) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "da::vault",
|
||||
path = %state_path.display(),
|
||||
"push state is unreadable, tiles will be re-created: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut g = self.inner.write();
|
||||
let mut restored = 0usize;
|
||||
for a in g.iter_mut() {
|
||||
// An entry with no message id carries nothing worth restoring.
|
||||
if let Some(state) = parsed.accounts.get(&a.id) {
|
||||
if state.message_id.is_some() {
|
||||
a.discord = state.clone();
|
||||
restored += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if restored > 0 {
|
||||
tracing::info!(
|
||||
target: "da::vault",
|
||||
count = restored,
|
||||
"restored Discord tile ids"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -427,7 +560,7 @@ impl Vault {
|
||||
a.discord.last_error = None;
|
||||
a.discord.last_push_at = Some(OffsetDateTime::now_utc());
|
||||
}
|
||||
self.persist();
|
||||
self.touch_state();
|
||||
}
|
||||
|
||||
/// Record a delivery failure against one account.
|
||||
@@ -444,7 +577,7 @@ impl Vault {
|
||||
}
|
||||
a.discord.last_error = Some(error.to_string());
|
||||
}
|
||||
self.persist();
|
||||
self.touch_state();
|
||||
}
|
||||
|
||||
/// Forget an account's message id — used when Discord reports the
|
||||
@@ -458,7 +591,7 @@ impl Vault {
|
||||
a.discord.message_id = None;
|
||||
a.discord.last_code = None;
|
||||
}
|
||||
self.persist();
|
||||
self.touch_state();
|
||||
}
|
||||
|
||||
/// Drop every stored message id. Called when the webhook URL changes:
|
||||
@@ -470,7 +603,11 @@ impl Vault {
|
||||
a.discord = DiscordState::default();
|
||||
}
|
||||
}
|
||||
self.persist();
|
||||
self.touch_state();
|
||||
// A webhook change is a deliberate, rare operator action and the
|
||||
// caller is about to tell them it worked, so make it durable now
|
||||
// rather than on the next tick.
|
||||
self.flush_state_now();
|
||||
}
|
||||
|
||||
/// Is an identical account already stored?
|
||||
@@ -558,7 +695,14 @@ impl Vault {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Write the accounts out.
|
||||
///
|
||||
/// Called only when the account list itself changes. Discord bookkeeping
|
||||
/// rides in [`Vault::state_path`] instead and never reaches this function,
|
||||
/// so the secrets are rewritten on operator edits rather than on every
|
||||
/// code rotation — see the module docs.
|
||||
fn persist(&self) {
|
||||
let _guard = self.write_lock.lock();
|
||||
let snapshot = VaultFile {
|
||||
accounts: self.inner.read().clone(),
|
||||
};
|
||||
@@ -572,15 +716,269 @@ impl Vault {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::error!(target: "da::vault", "could not write vault temp file: {e}");
|
||||
if let Err(e) = write_atomic(&self.path, &body) {
|
||||
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
|
||||
// Refresh the backup *after* the new vault is safely in place, so the
|
||||
// two files always hold the same accounts. The tempting order — back
|
||||
// up the old file, then overwrite it — leaves the backup a full change
|
||||
// behind, which quietly loses the most recent edit if the vault is
|
||||
// later damaged. Recovery is only worth having if it recovers
|
||||
// everything.
|
||||
if let Err(e) = std::fs::copy(self.path.as_path(), self.backup_path.as_path()) {
|
||||
tracing::warn!(target: "da::vault", "could not refresh vault backup: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the Discord bookkeeping as changed.
|
||||
///
|
||||
/// Cheap on purpose: the actual write is deferred to
|
||||
/// [`Vault::flush_state`], which the push loop calls once per pass.
|
||||
fn touch_state(&self) {
|
||||
let mut g = self.pending.lock();
|
||||
g.dirty = true;
|
||||
}
|
||||
|
||||
/// Write `push-state.json` if anything has changed and enough time has
|
||||
/// passed. Safe and cheap to call on every push tick.
|
||||
pub fn flush_state(&self) {
|
||||
{
|
||||
let g = self.pending.lock();
|
||||
if !g.due() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.write_state();
|
||||
}
|
||||
|
||||
/// Write `push-state.json` unconditionally — for shutdown, where there is
|
||||
/// no next tick to catch a deferred write.
|
||||
pub fn flush_state_now(&self) {
|
||||
self.write_state();
|
||||
}
|
||||
|
||||
fn write_state(&self) {
|
||||
let _guard = self.write_lock.lock();
|
||||
let body = {
|
||||
let accounts = self.inner.read();
|
||||
let file = PushStateFile {
|
||||
accounts: accounts
|
||||
.iter()
|
||||
.filter(|a| a.discord.message_id.is_some() || a.discord.last_error.is_some())
|
||||
.map(|a| (a.id.clone(), a.discord.clone()))
|
||||
.collect(),
|
||||
};
|
||||
match serde_json::to_vec(&file) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::error!(target: "da::vault", "could not serialize push state: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.state_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = write_atomic(&self.state_path, &body) {
|
||||
tracing::error!(target: "da::vault", "could not write push state: {e}");
|
||||
// Leave it dirty so the next tick tries again rather than
|
||||
// silently forgetting the change.
|
||||
return;
|
||||
}
|
||||
let mut g = self.pending.lock();
|
||||
g.dirty = false;
|
||||
g.last_flush = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Has the operator explicitly asked to start with an empty vault?
|
||||
///
|
||||
/// Set `DA_VAULT_RESET=1` to boot an unreadable vault as empty instead of
|
||||
/// refusing. Checked only on the failure path, so it has no effect on a vault
|
||||
/// that loads normally.
|
||||
fn reset_requested() -> bool {
|
||||
std::env::var("DA_VAULT_RESET")
|
||||
.map(|v| looks_like_reset(&v))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Split out so the accepted spellings can be tested without touching the
|
||||
/// process environment.
|
||||
fn looks_like_reset(value: &str) -> bool {
|
||||
let v = value.trim();
|
||||
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
|
||||
}
|
||||
|
||||
/// Serializes the tests that write process environment variables. Rust runs
|
||||
/// tests in parallel threads within one process, so an env var set by one test
|
||||
/// is visible to every other test running at that moment.
|
||||
#[cfg(test)]
|
||||
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// `vault.json` → `vault.json.bak`.
|
||||
fn backup_of(path: &Path) -> PathBuf {
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.map_or_else(|| std::ffi::OsString::from("vault"), ToOwned::to_owned);
|
||||
name.push(".bak");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Read the accounts, falling back to the backup if the main file is damaged.
|
||||
///
|
||||
/// Refusing to start on a bad vault is right — silently starting empty invites
|
||||
/// the operator to re-add accounts they already have. But there is a large gap
|
||||
/// between "won't parse" and "unrecoverable", and the backup lives in it:
|
||||
/// whenever one exists and does parse, the honest move is to use it and say so
|
||||
/// loudly, keeping the damaged original for inspection.
|
||||
///
|
||||
/// When *nothing* is recoverable the refusal has to say why precisely, because
|
||||
/// the two cases need opposite responses from the operator. A backup that is
|
||||
/// present and unreadable means both copies are gone and the accounts have to
|
||||
/// be re-added; a backup that was never created (a container whose first write
|
||||
/// never completed, so this feature has not run once) means the same file is
|
||||
/// fine and there is simply nothing on disk to recover. The old wording said
|
||||
/// "the backup could not be used either" for both, which sent the operator
|
||||
/// looking for a file that did not exist.
|
||||
fn load_accounts(path: &Path, backup_path: &Path) -> Result<(Vec<Account>, bool)> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
// A missing vault is a first run, not a failure: there is nothing to
|
||||
// recover and the service creates one on its first write.
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), false)),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
match serde_json::from_str::<VaultFile>(&text) {
|
||||
Ok(file) => Ok((file.accounts, false)),
|
||||
Err(primary_err) => {
|
||||
match read_backup(backup_path) {
|
||||
Some(accounts) => {
|
||||
let quarantined = quarantine(path);
|
||||
tracing::error!(
|
||||
target: "da::vault",
|
||||
path = %path.display(),
|
||||
backup = %backup_path.display(),
|
||||
recovered = accounts.len(),
|
||||
quarantined = %quarantined.as_deref().map_or_else(
|
||||
|| "could not move aside".to_string(),
|
||||
|p| p.display().to_string()
|
||||
),
|
||||
"vault.json could not be parsed ({primary_err}); recovered the accounts \
|
||||
from the backup. The damaged file is kept alongside for inspection — \
|
||||
compare it against the backup if anything you added recently is missing."
|
||||
);
|
||||
return Ok((accounts, true));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let damage = describe_damage(path, backup_path, &primary_err.to_string());
|
||||
|
||||
// The refusal is the right default, but it must not be a dead end:
|
||||
// an operator who has accepted that the accounts are gone needs a
|
||||
// way forward that does not involve editing files inside a
|
||||
// container that will not start. The variable has to be set
|
||||
// deliberately, so this can never look like the silent-empty
|
||||
// fallback the refusal exists to prevent.
|
||||
if reset_requested() {
|
||||
// Move the unreadable file aside *before* the empty vault is
|
||||
// written, or the caller's `persist` would overwrite it and
|
||||
// then take the backup from the replacement — destroying the
|
||||
// only copy of whatever might still have been salvageable.
|
||||
let kept = quarantine(path);
|
||||
tracing::warn!(
|
||||
target: "da::vault",
|
||||
preserved = %kept.as_deref().map_or_else(
|
||||
|| "NOTHING — it could not be moved aside and will be overwritten"
|
||||
.to_string(),
|
||||
|p| p.display().to_string()
|
||||
),
|
||||
"DA_VAULT_RESET is set, so starting with an empty vault. {damage}"
|
||||
);
|
||||
return Ok((Vec::new(), true));
|
||||
}
|
||||
|
||||
Err(Error::invalid(format!(
|
||||
"{damage} Refusing to start with an empty vault, because that would look \
|
||||
exactly like a vault you had emptied on purpose. If those accounts are gone, \
|
||||
either move the files aside to start fresh, or set DA_VAULT_RESET=1 to do the \
|
||||
same thing from the Unraid template — either way a copy of the unreadable file \
|
||||
is kept."
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe exactly what is on disk and why nothing could be loaded.
|
||||
///
|
||||
/// The wording has to distinguish the cases, because they need opposite
|
||||
/// responses. A backup that is present and unreadable means both copies are
|
||||
/// gone and the accounts must be re-added. A backup that was never created —
|
||||
/// a container that has not completed a single write since backups were
|
||||
/// introduced — means there is simply nothing on disk to recover. The previous
|
||||
/// message said "the backup could not be used either" for both, which sent an
|
||||
/// operator hunting for a file that did not exist.
|
||||
///
|
||||
/// Returns only the description. The decision to refuse, and the advice that
|
||||
/// goes with it, belongs to the caller: a caller that has been told to reset is
|
||||
/// not refusing anything, and must not say that it is.
|
||||
fn describe_damage(path: &Path, backup_path: &Path, primary_err: &str) -> String {
|
||||
let backup_note = match std::fs::metadata(backup_path) {
|
||||
Ok(m) if m.len() == 0 => format!(
|
||||
"the backup at {} is empty as well, so there is nothing to recover",
|
||||
backup_path.display()
|
||||
),
|
||||
Ok(_) => format!(
|
||||
"the backup at {} exists but could not be parsed either",
|
||||
backup_path.display()
|
||||
),
|
||||
Err(_) => format!(
|
||||
"there is no backup at {} — this vault has not been written since \
|
||||
backups were introduced, so none was ever created",
|
||||
backup_path.display()
|
||||
),
|
||||
};
|
||||
|
||||
// A quarantine file means a previous boot already recovered this vault and
|
||||
// moved a damaged copy aside. That copy is the only other candidate left.
|
||||
let quarantined: Vec<String> = std::fs::read_dir(path.parent().unwrap_or(Path::new(".")))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with("vault.json.corrupt-"))
|
||||
.collect();
|
||||
let quarantine_note = if quarantined.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" Files kept from earlier recoveries that may be worth inspecting: {}.",
|
||||
quarantined.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
format!("vault.json could not be parsed ({primary_err}), and {backup_note}.{quarantine_note}")
|
||||
}
|
||||
|
||||
/// Parse the backup, or `None` if it is absent or not parseable as a vault.
|
||||
fn read_backup(backup_path: &Path) -> Option<Vec<Account>> {
|
||||
let text = std::fs::read_to_string(backup_path).ok()?;
|
||||
serde_json::from_str::<VaultFile>(&text)
|
||||
.ok()
|
||||
.map(|f| f.accounts)
|
||||
}
|
||||
|
||||
/// Move a damaged vault aside so it is preserved but out of the way, and
|
||||
/// report where it went.
|
||||
fn quarantine(path: &Path) -> Option<PathBuf> {
|
||||
let stamp = OffsetDateTime::now_utc().unix_timestamp();
|
||||
let dest = path.with_file_name(format!("vault.json.corrupt-{stamp}"));
|
||||
std::fs::rename(path, &dest).ok().map(|()| dest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -678,6 +1076,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
|
||||
// No backup to fall back on, so this is the unrecoverable case and the
|
||||
// original refusal must stand.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"{ not json").unwrap();
|
||||
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||
@@ -857,12 +1257,15 @@ mod tests {
|
||||
#[test]
|
||||
fn message_ids_are_persisted_as_strings() {
|
||||
// Snowflakes exceed 2^53; a JSON number would round-trip lossily.
|
||||
// Message ids now live in push-state.json, so that is where the
|
||||
// quoting has to hold.
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.mark_pushed(&a.id, "1409876543210987654", "000000");
|
||||
v.flush_state_now();
|
||||
|
||||
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
|
||||
let raw = std::fs::read_to_string(dir.path().join("push-state.json")).unwrap();
|
||||
assert!(
|
||||
raw.contains("\"1409876543210987654\""),
|
||||
"must be quoted: {raw}"
|
||||
@@ -875,6 +1278,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_secret_bearing_file_stays_free_of_discord_state() {
|
||||
// The split is the protection: whatever goes wrong with the file that
|
||||
// is rewritten constantly, it cannot take the secrets with it.
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.mark_pushed(&a.id, "1409876543210987654", "000000");
|
||||
v.flush_state_now();
|
||||
|
||||
let vault_json = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
|
||||
assert!(
|
||||
!vault_json.contains("1409876543210987654"),
|
||||
"vault.json must not carry tile ids: {vault_json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_targets_exclude_paused_accounts_and_keep_order() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -907,4 +1327,363 @@ mod tests {
|
||||
assert!(v.push_targets()[0].message_id.is_none());
|
||||
assert!(!v.get(&a.id).unwrap().push_live);
|
||||
}
|
||||
|
||||
// ── Recovery from a damaged vault ───────────────────────────────────────
|
||||
//
|
||||
// The failure these cover: an unclean shutdown left `vault.json` present
|
||||
// but zero bytes, and the old load path refused to start, so the container
|
||||
// restart-looped with no way back in.
|
||||
|
||||
#[test]
|
||||
fn a_zero_length_vault_recovers_from_the_backup() {
|
||||
let dir = tempdir().unwrap();
|
||||
let id = {
|
||||
let v = vault(dir.path());
|
||||
let id = v.add(&sample(), None, None, true).unwrap().id;
|
||||
// A second change is what creates the .bak of the first.
|
||||
v.add(&sample(), Some("Second"), None, true).unwrap();
|
||||
id
|
||||
};
|
||||
// Simulate the interrupted write: an empty file where the vault was.
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
|
||||
let recovered = vault(dir.path());
|
||||
assert_eq!(recovered.len(), 2, "the backup must be used, not ignored");
|
||||
assert!(
|
||||
recovered.totp_for(&id).is_ok(),
|
||||
"recovered accounts must still decrypt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_vault_keeps_the_damaged_file_for_inspection() {
|
||||
let dir = tempdir().unwrap();
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap();
|
||||
v.add(&sample(), Some("Second"), None, true).unwrap();
|
||||
}
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
let _ = vault(dir.path());
|
||||
|
||||
let quarantined: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with("vault.json.corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
quarantined.len(),
|
||||
1,
|
||||
"the damaged file must be moved aside, not deleted: {quarantined:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovery_is_written_back_so_the_next_boot_is_clean() {
|
||||
let dir = tempdir().unwrap();
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap();
|
||||
v.add(&sample(), Some("Second"), None, true).unwrap();
|
||||
}
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
|
||||
// First boot recovers; a later edit rewrites vault.json from memory.
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
assert_eq!(v.len(), 2);
|
||||
v.add(&sample(), Some("Third"), None, true).unwrap();
|
||||
}
|
||||
let reloaded = vault(dir.path());
|
||||
assert_eq!(reloaded.len(), 3);
|
||||
assert_eq!(reloaded.push_targets().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovery_puts_the_vault_back_on_disk_immediately() {
|
||||
let dir = tempdir().unwrap();
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap();
|
||||
v.add(&sample(), Some("Second"), None, true).unwrap();
|
||||
}
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
|
||||
// Booting must leave a real vault behind, not just the .bak. If it
|
||||
// did not, the recovered accounts would live only in memory and a
|
||||
// second unclean stop would have nothing left to recover from.
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
assert_eq!(v.len(), 2);
|
||||
}
|
||||
let on_disk = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
|
||||
assert!(
|
||||
on_disk.contains("Cloudflare"),
|
||||
"the vault must be rebuilt on load: {on_disk}"
|
||||
);
|
||||
// And a second boot is then entirely ordinary.
|
||||
assert_eq!(vault(dir.path()).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backup_that_is_also_unreadable_still_refuses_to_start() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"not json").unwrap();
|
||||
std::fs::write(dir.path().join("vault.json.bak"), b"{ not json either").unwrap();
|
||||
|
||||
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||
assert!(
|
||||
r.is_err(),
|
||||
"a backup that cannot be parsed is no recovery at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal has to say which of the two situations the operator is in:
|
||||
/// both copies gone, or a backup that was never created. The old wording
|
||||
/// said "the backup could not be used either" for both, which sent someone
|
||||
/// hunting for a file that did not exist.
|
||||
#[test]
|
||||
fn the_refusal_distinguishes_a_missing_backup_from_a_broken_one() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
|
||||
let missing = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
// No .bak at all: this is the state a container is in when it has not
|
||||
// completed a single write since backups were introduced.
|
||||
assert!(
|
||||
missing.contains("no backup at") && missing.contains("has not been written since"),
|
||||
"a missing backup must be named as missing: {missing}"
|
||||
);
|
||||
|
||||
std::fs::write(dir.path().join("vault.json.bak"), b"garbage").unwrap();
|
||||
let broken = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
broken.contains("exists but could not be parsed"),
|
||||
"a backup that is present but unreadable must say so: {broken}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_backup_is_reported_as_empty_not_as_broken() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
std::fs::write(dir.path().join("vault.json.bak"), b"").unwrap();
|
||||
|
||||
let msg = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("is empty as well"),
|
||||
"zero bytes is not the same as unparseable: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_refusal_points_at_anything_kept_by_an_earlier_recovery() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("vault.json.corrupt-1700000000"),
|
||||
b"an earlier damaged copy",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let msg = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("vault.json.corrupt-1700000000"),
|
||||
"a copy from an earlier recovery is the only other thing worth \
|
||||
looking at, so it should be named: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_is_only_requested_by_a_deliberate_value() {
|
||||
assert!(looks_like_reset("1"));
|
||||
assert!(looks_like_reset("yes"));
|
||||
assert!(looks_like_reset(" true "));
|
||||
// Explicitly off, and the ways an unset variable shows up.
|
||||
assert!(!looks_like_reset("0"));
|
||||
assert!(!looks_like_reset("false"));
|
||||
assert!(!looks_like_reset("FALSE"));
|
||||
assert!(!looks_like_reset(""));
|
||||
assert!(!looks_like_reset(" "));
|
||||
}
|
||||
|
||||
/// The escape hatch itself. Serialized against the greeting test because
|
||||
/// both write process environment variables, which are shared by every
|
||||
/// test in the binary.
|
||||
#[test]
|
||||
fn da_vault_reset_boots_empty_and_preserves_the_unreadable_file() {
|
||||
let _guard = env_guard();
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
std::fs::write(dir.path().join("vault.json.bak"), b"").unwrap();
|
||||
|
||||
std::env::set_var("DA_VAULT_RESET", "1");
|
||||
let loaded = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||
std::env::remove_var("DA_VAULT_RESET");
|
||||
|
||||
let v = loaded.expect("DA_VAULT_RESET must get an operator past a dead vault");
|
||||
assert!(v.is_empty());
|
||||
|
||||
// The unreadable original must not have been silently overwritten —
|
||||
// it is the only thing anyone could still try to salvage.
|
||||
let kept: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with("vault.json.corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "the original must be kept: {kept:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_reset_a_dead_vault_still_refuses() {
|
||||
let _guard = env_guard();
|
||||
std::env::remove_var("DA_VAULT_RESET");
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||
assert!(Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_install_is_not_an_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
assert!(v.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_json_with_no_accounts_still_loads() {
|
||||
// An operator who deliberately emptied their vault must not be told
|
||||
// their file is corrupt.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"{\"accounts\":[]}").unwrap();
|
||||
let v = vault(dir.path());
|
||||
assert!(v.is_empty());
|
||||
}
|
||||
|
||||
// ── Discord bookkeeping lives outside the vault ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rotating_a_code_does_not_rewrite_the_vault() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
let vault_path = dir.path().join("vault.json");
|
||||
let before = std::fs::read(&vault_path).unwrap();
|
||||
|
||||
// A code rotation is a mark_pushed per account, thousands of times a
|
||||
// day. The file holding the secrets must not be one of the things
|
||||
// being rewritten.
|
||||
for code in ["111111", "222222", "333333"] {
|
||||
v.mark_pushed(&a.id, "1409876543210987654", code);
|
||||
}
|
||||
v.flush_state_now();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&vault_path).unwrap(),
|
||||
before,
|
||||
"vault.json must be untouched by push bookkeeping"
|
||||
);
|
||||
assert!(dir.path().join("push-state.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_state_survives_a_reload() {
|
||||
let dir = tempdir().unwrap();
|
||||
let id = {
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.mark_pushed(&a.id, "1409876543210987654", "000000");
|
||||
v.flush_state_now();
|
||||
a.id
|
||||
};
|
||||
|
||||
// Without this the restart would repost every tile in the channel,
|
||||
// because the engine would no longer know the messages exist.
|
||||
let reloaded = vault(dir.path());
|
||||
let target = &reloaded.push_targets()[0];
|
||||
assert_eq!(target.id, id);
|
||||
assert_eq!(
|
||||
target.message_id.as_deref(),
|
||||
Some("1409876543210987654"),
|
||||
"the tile id must be re-attached to the account"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_push_state_file_costs_nothing_but_a_repost() {
|
||||
let dir = tempdir().unwrap();
|
||||
{
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.mark_pushed(&a.id, "555", "000000");
|
||||
v.flush_state_now();
|
||||
}
|
||||
std::fs::write(dir.path().join("push-state.json"), b"not json at all").unwrap();
|
||||
|
||||
// Unlike vault.json, this must never stop the service starting.
|
||||
let reloaded = vault(dir.path());
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
assert!(reloaded.push_targets()[0].message_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_state_is_not_written_on_every_push() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.flush_state_now();
|
||||
let state_path = dir.path().join("push-state.json");
|
||||
|
||||
// A handful of rotations in quick succession must coalesce into one
|
||||
// write rather than one write each: that is the entire reason the
|
||||
// bookkeeping is batched. `flush_state` is what the push loop calls
|
||||
// once per tick, and it declines while the last write is still fresh.
|
||||
for code in ["111111", "222222", "333333"] {
|
||||
v.mark_pushed(&a.id, "999", code);
|
||||
v.flush_state();
|
||||
}
|
||||
let after_ticks = std::fs::read(&state_path).unwrap();
|
||||
assert_eq!(
|
||||
after_ticks,
|
||||
b"{\"accounts\":{}}",
|
||||
"nothing may reach disk yet, but got: {}",
|
||||
String::from_utf8_lossy(&after_ticks)
|
||||
);
|
||||
|
||||
// An explicit flush is the shutdown path, and must not defer.
|
||||
v.flush_state_now();
|
||||
let flushed = std::fs::read(&state_path).unwrap();
|
||||
assert!(
|
||||
flushed.windows(3).any(|w| w == b"999"),
|
||||
"the tile id must reach disk on an explicit flush: {}",
|
||||
String::from_utf8_lossy(&flushed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_state_for_removed_accounts_is_dropped() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
v.mark_pushed(&a.id, "555", "000000");
|
||||
v.flush_state_now();
|
||||
v.remove(&a.id).unwrap();
|
||||
v.flush_state_now();
|
||||
|
||||
let raw = std::fs::read_to_string(dir.path().join("push-state.json")).unwrap();
|
||||
assert!(
|
||||
!raw.contains(&a.id),
|
||||
"a state entry for a deleted account is dead weight: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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