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]]
|
[[package]]
|
||||||
name = "da-core"
|
name = "da-core"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base32",
|
"base32",
|
||||||
@@ -461,7 +461,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-discord"
|
name = "da-discord"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"da-core",
|
"da-core",
|
||||||
@@ -478,7 +478,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-http-api"
|
name = "da-http-api"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -500,7 +500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-render"
|
name = "da-render"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"resvg",
|
"resvg",
|
||||||
@@ -512,7 +512,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-webui"
|
name = "da-webui"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
@@ -542,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "discord-authenticator"
|
name = "discord-authenticator"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pull), no database, no external services beyond Discord itself.
|
|||||||
- [Using it](#using-it)
|
- [Using it](#using-it)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,6 +114,12 @@ What the app does hold up its end on:
|
|||||||
|
|
||||||
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
|
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
|
||||||
contains ciphertext only, and the metadata beside it is deliberately readable.
|
contains ciphertext only, and the metadata beside it is deliberately readable.
|
||||||
|
- `vault.json` is written atomically *and* durably — new bytes are fsynced
|
||||||
|
before the rename that publishes them, and the directory is fsynced after —
|
||||||
|
so an unclean shutdown can't leave a truncated file behind. The previous
|
||||||
|
contents are kept in `vault.json.bak`, and startup falls back to it if
|
||||||
|
`vault.json` is ever unreadable, moving the damaged file aside as
|
||||||
|
`vault.json.corrupt-<timestamp>` rather than refusing to boot.
|
||||||
- The admin password is hashed with **Argon2id**.
|
- The admin password is hashed with **Argon2id**.
|
||||||
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
|
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
|
||||||
keep it off the data volume entirely — then a leaked copy of that volume is
|
keep it off the data volume entirely — then a leaked copy of that volume is
|
||||||
@@ -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
|
port. Map `/var/lib/discord-authenticator` to somewhere on `appdata` and **back
|
||||||
that path up**: losing it loses every stored secret.
|
that path up**: losing it loses every stored secret.
|
||||||
|
|
||||||
|
### What lives in the data directory
|
||||||
|
|
||||||
|
| File | Holds | If it's lost |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vault.json` | Every account, with its secret sealed | Everything — this is the file to back up |
|
||||||
|
| `vault.json.bak` | The same accounts, as of the last change | Nothing, unless `vault.json` is damaged |
|
||||||
|
| `vault.key` | The vault key (unless `DA_MASTER_KEY` is set) | Every secret becomes unreadable |
|
||||||
|
| `push-state.json` | Discord message ids and last-known push errors | Nothing — tiles are reposted on the next tick |
|
||||||
|
| `auth.json`, `settings.json` | Admin login and webhook config | You redo first-run setup |
|
||||||
|
|
||||||
|
`push-state.json` is deliberately separate from the vault. Codes roll every 30
|
||||||
|
seconds, so the bookkeeping changes constantly while the accounts themselves
|
||||||
|
change only when you edit them; keeping them together meant every rotation
|
||||||
|
rewrote all the secrets.
|
||||||
|
|
||||||
### Building and publishing the image
|
### Building and publishing the image
|
||||||
|
|
||||||
|
Pushing a version tag builds, smoke-tests, pushes and releases — see
|
||||||
|
[Automation](#automation) below. To do it by hand instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
|
||||||
|
Two workflows under `.gitea/workflows/`, both needing a repository secret named
|
||||||
|
`REGISTRY_TOKEN` with the `write:package` scope (Settings → Actions → Secrets).
|
||||||
|
Gitea reserves the `GITEA_` prefix for secret names and rejects it, which is why
|
||||||
|
the secret is not called `GITEA_TOKEN`.
|
||||||
|
|
||||||
|
**`release.yml`** runs when a `vX.Y.Z` tag is pushed. It builds the image,
|
||||||
|
starts it on a fresh root-owned volume, waits for `/healthz`, checks the binary
|
||||||
|
reports the tagged version, pushes to the registry, and only then creates the
|
||||||
|
release. The order matters: a release announcing an image that isn't in the
|
||||||
|
registry yet is worse than no release, and a broken build must not overwrite a
|
||||||
|
tag that already works. The workflow also asserts the tag and `Cargo.toml`
|
||||||
|
agree, which is what catches a tag pushed without a version bump.
|
||||||
|
|
||||||
|
Release notes live in `docs/releases/<version>.md` and are carried into the
|
||||||
|
release through the tag annotation, which is the one thing the runner provably
|
||||||
|
receives — Gitea's shallow checkout has intermittently omitted files from the
|
||||||
|
worktree. So tag with `--cleanup=verbatim`, which stops git treating every `#`
|
||||||
|
line in the notes as a comment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# cut a release
|
||||||
|
sed -i 's/^version = "0.1.3"/version = "0.1.4"/' Cargo.toml # and Cargo.lock
|
||||||
|
git commit -am "v0.1.4: ..."
|
||||||
|
NOTES=docs/releases/v0.1.4.md
|
||||||
|
{ echo "v0.1.4: one-line summary"; echo; cat "$NOTES"; } > /tmp/tagmsg
|
||||||
|
git tag -a v0.1.4 -F /tmp/tagmsg --cleanup=verbatim
|
||||||
|
git push origin main v0.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
**`dependency-check.yml`** runs every six hours and reports drifted crates, the
|
||||||
|
Rust pin and the Docker base images in a single issue titled "Dependency
|
||||||
|
updates" — rewritten in place rather than commented on, and closed
|
||||||
|
automatically when nothing is out of date. `.gitea/scripts/dependency-report.mjs`
|
||||||
|
does the work and is shared verbatim with the sibling Rust repos.
|
||||||
|
|
||||||
|
Nothing is upgraded automatically: the report separates what `cargo update`
|
||||||
|
covers from what crosses a breaking boundary, and leaves the decision to a
|
||||||
|
human. A service holding someone's second factor is the wrong place for
|
||||||
|
unreviewed dependency bumps.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
@@ -285,7 +352,7 @@ JetBrains Mono. Light and dark both ship.
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
126 tests, including:
|
143 tests, including:
|
||||||
|
|
||||||
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
||||||
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
|
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
|
||||||
@@ -300,6 +367,11 @@ JetBrains Mono. Light and dark both ship.
|
|||||||
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
||||||
instead of hammered, that concurrent rollovers are paced apart, and that a
|
instead of hammered, that concurrent rollovers are paced apart, and that a
|
||||||
rename refreshes the tile immediately without re-posting it or looping.
|
rename refreshes the tile immediately without re-posting it or looping.
|
||||||
|
- **Crash recovery**: that a zero-length `vault.json` is recovered from the
|
||||||
|
backup, that the damaged file is kept rather than deleted, that the recovered
|
||||||
|
accounts are written back so the next boot is ordinary, that an unusable
|
||||||
|
backup still refuses to boot rather than starting empty, and that a code
|
||||||
|
rotation does not rewrite the file holding the secrets.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
@@ -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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod crypto;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod store_io;
|
||||||
pub mod totp;
|
pub mod totp;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
//! 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
|
//! would silently present an empty vault and invite the operator to re-add
|
||||||
//! accounts they already have, so we refuse to start instead.
|
//! 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::crypto::{Sealed, VaultKey};
|
||||||
|
use crate::store_io::write_atomic;
|
||||||
use crate::totp::{Algorithm, OtpAuth, Totp};
|
use crate::totp::{Algorithm, OtpAuth, Totp};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
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.
|
/// Live-message bookkeeping for one account's Discord tile.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -149,11 +183,51 @@ struct VaultFile {
|
|||||||
accounts: Vec<Account>,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
path: Arc<PathBuf>,
|
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>,
|
key: Arc<VaultKey>,
|
||||||
inner: Arc<RwLock<Vec<Account>>>,
|
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
|
#[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.
|
/// 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
|
/// 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> {
|
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
|
||||||
let path = work_dir.join("vault.json");
|
let path = work_dir.join("vault.json");
|
||||||
let accounts = match std::fs::read_to_string(&path) {
|
let backup_path = backup_of(&path);
|
||||||
Ok(text) => {
|
// `load_accounts` decides on DA_VAULT_RESET itself, because that is
|
||||||
serde_json::from_str::<VaultFile>(&text)
|
// where the description of what went wrong is built.
|
||||||
.map_err(|e| {
|
let (accounts, recovered) = load_accounts(&path, &backup_path)?;
|
||||||
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()),
|
|
||||||
};
|
|
||||||
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
|
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),
|
path: Arc::new(path),
|
||||||
|
backup_path: Arc::new(backup_path),
|
||||||
|
state_path: Arc::new(state_path.clone()),
|
||||||
key: Arc::new(key),
|
key: Arc::new(key),
|
||||||
inner: Arc::new(RwLock::new(accounts)),
|
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 {
|
pub fn len(&self) -> usize {
|
||||||
@@ -427,7 +560,7 @@ impl Vault {
|
|||||||
a.discord.last_error = None;
|
a.discord.last_error = None;
|
||||||
a.discord.last_push_at = Some(OffsetDateTime::now_utc());
|
a.discord.last_push_at = Some(OffsetDateTime::now_utc());
|
||||||
}
|
}
|
||||||
self.persist();
|
self.touch_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a delivery failure against one account.
|
/// Record a delivery failure against one account.
|
||||||
@@ -444,7 +577,7 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
a.discord.last_error = Some(error.to_string());
|
a.discord.last_error = Some(error.to_string());
|
||||||
}
|
}
|
||||||
self.persist();
|
self.touch_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget an account's message id — used when Discord reports the
|
/// Forget an account's message id — used when Discord reports the
|
||||||
@@ -458,7 +591,7 @@ impl Vault {
|
|||||||
a.discord.message_id = None;
|
a.discord.message_id = None;
|
||||||
a.discord.last_code = None;
|
a.discord.last_code = None;
|
||||||
}
|
}
|
||||||
self.persist();
|
self.touch_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop every stored message id. Called when the webhook URL changes:
|
/// Drop every stored message id. Called when the webhook URL changes:
|
||||||
@@ -470,7 +603,11 @@ impl Vault {
|
|||||||
a.discord = DiscordState::default();
|
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?
|
/// Is an identical account already stored?
|
||||||
@@ -558,7 +695,14 @@ impl Vault {
|
|||||||
Ok(outcome)
|
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) {
|
fn persist(&self) {
|
||||||
|
let _guard = self.write_lock.lock();
|
||||||
let snapshot = VaultFile {
|
let snapshot = VaultFile {
|
||||||
accounts: self.inner.read().clone(),
|
accounts: self.inner.read().clone(),
|
||||||
};
|
};
|
||||||
@@ -572,15 +716,269 @@ impl Vault {
|
|||||||
if let Some(parent) = self.path.parent() {
|
if let Some(parent) = self.path.parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
if let Err(e) = write_atomic(&self.path, &body) {
|
||||||
if let Err(e) = std::fs::write(&tmp, body) {
|
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
|
||||||
tracing::error!(target: "da::vault", "could not write vault temp file: {e}");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
// Refresh the backup *after* the new vault is safely in place, so the
|
||||||
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
|
// 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)]
|
#[cfg(test)]
|
||||||
@@ -678,6 +1076,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
|
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();
|
let dir = tempdir().unwrap();
|
||||||
std::fs::write(dir.path().join("vault.json"), b"{ not json").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());
|
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||||
@@ -857,12 +1257,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn message_ids_are_persisted_as_strings() {
|
fn message_ids_are_persisted_as_strings() {
|
||||||
// Snowflakes exceed 2^53; a JSON number would round-trip lossily.
|
// 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 dir = tempdir().unwrap();
|
||||||
let v = vault(dir.path());
|
let v = vault(dir.path());
|
||||||
let a = v.add(&sample(), None, None, true).unwrap();
|
let a = v.add(&sample(), None, None, true).unwrap();
|
||||||
v.mark_pushed(&a.id, "1409876543210987654", "000000");
|
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!(
|
assert!(
|
||||||
raw.contains("\"1409876543210987654\""),
|
raw.contains("\"1409876543210987654\""),
|
||||||
"must be quoted: {raw}"
|
"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]
|
#[test]
|
||||||
fn push_targets_exclude_paused_accounts_and_keep_order() {
|
fn push_targets_exclude_paused_accounts_and_keep_order() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -907,4 +1327,363 @@ mod tests {
|
|||||||
assert!(v.push_targets()[0].message_id.is_none());
|
assert!(v.push_targets()[0].message_id.is_none());
|
||||||
assert!(!v.get(&a.id).unwrap().push_live);
|
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 admin = AdminStore::load_or_default(&args.work_dir);
|
||||||
let settings = SettingsStore::load_or_default(&args.work_dir);
|
let settings = SettingsStore::load_or_default(&args.work_dir);
|
||||||
|
|
||||||
|
// Held separately from `state` so the shutdown path below can still reach
|
||||||
|
// the vault after the router has taken ownership.
|
||||||
|
let vault_on_shutdown = vault.clone();
|
||||||
|
|
||||||
let engine = PushEngine::new(vault.clone(), settings.clone());
|
let engine = PushEngine::new(vault.clone(), settings.clone());
|
||||||
tokio::spawn(engine.clone().run());
|
tokio::spawn(engine.clone().run());
|
||||||
|
|
||||||
@@ -97,6 +101,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("the HTTP server stopped unexpectedly")?;
|
.context("the HTTP server stopped unexpectedly")?;
|
||||||
|
|
||||||
|
// The push loop only flushes bookkeeping when a few seconds have piled up.
|
||||||
|
// On a clean stop there is no next tick, so write whatever is still
|
||||||
|
// pending rather than leaving the last rotation to be rediscovered.
|
||||||
|
vault_on_shutdown.flush_state_now();
|
||||||
|
|
||||||
tracing::info!(target: "da", "shutting down");
|
tracing::info!(target: "da", "shutting down");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ impl std::fmt::Debug for PushEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PushEngine {
|
impl PushEngine {
|
||||||
|
/// The vault this engine is driving, so a caller shutting down can flush
|
||||||
|
/// the bookkeeping it has been batching.
|
||||||
|
pub fn vault(&self) -> &Vault {
|
||||||
|
&self.vault
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
|
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
vault,
|
vault,
|
||||||
@@ -81,6 +87,14 @@ impl PushEngine {
|
|||||||
/// One pass: bring every tile up to date and tidy away any that shouldn't
|
/// One pass: bring every tile up to date and tidy away any that shouldn't
|
||||||
/// exist. Returns how many messages were written.
|
/// exist. Returns how many messages were written.
|
||||||
pub async fn sync_once(&self) -> usize {
|
pub async fn sync_once(&self) -> usize {
|
||||||
|
let written = self.sync_tiles().await;
|
||||||
|
// Bookkeeping changes are batched in memory; this is the tick that
|
||||||
|
// decides whether enough of them have piled up to be worth a write.
|
||||||
|
self.vault.flush_state();
|
||||||
|
written
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_tiles(&self) -> usize {
|
||||||
// Held for the whole pass: see `sync_lock`. A pass can take several
|
// Held for the whole pass: see `sync_lock`. A pass can take several
|
||||||
// seconds once the rate limiter spaces things out, which is easily
|
// seconds once the rate limiter spaces things out, which is easily
|
||||||
// long enough for the next tick to arrive.
|
// long enough for the next tick to arrive.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
This release fixes the failure that could leave the container in a restart loop
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: could not load the vault
|
||||||
|
|
||||||
|
Caused by:
|
||||||
|
/var/lib/discord-authenticator/vault.json exists but could not be parsed
|
||||||
|
(EOF while parsing a value at line 1 column 0). Refusing to start with an
|
||||||
|
empty vault — move the file aside to start fresh.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What was wrong
|
||||||
|
|
||||||
|
The vault was written as `fs::write(tmp)` → `rename(tmp, path)`. The rename is
|
||||||
|
atomic, so a reader never saw a half-written file — but **atomicity is not
|
||||||
|
durability**. Nothing fsynced the new bytes before the rename, so on ext4
|
||||||
|
(`data=ordered`, the default) a crash or hard power-off could commit the rename
|
||||||
|
while the data it published never landed. The result is a file that exists and
|
||||||
|
is zero bytes long, which is exactly what the message above describes.
|
||||||
|
|
||||||
|
Two things made it far more likely. The write happened on **every** push tick,
|
||||||
|
so a ten-account vault was rewriting all of its secrets thousands of times a
|
||||||
|
day, and there was no backup to fall back on — one damaged file meant a crash
|
||||||
|
loop, with the web UI that could have fixed it locked behind the service that
|
||||||
|
would not start.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
**Durable writes.** `store_io::write_atomic` fsyncs the new bytes, renames, then
|
||||||
|
fsyncs the directory. The rename now cannot reach the journal ahead of the data
|
||||||
|
it publishes.
|
||||||
|
|
||||||
|
**A backup, and recovery from it.** Every write refreshes `vault.json.bak`. The
|
||||||
|
backup is taken *after* the new file is safely in place, so the two always hold
|
||||||
|
the same accounts — backing up first would leave the backup a full change
|
||||||
|
behind and quietly lose your most recent edit. At startup, an unparseable
|
||||||
|
`vault.json` now falls back to that backup, logs one `ERROR` explaining what
|
||||||
|
happened, moves the damaged file to `vault.json.corrupt-<timestamp>` instead of
|
||||||
|
deleting it, and writes the recovered accounts back so the next boot is
|
||||||
|
ordinary. With no usable backup it still refuses to start, exactly as before.
|
||||||
|
|
||||||
|
**The secrets are no longer rewritten on every code rotation.** Discord
|
||||||
|
bookkeeping — message ids, last-sent codes, last errors — moved to its own
|
||||||
|
`push-state.json`, batched and flushed at most every five seconds, with a final
|
||||||
|
flush on clean shutdown. It is a cache by construction: losing or corrupting it
|
||||||
|
costs one reposted Discord tile, never a TOTP secret, so it is never a reason to
|
||||||
|
refuse to boot.
|
||||||
|
|
||||||
|
### Upgrading from v0.1.2
|
||||||
|
|
||||||
|
Nothing to do. The vault format is unchanged and no accounts need re-importing.
|
||||||
|
|
||||||
|
On Unraid, either wait for the Docker page to notice `:latest`, or force it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull gitea.milesward.dev/mward4/discord-authenticator:0.1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
then **Force Update** on the container. Pin the image to `0.1.3` rather than
|
||||||
|
`:latest` in the Unraid template if you would rather updates be deliberate.
|
||||||
|
|
||||||
|
Your data directory gains two files: `vault.json.bak` (a copy of the vault —
|
||||||
|
include it in backups alongside `vault.json`) and `push-state.json` (safe to
|
||||||
|
delete at any time; the tiles are simply reposted).
|
||||||
|
|
||||||
|
### If you already hit this
|
||||||
|
|
||||||
|
If your `vault.json` is currently zero bytes, check for a `.bak`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata/discord-authenticator
|
||||||
|
ls -la vault* && head -c 100 vault.json.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
`v0.1.3` recovers automatically when that backup parses — just update the image
|
||||||
|
and start it. If there is no usable backup, the bytes are gone and the accounts
|
||||||
|
must be re-added from your own `otpauth://` links or a Google Authenticator
|
||||||
|
export. **Keep `vault.key`**: without it, nothing in an old vault file can be
|
||||||
|
decrypted even if the bytes turn up later.
|
||||||
|
|
||||||
|
### Verifying the fix
|
||||||
|
|
||||||
|
143 tests cover this, including recovery from a zero-length vault, retention of
|
||||||
|
the damaged file, write-back on recovery, refusal when both files are bad,
|
||||||
|
reload of tile ids across a restart, batching, and an assertion that a code
|
||||||
|
rotation leaves `vault.json` byte-identical.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## An accurate message, and a way out
|
||||||
|
|
||||||
|
No functional change to how the vault is written or recovered. This fixes what
|
||||||
|
the service tells you when recovery is not possible, and adds an escape hatch
|
||||||
|
for the case it is not.
|
||||||
|
|
||||||
|
### The message said the wrong thing
|
||||||
|
|
||||||
|
When neither `vault.json` nor `vault.json.bak` could be loaded, the error read:
|
||||||
|
|
||||||
|
> ...and the backup at /var/lib/discord-authenticator/vault.json.bak could not
|
||||||
|
> be used either.
|
||||||
|
|
||||||
|
That is true for three quite different situations, and it reads as though a
|
||||||
|
backup exists. The case that actually bites is the one where **no backup was
|
||||||
|
ever created**: a vault that has not completed a single write since v0.1.3
|
||||||
|
introduced backups has no `.bak` at all, so there is nothing on disk to recover
|
||||||
|
and no file to go looking for. The old wording sent you looking anyway.
|
||||||
|
|
||||||
|
It now says which situation you are in:
|
||||||
|
|
||||||
|
- `there is no backup at … — this vault has not been written since backups were
|
||||||
|
introduced, so none was ever created`
|
||||||
|
- `the backup at … exists but could not be parsed either`
|
||||||
|
- `the backup at … is empty as well, so there is nothing to recover`
|
||||||
|
|
||||||
|
It also lists any `vault.json.corrupt-<timestamp>` files left by earlier
|
||||||
|
recoveries, since those are the only other thing worth inspecting.
|
||||||
|
|
||||||
|
### DA_VAULT_RESET
|
||||||
|
|
||||||
|
Refusing to start on an unreadable vault is still the default — booting empty
|
||||||
|
would look identical to a vault you had emptied on purpose, and the whole point
|
||||||
|
is that the service cannot tell the difference. But refusing must not be a dead
|
||||||
|
end, and having to edit files by hand inside a container that will not start is
|
||||||
|
a poor answer at 2am.
|
||||||
|
|
||||||
|
Set `DA_VAULT_RESET=1` and the service boots with an empty vault instead. It
|
||||||
|
moves the unreadable file to `vault.json.corrupt-<timestamp>` *before* writing
|
||||||
|
the fresh one, so nothing is overwritten, and the log line names where it went.
|
||||||
|
Remove the variable afterwards: it has no effect on a vault that loads, but
|
||||||
|
leaving it set means the next genuinely-broken vault boots empty rather than
|
||||||
|
telling you about it.
|
||||||
|
|
||||||
|
In the Unraid template: Docker → the container → **Add another Path, Variable,
|
||||||
|
Port or Label** → Variable, Key `DA_VAULT_RESET`, Value `1`.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Nothing to do. Vault format unchanged, no accounts to re-import. If you are
|
||||||
|
currently stuck on the old message, this release does not recover the accounts
|
||||||
|
either — it just tells you why, and lets you start clean without leaving the UI.
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
# GITEA_OWNER default: mward4
|
# GITEA_OWNER default: mward4
|
||||||
# GITEA_REPO default: Discord-Authenticator
|
# GITEA_REPO default: Discord-Authenticator
|
||||||
# IMAGE_NAME default: discord-authenticator
|
# IMAGE_NAME default: discord-authenticator
|
||||||
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
|
# IMAGE_TAG default: 0.1.3 (also pushed as :latest)
|
||||||
# PLATFORM default: linux/amd64 (Unraid is x86_64)
|
# PLATFORM default: linux/amd64 (Unraid is x86_64)
|
||||||
# GIT_REF default: v0.1 (tag/branch to build)
|
# GIT_REF default: v0.1.3 (tag/branch to build)
|
||||||
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
|
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
|
||||||
#
|
#
|
||||||
# After this, set the Unraid template's Repository to:
|
# After this, set the Unraid template's Repository to:
|
||||||
@@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
|
|||||||
GITEA_OWNER=${GITEA_OWNER:-mward4}
|
GITEA_OWNER=${GITEA_OWNER:-mward4}
|
||||||
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
|
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
|
||||||
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
|
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
|
||||||
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
|
IMAGE_TAG=${IMAGE_TAG:-0.1.3}
|
||||||
PLATFORM=${PLATFORM:-linux/amd64}
|
PLATFORM=${PLATFORM:-linux/amd64}
|
||||||
GIT_REF=${GIT_REF:-v0.1}
|
GIT_REF=${GIT_REF:-v0.1.3}
|
||||||
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
|
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
|
||||||
|
|
||||||
# OCI distribution rejects uppercase repository paths, so the image name is
|
# OCI distribution rejects uppercase repository paths, so the image name is
|
||||||
|
|||||||
Reference in new issue
Block a user