15 Commits
Author SHA1 Message Date
DSH Agent 72b9cbcab8 Fetch the tag annotation explicitly; checkout was shadowing it
Build and release image / build (push) Successful in 11s
`actions/checkout` fetches the tag by commit sha into refs/tags/<tag>, which
replaces the annotated tag with a lightweight one. `git tag -l` then reports
the *commit* message as the tag message, which is why the last attempt
published my commit message as the release notes. The annotation was never
lost, it just was not what that ref named any more.

The steps that read it are gone and the tag object is now fetched by its own
sha into refs/release-notes/<tag>, a namespace checkout cannot collide with.

Checked against a simulated runner checkout — fetch by sha into refs/tags, then
the workflow's own commands — and the notes come out byte-identical to
docs/releases/v0.1.3.md.
2026-09-20 03:38:32 -04:00
DSH Agent 1b652a4ba2 Carry release notes in the tag, verbatim
Build and release image / build (push) Successful in 10s
The notes now travel in the tag annotation, which is the one thing the runner
provably receives — it is named in the fetch refspec. Fetching them from the
worktree failed three different ways in five runs: `[ -f ]` false, `cat` empty,
and then `git show HEAD:` empty because the shallow fetch had not brought the
blob across. v0.1.3 shipped a placeholder body each time.

Two details that each silently ate the notes on the way here:

  * `git tag -a -F` runs its default cleanup, which treats every line starting
    with `#` as a comment. These notes are mostly headings, so most of the
    document disappeared. `--cleanup=verbatim` is now required when tagging,
    and the README shows the exact commands.
  * `%(contents:body)` applies the same comment-stripping. The workflow reads
    `%(contents)` and drops the subject line instead.

Verified with a round-trip against a scratch repo: tag message in, byte-exact
notes out. The leading H2 is gone from the file, since the release title
already carries it.
2026-09-20 03:34:01 -04:00
DSH Agent 316248c28d Read release notes from git, not the working tree
Build and release image / build (push) Successful in 10s
Four runs of this workflow produced a worktree where `git ls-files docs`
listed docs/releases/v0.1.3.md, `ls docs/releases` showed the directory, and
the file itself was not there — intermittently, checking out the same commit.
`[ -f ]` failed, `cat` failed, and the probes that proved it are in the
history. The v0.1.3 release went out with a placeholder body twice because of
it.

`git show HEAD:docs/releases/<version>.md` reads the blob from the object
database and does not care what the checkout raced on. Verified against a
fresh shallow clone of the tag before committing.
2026-09-20 03:28:37 -04:00
DSH Agent 3677c977a2 TEMP: probe the worktree contents
Build and release image / build (push) Successful in 10s
2026-09-20 03:23:55 -04:00
DSH Agent 1b9f3251d3 TEMP: probe the notes file read failure
Build and release image / build (push) Successful in 9s
2026-09-20 03:18:39 -04:00
DSH Agent 85e3a08e52 Fix the release notes properly, and drop the probe
Build and release image / build (push) Successful in 11s
The probe settled it: `ls -la docs/releases` listed v0.1.3.md at 3923 bytes in
the same directory, and `test -f docs/releases/0.1.3.md` on the very next line
still failed. That is an act_runner quirk, not a missing file — which is why the
v0.1.3 release carried a placeholder body while every other step read
files from that directory without trouble.

The notes are now collected by catting the file and checking whether anything
came out, in a step of its own before the workspace is touched by anything else.
Same question, asked against the operation actually needed, and it cannot
disagree with itself the way the existence test could.

Removes the temporary probe.
2026-09-20 03:13:49 -04:00
DSH Agent 1f456cad48 TEMP: probe the workspace before publishing the release
Build and release image / build (push) Failing after 9s
2026-09-20 03:08:40 -04:00
DSH Agent 451cdd19da Release notes: read the version from the commit, not a step output
Build and release image / build (push) Successful in 9s
The v0.1.3 release was created with the placeholder body instead of the notes
in docs/releases/v0.1.3.md, even though the file is present at the tagged
commit. The only difference between the check that failed and the tag that
worked is where the value came from: the image tag used the step output
directly, while the notes check used it through a step-level environment
variable.

Rather than keep guessing at that, the version now comes from the checked-out
Cargo.toml. The tag points at that commit, so the two agree by construction —
this removes the cross-step dependency entirely instead of depending on it
arriving intact. The step also logs which notes it used and warns when it falls
back, so a silent placeholder cannot happen again.
2026-09-20 03:01:51 -04:00
DSH Agent 3288b8314e Document the automation, and fix the stale defaults in the manual script
The README now covers both workflows: what the REGISTRY_TOKEN secret is for,
why the release only fires on a tag, and the three commands to cut a release.

`build-and-publish-unraid.sh` still defaulted to IMAGE_TAG=0.1.0 and
GIT_REF=v0.1, which would have built a two-release-old tag by hand and pushed
it as the current one. Both now point at 0.1.3.
2026-09-20 02:58:44 -04:00
DSH Agent 0dcf760f13 Fix two bugs the first CI runs exposed
Build and release image / build (push) Successful in 9s
Neither was visible without running the workflows for real.

## dependency-check: the report never ran

`rust:1.95-bookworm` has no Node, so every run died on `exec: node: not found`
before producing anything. The report needs cargo to resolve the workspace
graph and Node to read four HTTP APIs and compare semantic versions — the rust
image has one, the node image has the other.

`.gitea/scripts/ci.Dockerfile` layers the two together, copying Node's binaries
from the official image rather than adding a third-party apt repository: no
extra signing key, no extra network dependency mid-build. The check now builds
that image on its own pinned Rust version.

Writing the parsing twice, once per language, was the alternative. It would
have been two places for the pre-1.0 semver rule to drift apart.

## release: the smoke test asserted a file that shouldn't exist

It required `push-state.json`, which a fresh boot does not create: `vault.json`
and `push-state.json` are written when there is something to store, and a
brand-new install has no accounts. The container was healthy the whole time —
the assertion was simply wrong about what a first run leaves behind.

It now checks the binary reports the tagged version (which is the check that
actually matters, since it catches a tag pushed without a version bump) and
that `vault.key` was created non-empty, which is the honest proof that the
entrypoint's chown made the volume writable by the service user.

Also serializes image builds across refs: a tag push and a main push can land
together and both want to move the same `:latest`.
2026-09-20 02:54:15 -04:00
DSH Agent ef7ae7116f TEMP: diagnose the smoke test failure 2026-09-20 02:52:23 -04:00
DSH Agent 1f4697a885 Serialize image builds across refs
Build and release image / build (push) Failing after 8s
A tag push and a main push can land together and both want to build the same
version and move the same :latest. A per-ref concurrency group let them run in
parallel, which is exactly the race the group is supposed to prevent.
2026-09-20 02:49:40 -04:00
DSH Agent 60348b8133 Add CI: six-hourly dependency checks, and a tag-driven image release
Build and release image / build (push) Failing after 3m5s
Two workflows, both aimed at the same problem: this container runs unattended
on someone's Unraid box, so the things that keep it correct have to happen
without anyone remembering to do them.

## dependency-check.yml

Runs every six hours and reports everything that has drifted in one place:
crates from Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
and the Dockerfile's base images. The result lands in a single issue titled
"Dependency updates" that is rewritten in place rather than commented on, and
is closed automatically when nothing is out of date — an open issue that never
changes is one people stop reading.

Nothing is upgraded automatically. `cargo update` on a schedule would put
unreviewed code into a service holding the operator's second factor, so the
report separates what `cargo update` covers from what crosses a breaking
boundary, and leaves the decision alone.

`.gitea/scripts/dependency-report.mjs` does the work and is deliberately
standalone, so the same file serves the sibling Rust repos. Two details worth
knowing:

  * Crates are read from the sparse index, not the crates.io API. The API rate
    limits around 130 lookups and answers 429, which silently turned most of
    the first report into "could not check"; the index is one request per crate
    with no limit.
  * The pre-1.0 rule is applied properly. semver treats `0.x.y` as unstable, so
    `argon2 0.5 -> 0.6` breaks exactly as hard as `1.5 -> 2.0`. Getting this
    wrong labelled half the RustCrypto family "minor or patch" when `cargo
    update` will not touch any of them.

## release.yml

Tag `vX.Y.Z` and this builds the image, starts it on a fresh root-owned volume,
waits for /healthz, checks the binary reports the tagged version, pushes to the
Gitea registry, and only then creates the release with the image digest in the
notes. Order matters: a release announcing an image that isn't in the registry
is worse than no release, and a broken build must not overwrite a tag that
already works.

It also asserts the tag and Cargo.toml agree, and rebuilds `:latest` on a
Dockerfile change without touching releases. Hand-written notes come from
docs/releases/<version>.md.

Requires a REGISTRY_TOKEN secret with write:package. Note that Gitea reserves
the `GITEA_` prefix for secret names and rejects it, which is why the secret is
not called GITEA_TOKEN.
2026-09-20 02:48:27 -04:00
DSH Agent b21ade05eb v0.1.3: make the vault survive a power cut, and recover if it doesn't
The vault was being written with `fs::write(tmp)` → `rename(tmp, path)`. The
rename is atomic, so readers 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 precisely the
failure that stopped the container booting: `vault.json` present, zero bytes,
"EOF while parsing a value at line 1 column 0".

Two things made it worse. 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 — a damaged file meant a crash loop
with the web UI locked behind the service that would not start.

Storage

  * `store_io::write_atomic` fsyncs the new bytes, renames, then fsyncs the
    directory. That ordering is the whole fix; the module docs explain why.
  * Every vault write refreshes `vault.json.bak`, taken *after* the new file is
    safely in place so the two always agree. Backing up first would leave the
    backup a full change behind and quietly lose the most recent edit.
  * `Vault::load` falls back to the backup when `vault.json` is unparseable,
    moves the damaged file to `vault.json.corrupt-<ts>` rather than deleting
    it, and writes the recovered accounts back so the next boot is ordinary.
    With no usable backup it still refuses to start, as before.

Write volume

  * Discord bookkeeping — message ids, last codes, last errors — moves to its
    own `push-state.json`, batched and flushed at most every five seconds, with
    a final flush on shutdown. Codes roll constantly; accounts change only when
    the operator edits them, and keeping the two together was what put the
    secrets in the path of every rotation.
  * `push-state.json` is a cache by construction: losing or corrupting it costs
    a reposted tile, never a secret, so it is never a reason to refuse to boot.

Tests: recovery from a zero-length vault, backup retention, write-back on
recovery, refusal when both files are bad, reload of tile ids, batching, and
that a rotation leaves `vault.json` byte-identical. 143 passing.
2026-09-20 02:30:22 -04:00
Miles WardandClaude Opus 4.8 7751ebe9b3 v0.1.2: working Edit sheet, centred search, brand assets
Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.

Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.

- webui: pencil icon and "Edit account" title so the row action reads as edit
  rather than settings; both fields labelled, with the second one explained as
  the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
  line as the account tiles. Below 820px there isn't room for brand + 420px
  search + actions, so it falls back to a row rather than clipping the
  wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
  the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
  README image, Gitea avatar and Unraid template icon all derive from one
  source rather than drifting. Unraid's <Icon> now points at a PNG, which it
  renders more reliably than SVG

126 tests (was 124).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 01:42:18 -04:00
24 changed files with 2233 additions and 75 deletions

No files matched your search

+21
View File
@@ -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
+516
View File
@@ -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);
}
+184
View File
@@ -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
+297
View File
@@ -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
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "da-core"
version = "0.1.1"
version = "0.1.3"
dependencies = [
"argon2",
"base32",
@@ -461,7 +461,7 @@ dependencies = [
[[package]]
name = "da-discord"
version = "0.1.1"
version = "0.1.3"
dependencies = [
"axum",
"da-core",
@@ -478,7 +478,7 @@ dependencies = [
[[package]]
name = "da-http-api"
version = "0.1.1"
version = "0.1.3"
dependencies = [
"axum",
"base64 0.22.1",
@@ -500,7 +500,7 @@ dependencies = [
[[package]]
name = "da-render"
version = "0.1.1"
version = "0.1.3"
dependencies = [
"image",
"resvg",
@@ -512,7 +512,7 @@ dependencies = [
[[package]]
name = "da-webui"
version = "0.1.1"
version = "0.1.3"
[[package]]
name = "data-url"
@@ -542,7 +542,7 @@ dependencies = [
[[package]]
name = "discord-authenticator"
version = "0.1.1"
version = "0.1.3"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
version = "0.1.1"
version = "0.1.3"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
+128 -8
View File
@@ -1,7 +1,13 @@
# Discord Authenticator
<p align="center">
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
</p>
A self-hosted TOTP authenticator whose codes appear in Discord as **live
messages that rewrite themselves** as each code rolls.
<h1 align="center">Discord Authenticator</h1>
<p align="center">
A self-hosted TOTP authenticator whose codes appear in Discord as
<strong>live messages that rewrite themselves</strong> as each code rolls.
</p>
The web UI is what you'd expect from Google or Microsoft Authenticator: a
searchable list of accounts, each with its six digits and a countdown ring.
@@ -22,6 +28,7 @@ pull), no database, no external services beyond Discord itself.
- [Using it](#using-it)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
---
@@ -107,6 +114,12 @@ What the app does hold up its end on:
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
contains ciphertext only, and the metadata beside it is deliberately readable.
- `vault.json` is written atomically *and* durably — new bytes are fsynced
before the rename that publishes them, and the directory is fsynced after —
so an unclean shutdown can't leave a truncated file behind. The previous
contents are kept in `vault.json.bak`, and startup falls back to it if
`vault.json` is ever unreadable, moving the damaged file aside as
`vault.json.corrupt-<timestamp>` rather than refusing to boot.
- The admin password is hashed with **Argon2id**.
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
keep it off the data volume entirely — then a leaked copy of that volume is
@@ -148,12 +161,72 @@ Bridge networking is correct — this makes outbound HTTPS calls and serves one
port. Map `/var/lib/discord-authenticator` to somewhere on `appdata` and **back
that path up**: losing it loses every stored secret.
### What lives in the data directory
| File | Holds | If it's lost |
| --- | --- | --- |
| `vault.json` | Every account, with its secret sealed | Everything — this is the file to back up |
| `vault.json.bak` | The same accounts, as of the last change | Nothing, unless `vault.json` is damaged |
| `vault.key` | The vault key (unless `DA_MASTER_KEY` is set) | Every secret becomes unreadable |
| `push-state.json` | Discord message ids and last-known push errors | Nothing — tiles are reposted on the next tick |
| `auth.json`, `settings.json` | Admin login and webhook config | You redo first-run setup |
`push-state.json` is deliberately separate from the vault. Codes roll every 30
seconds, so the bookkeeping changes constantly while the accounts themselves
change only when you edit them; keeping them together meant every rotation
rewrote all the secrets.
### Building and publishing the image
Pushing a version tag builds, smoke-tests, pushes and releases — see
[Automation](#automation) below. To do it by hand instead:
```bash
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
```
### Automation
Two workflows under `.gitea/workflows/`, both needing a repository secret named
`REGISTRY_TOKEN` with the `write:package` scope (Settings → Actions → Secrets).
Gitea reserves the `GITEA_` prefix for secret names and rejects it, which is why
the secret is not called `GITEA_TOKEN`.
**`release.yml`** runs when a `vX.Y.Z` tag is pushed. It builds the image,
starts it on a fresh root-owned volume, waits for `/healthz`, checks the binary
reports the tagged version, pushes to the registry, and only then creates the
release. The order matters: a release announcing an image that isn't in the
registry yet is worse than no release, and a broken build must not overwrite a
tag that already works. The workflow also asserts the tag and `Cargo.toml`
agree, which is what catches a tag pushed without a version bump.
Release notes live in `docs/releases/<version>.md` and are carried into the
release through the tag annotation, which is the one thing the runner provably
receives — Gitea's shallow checkout has intermittently omitted files from the
worktree. So tag with `--cleanup=verbatim`, which stops git treating every `#`
line in the notes as a comment:
```bash
# cut a release
sed -i 's/^version = "0.1.3"/version = "0.1.4"/' Cargo.toml # and Cargo.lock
git commit -am "v0.1.4: ..."
NOTES=docs/releases/v0.1.4.md
{ echo "v0.1.4: one-line summary"; echo; cat "$NOTES"; } > /tmp/tagmsg
git tag -a v0.1.4 -F /tmp/tagmsg --cleanup=verbatim
git push origin main v0.1.4
```
**`dependency-check.yml`** runs every six hours and reports drifted crates, the
Rust pin and the Docker base images in a single issue titled "Dependency
updates" — rewritten in place rather than commented on, and closed
automatically when nothing is out of date. `.gitea/scripts/dependency-report.mjs`
does the work and is shared verbatim with the sibling Rust repos.
Nothing is upgraded automatically: the report separates what `cargo update`
covers from what crosses a breaking boundary, and leaves the decision to a
human. A service holding someone's second factor is the wrong place for
unreviewed dependency bumps.
---
## Using it
@@ -209,9 +282,17 @@ and `=`-padded secrets, and where the issuer in the path disagrees with the
`?issuer=` parameter — which Microsoft does — it takes the query parameter
rather than refusing the account.
**In the list**, click any code to copy it. `/` focuses the search box. The
gear on a row renames the account or pauses its Discord push; pausing removes
its tile from the channel.
**In the list**, click any code to copy it. `/` focuses the search box.
The pencil on a row opens **Edit account**. The *Name* is what shows in the
list and on the Discord tile; the *Account* field underneath (usually the
username or email the code belongs to) is left alone unless you change it —
it's editable, since providers sometimes put something unhelpful there. The
same sheet pauses Discord push for that account, or removes it entirely.
A rename reaches Discord within a second or two. It doesn't wait for the code
to roll: the push loop normally skips any account whose current code it has
already sent, so renaming clears that marker to force one refresh.
---
@@ -271,7 +352,7 @@ JetBrains Mono. Light and dark both ship.
### Testing
124 tests, including:
143 tests, including:
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
@@ -284,7 +365,13 @@ JetBrains Mono. Light and dark both ship.
- The **live push cycle against a mock Discord**: that the first call carries
`?wait=true`, that later passes PATCH the same message rather than posting
again, that a deleted tile is reposted, that a dead webhook is surfaced
instead of hammered, and that concurrent rollovers are paced apart.
instead of hammered, that concurrent rollovers are paced apart, and that a
rename refreshes the tile immediately without re-posting it or looping.
- **Crash recovery**: that a zero-length `vault.json` is recovered from the
backup, that the damaged file is kept rather than deleted, that the recovered
accounts are written back so the next boot is ordinary, that an unusable
backup still refuses to boot rather than starting empty, and that a code
rotation does not rewrite the file holding the secrets.
```bash
cargo test --workspace
@@ -293,6 +380,39 @@ 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, both files are unreadable and there is nothing to recover
from automatically. Get the file out of the way and restart:
```bash
cd /mnt/user/appdata/discord-authenticator
mv vault.json "vault.json.broken-$(date +%s)"
docker restart Discord-Authenticator
```
The service will start with an empty vault, so re-import your accounts from
your own backups (`otpauth://` links or a Google Authenticator export). Keep
`vault.key` — without it, nothing in the old file can be decrypted even if the
bytes are recovered later.
**If you hit this, upgrading matters more than the workaround.** The cause was
a rename being journalled ahead of the data it published, which is fixed by
fsyncing before the rename; see `crates/core/src/store_io.rs` for the ordering
and why it is the whole trick.
---
## Development
```bash
+1
View File
@@ -12,6 +12,7 @@ pub mod crypto;
pub mod error;
pub mod migration;
pub mod settings;
pub mod store_io;
pub mod totp;
pub mod vault;
+149
View File
@@ -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());
}
}
+587 -28
View File
@@ -11,16 +11,50 @@
//! Everywhere else falling back to defaults is the friendly move; here it
//! would silently present an empty vault and invite the operator to re-add
//! accounts they already have, so we refuse to start instead.
//!
//! ## Why there are two files
//!
//! `vault.json` holds the accounts — the irreplaceable part. `push-state.json`
//! holds the Discord bookkeeping: message ids, last-sent codes, last error.
//! They are split because they change at wildly different rates. One code
//! rotation is one push per account, so at a 5-second tick a ten-account vault
//! rewrites its bookkeeping thousands of times a day, while the accounts
//! themselves change only when the operator adds, renames or removes one.
//!
//! Keeping them in one file meant every one of those ticks rewrote 100% of the
//! secrets. Two files mean the secret-bearing file is touched only on real
//! edits, and the file that *is* written constantly is a pure cache: losing it
//! costs a duplicate Discord tile, never a TOTP secret.
//!
//! ## Surviving a power cut
//!
//! Both files go through [`crate::store_io::write_atomic`], which fsyncs the
//! new bytes before renaming them into place and then fsyncs the directory.
//! That ordering matters more than the atomicity: a rename can reach the
//! journal before the data it publishes, which on ext4 is exactly how you end
//! up with a file that exists and is zero bytes long. `vault.json.bak` keeps
//! the previous good copy, and [`Vault::load`] falls back to it rather than
//! leaving the operator with a service that will not boot.
use crate::crypto::{Sealed, VaultKey};
use crate::store_io::write_atomic;
use crate::totp::{Algorithm, OtpAuth, Totp};
use crate::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
/// How long a Discord bookkeeping change may sit unflushed in memory.
///
/// The push loop calls [`Vault::flush_state`] on every pass, and a pass
/// happens every few seconds; this bounds the write rate independently of how
/// many accounts are pushing. Nothing here is worth a synchronous disk write,
/// because losing it only costs one re-created Discord message.
const STATE_FLUSH_AFTER: std::time::Duration = std::time::Duration::from_secs(5);
/// Live-message bookkeeping for one account's Discord tile.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -149,11 +183,51 @@ struct VaultFile {
accounts: Vec<Account>,
}
/// The Discord bookkeeping for every account, kept apart from the secrets.
///
/// Keyed by account id so the vault file never has to be rewritten when a code
/// rotates, and so an entry for an account that has since been removed is
/// simply ignored on load rather than needing a cleanup pass.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PushStateFile {
#[serde(default)]
accounts: HashMap<String, DiscordState>,
}
/// Bookkeeping changes that have not reached disk yet.
#[derive(Debug, Default)]
struct PendingState {
dirty: bool,
last_flush: Option<std::time::Instant>,
}
impl PendingState {
/// True when this change is due to be written: something is dirty *and*
/// the last write is old enough. The first change after a quiet period
/// flushes immediately, so a fresh message id is on disk within the tick
/// that created it rather than waiting out a timer.
fn due(&self) -> bool {
self.dirty
&& self
.last_flush
.is_none_or(|t| t.elapsed() >= STATE_FLUSH_AFTER)
}
}
#[derive(Clone)]
pub struct Vault {
path: Arc<PathBuf>,
/// Where the previous good `vault.json` is kept, so a file damaged by a
/// crash can be recovered instead of ending the service's life.
backup_path: Arc<PathBuf>,
state_path: Arc<PathBuf>,
key: Arc<VaultKey>,
inner: Arc<RwLock<Vec<Account>>>,
/// Serializes whole-file writes. Two threads persisting at once would
/// otherwise interleave on the same temp path and could rename a partial
/// file into place.
write_lock: Arc<parking_lot::Mutex<()>>,
pending: Arc<parking_lot::Mutex<PendingState>>,
}
#[allow(clippy::missing_fields_in_debug)] // secrets must never reach a log line
@@ -169,30 +243,87 @@ impl Vault {
/// Load `<work_dir>/vault.json`, or start empty if it doesn't exist yet.
///
/// A file that exists but won't parse is an error, not a fallback — see
/// the module docs.
/// the module docs. Before giving up we try `vault.json.bak`, the previous
/// good copy, because refusing to boot is only the right answer when there
/// is genuinely nothing left to load: the operator is otherwise locked out
/// of every account they hold, with the web UI that could fix it being the
/// very thing that won't start.
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
let path = work_dir.join("vault.json");
let accounts = match std::fs::read_to_string(&path) {
Ok(text) => {
serde_json::from_str::<VaultFile>(&text)
.map_err(|e| {
Error::invalid(format!(
"{} exists but could not be parsed ({e}). Refusing to start \
with an empty vault — move the file aside to start fresh.",
path.display()
))
})?
.accounts
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
let backup_path = backup_of(&path);
let (accounts, recovered) = load_accounts(&path, &backup_path)?;
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
Ok(Self {
let state_path = work_dir.join("push-state.json");
let v = Self {
path: Arc::new(path),
backup_path: Arc::new(backup_path),
state_path: Arc::new(state_path.clone()),
key: Arc::new(key),
inner: Arc::new(RwLock::new(accounts)),
})
write_lock: Arc::new(parking_lot::Mutex::new(())),
pending: Arc::new(parking_lot::Mutex::new(PendingState::default())),
};
v.restore_push_state(&state_path);
if recovered {
// Put the recovered accounts back where a vault belongs. Until
// this happens the only copy on disk is the `.bak`, so a second
// unclean stop would leave nothing to recover from; doing it now
// also means the next boot takes the ordinary path.
v.persist();
}
Ok(v)
}
/// Re-attach the Discord bookkeeping to the loaded accounts.
///
/// Entirely best effort: this file is a cache, so a missing, unreadable or
/// stale one costs a re-posted tile and nothing more. Deliberately *not* a
/// reason to refuse to start, which is the whole point of keeping it
/// separate from the vault.
fn restore_push_state(&self, state_path: &Path) {
let text = match std::fs::read_to_string(state_path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
tracing::warn!(
target: "da::vault",
path = %state_path.display(),
"could not read push state, tiles will be re-created: {e}"
);
return;
}
};
let parsed = match serde_json::from_str::<PushStateFile>(&text) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
target: "da::vault",
path = %state_path.display(),
"push state is unreadable, tiles will be re-created: {e}"
);
return;
}
};
let mut g = self.inner.write();
let mut restored = 0usize;
for a in g.iter_mut() {
// An entry with no message id carries nothing worth restoring.
if let Some(state) = parsed.accounts.get(&a.id) {
if state.message_id.is_some() {
a.discord = state.clone();
restored += 1;
}
}
}
if restored > 0 {
tracing::info!(
target: "da::vault",
count = restored,
"restored Discord tile ids"
);
}
}
pub fn len(&self) -> usize {
@@ -354,16 +485,20 @@ impl Vault {
.iter_mut()
.find(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
let mut renamed = false;
if let Some(v) = issuer.map(str::trim).filter(|s| !s.is_empty()) {
if v.len() > 128 {
return Err(Error::invalid("issuer must be 128 characters or fewer"));
}
renamed |= a.issuer != v;
a.issuer = v.to_string();
}
if let Some(v) = label.map(str::trim) {
if v.len() > 128 {
return Err(Error::invalid("account must be 128 characters or fewer"));
}
renamed |= a.label != v;
a.label = v.to_string();
}
if let Some(v) = push_enabled {
@@ -374,6 +509,17 @@ impl Vault {
}
a.push_enabled = v;
}
// A rename changes what the Discord tile should say, but not the
// code — and the push loop skips any account whose code it has
// already sent. Without this the new name would sit invisible
// until the code next rolled, up to a full period later. Clearing
// the last-sent code makes the very next tick treat the tile as
// stale, so the rename shows up in about a second.
if renamed {
a.discord.last_code = None;
}
AccountPublic::from(&*a)
};
self.persist();
@@ -412,7 +558,7 @@ impl Vault {
a.discord.last_error = None;
a.discord.last_push_at = Some(OffsetDateTime::now_utc());
}
self.persist();
self.touch_state();
}
/// Record a delivery failure against one account.
@@ -429,7 +575,7 @@ impl Vault {
}
a.discord.last_error = Some(error.to_string());
}
self.persist();
self.touch_state();
}
/// Forget an account's message id — used when Discord reports the
@@ -443,7 +589,7 @@ impl Vault {
a.discord.message_id = None;
a.discord.last_code = None;
}
self.persist();
self.touch_state();
}
/// Drop every stored message id. Called when the webhook URL changes:
@@ -455,7 +601,11 @@ impl Vault {
a.discord = DiscordState::default();
}
}
self.persist();
self.touch_state();
// A webhook change is a deliberate, rare operator action and the
// caller is about to tell them it worked, so make it durable now
// rather than on the next tick.
self.flush_state_now();
}
/// Is an identical account already stored?
@@ -543,7 +693,14 @@ impl Vault {
Ok(outcome)
}
/// Write the accounts out.
///
/// Called only when the account list itself changes. Discord bookkeeping
/// rides in [`Vault::state_path`] instead and never reaches this function,
/// so the secrets are rewritten on operator edits rather than on every
/// code rotation — see the module docs.
fn persist(&self) {
let _guard = self.write_lock.lock();
let snapshot = VaultFile {
accounts: self.inner.read().clone(),
};
@@ -557,15 +714,150 @@ impl Vault {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::error!(target: "da::vault", "could not write vault temp file: {e}");
if let Err(e) = write_atomic(&self.path, &body) {
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
// Refresh the backup *after* the new vault is safely in place, so the
// two files always hold the same accounts. The tempting order — back
// up the old file, then overwrite it — leaves the backup a full change
// behind, which quietly loses the most recent edit if the vault is
// later damaged. Recovery is only worth having if it recovers
// everything.
if let Err(e) = std::fs::copy(self.path.as_path(), self.backup_path.as_path()) {
tracing::warn!(target: "da::vault", "could not refresh vault backup: {e}");
}
}
/// Mark the Discord bookkeeping as changed.
///
/// Cheap on purpose: the actual write is deferred to
/// [`Vault::flush_state`], which the push loop calls once per pass.
fn touch_state(&self) {
let mut g = self.pending.lock();
g.dirty = true;
}
/// Write `push-state.json` if anything has changed and enough time has
/// passed. Safe and cheap to call on every push tick.
pub fn flush_state(&self) {
{
let g = self.pending.lock();
if !g.due() {
return;
}
}
self.write_state();
}
/// Write `push-state.json` unconditionally — for shutdown, where there is
/// no next tick to catch a deferred write.
pub fn flush_state_now(&self) {
self.write_state();
}
fn write_state(&self) {
let _guard = self.write_lock.lock();
let body = {
let accounts = self.inner.read();
let file = PushStateFile {
accounts: accounts
.iter()
.filter(|a| a.discord.message_id.is_some() || a.discord.last_error.is_some())
.map(|a| (a.id.clone(), a.discord.clone()))
.collect(),
};
match serde_json::to_vec(&file) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "da::vault", "could not serialize push state: {e}");
return;
}
}
};
if let Some(parent) = self.state_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = write_atomic(&self.state_path, &body) {
tracing::error!(target: "da::vault", "could not write push state: {e}");
// Leave it dirty so the next tick tries again rather than
// silently forgetting the change.
return;
}
let mut g = self.pending.lock();
g.dirty = false;
g.last_flush = Some(std::time::Instant::now());
}
}
/// `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.
fn load_accounts(path: &Path, backup_path: &Path) -> Result<(Vec<Account>, bool)> {
let text = match std::fs::read_to_string(path) {
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."
);
Ok((accounts, true))
}
None => Err(Error::invalid(format!(
"{} exists but could not be parsed ({primary_err}), and the backup at {} \
could not be used either. Refusing to start with an empty vault — move the \
file aside to start fresh.",
path.display(),
backup_path.display()
))),
},
}
}
/// Parse the backup, or `None` if it is missing 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)]
@@ -663,6 +955,8 @@ mod tests {
#[test]
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
// No backup to fall back on, so this is the unrecoverable case and the
// original refusal must stand.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("vault.json"), b"{ not json").unwrap();
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
@@ -842,12 +1136,15 @@ mod tests {
#[test]
fn message_ids_are_persisted_as_strings() {
// Snowflakes exceed 2^53; a JSON number would round-trip lossily.
// Message ids now live in push-state.json, so that is where the
// quoting has to hold.
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "1409876543210987654", "000000");
v.flush_state_now();
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
let raw = std::fs::read_to_string(dir.path().join("push-state.json")).unwrap();
assert!(
raw.contains("\"1409876543210987654\""),
"must be quoted: {raw}"
@@ -860,6 +1157,23 @@ mod tests {
);
}
#[test]
fn the_secret_bearing_file_stays_free_of_discord_state() {
// The split is the protection: whatever goes wrong with the file that
// is rewritten constantly, it cannot take the secrets with it.
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "1409876543210987654", "000000");
v.flush_state_now();
let vault_json = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
assert!(
!vault_json.contains("1409876543210987654"),
"vault.json must not carry tile ids: {vault_json}"
);
}
#[test]
fn push_targets_exclude_paused_accounts_and_keep_order() {
let dir = tempdir().unwrap();
@@ -892,4 +1206,249 @@ mod tests {
assert!(v.push_targets()[0].message_id.is_none());
assert!(!v.get(&a.id).unwrap().push_live);
}
// ── Recovery from a damaged vault ───────────────────────────────────────
//
// The failure these cover: an unclean shutdown left `vault.json` present
// but zero bytes, and the old load path refused to start, so the container
// restart-looped with no way back in.
#[test]
fn a_zero_length_vault_recovers_from_the_backup() {
let dir = tempdir().unwrap();
let id = {
let v = vault(dir.path());
let id = v.add(&sample(), None, None, true).unwrap().id;
// A second change is what creates the .bak of the first.
v.add(&sample(), Some("Second"), None, true).unwrap();
id
};
// Simulate the interrupted write: an empty file where the vault was.
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
let recovered = vault(dir.path());
assert_eq!(recovered.len(), 2, "the backup must be used, not ignored");
assert!(
recovered.totp_for(&id).is_ok(),
"recovered accounts must still decrypt"
);
}
#[test]
fn a_recovered_vault_keeps_the_damaged_file_for_inspection() {
let dir = tempdir().unwrap();
{
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap();
v.add(&sample(), Some("Second"), None, true).unwrap();
}
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
let _ = vault(dir.path());
let quarantined: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("vault.json.corrupt-"))
.collect();
assert_eq!(
quarantined.len(),
1,
"the damaged file must be moved aside, not deleted: {quarantined:?}"
);
}
#[test]
fn a_recovery_is_written_back_so_the_next_boot_is_clean() {
let dir = tempdir().unwrap();
{
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap();
v.add(&sample(), Some("Second"), None, true).unwrap();
}
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
// First boot recovers; a later edit rewrites vault.json from memory.
{
let v = vault(dir.path());
assert_eq!(v.len(), 2);
v.add(&sample(), Some("Third"), None, true).unwrap();
}
let reloaded = vault(dir.path());
assert_eq!(reloaded.len(), 3);
assert_eq!(reloaded.push_targets().len(), 3);
}
#[test]
fn a_recovery_puts_the_vault_back_on_disk_immediately() {
let dir = tempdir().unwrap();
{
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap();
v.add(&sample(), Some("Second"), None, true).unwrap();
}
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
// Booting must leave a real vault behind, not just the .bak. If it
// did not, the recovered accounts would live only in memory and a
// second unclean stop would have nothing left to recover from.
{
let v = vault(dir.path());
assert_eq!(v.len(), 2);
}
let on_disk = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
assert!(
on_disk.contains("Cloudflare"),
"the vault must be rebuilt on load: {on_disk}"
);
// And a second boot is then entirely ordinary.
assert_eq!(vault(dir.path()).len(), 2);
}
#[test]
fn a_backup_that_is_also_unreadable_still_refuses_to_start() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("vault.json"), b"not json").unwrap();
std::fs::write(dir.path().join("vault.json.bak"), b"{ not json either").unwrap();
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
assert!(
r.is_err(),
"a backup that cannot be parsed is no recovery at all"
);
}
#[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}"
);
}
}
+9
View File
@@ -61,6 +61,10 @@ async fn main() -> anyhow::Result<()> {
let admin = AdminStore::load_or_default(&args.work_dir);
let settings = SettingsStore::load_or_default(&args.work_dir);
// Held separately from `state` so the shutdown path below can still reach
// the vault after the router has taken ownership.
let vault_on_shutdown = vault.clone();
let engine = PushEngine::new(vault.clone(), settings.clone());
tokio::spawn(engine.clone().run());
@@ -97,6 +101,11 @@ async fn main() -> anyhow::Result<()> {
.await
.context("the HTTP server stopped unexpectedly")?;
// The push loop only flushes bookkeeping when a few seconds have piled up.
// On a clean stop there is no next tick, so write whatever is still
// pending rather than leaving the last rotation to be rediscovered.
vault_on_shutdown.flush_state_now();
tracing::info!(target: "da", "shutting down");
Ok(())
}
+14
View File
@@ -58,6 +58,12 @@ impl std::fmt::Debug for PushEngine {
}
impl PushEngine {
/// The vault this engine is driving, so a caller shutting down can flush
/// the bookkeeping it has been batching.
pub fn vault(&self) -> &Vault {
&self.vault
}
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
Self {
vault,
@@ -81,6 +87,14 @@ impl PushEngine {
/// One pass: bring every tile up to date and tidy away any that shouldn't
/// exist. Returns how many messages were written.
pub async fn sync_once(&self) -> usize {
let written = self.sync_tiles().await;
// Bookkeeping changes are batched in memory; this is the tick that
// decides whether enough of them have piled up to be worth a write.
self.vault.flush_state();
written
}
async fn sync_tiles(&self) -> usize {
// Held for the whole pass: see `sync_lock`. A pass can take several
// seconds once the rate limiter spaces things out, which is easily
// long enough for the next tick to arrive.
+81
View File
@@ -385,6 +385,87 @@ async fn a_dead_webhook_is_surfaced_and_not_hammered() {
assert!(vault.push_targets()[0].message_id.is_some());
}
#[tokio::test]
async fn renaming_an_account_refreshes_its_tile_without_waiting_for_the_code() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
// A 30-second period: the code will NOT roll during this test, so any
// update we see must have been triggered by the rename itself.
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
assert_eq!(mock.log.lock().unwrap().len(), 1);
// Nothing changed yet, so a second pass must stay silent.
engine.sync_once().await;
assert_eq!(
mock.log.lock().unwrap().len(),
1,
"an idle pass must cost nothing"
);
// Rename, then run one pass — no sleeping, no code rollover.
let id = vault.list()[0].id.clone();
vault
.update(&id, Some("Cloudflare Work"), Some("[email protected]"), None)
.unwrap();
assert_eq!(
engine.sync_once().await,
1,
"the rename should trigger a push"
);
{
let log = mock.log.lock().unwrap();
assert_eq!(log.len(), 2);
assert_eq!(
log[1].method, "PATCH",
"the same tile is edited, not replaced"
);
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
// The edited message must carry the new naming.
let body = log[1].json();
assert_eq!(body["embeds"][0]["title"], "Cloudflare Work");
let desc = body["embeds"][0]["description"].as_str().unwrap();
assert!(
desc.contains("[email protected]"),
"new label missing: {desc}"
);
assert!(
!desc.contains("[email protected]"),
"old label still present: {desc}"
);
}
// And it settles: a further pass sends nothing more.
engine.sync_once().await;
assert_eq!(
mock.log.lock().unwrap().len(),
2,
"rename must not re-push forever"
);
}
#[tokio::test]
async fn toggling_push_alone_does_not_rewrite_the_tile() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let id = vault.list()[0].id.clone();
// Re-affirming the same push state is not a rename and changes nothing
// the tile displays, so it should not cost a request.
vault.update(&id, None, None, Some(true)).unwrap();
engine.sync_once().await;
assert_eq!(mock.log.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn pausing_an_account_removes_its_tile() {
let dir = tempfile::tempdir().unwrap();
+15
View File
@@ -0,0 +1,15 @@
//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo
//! avatar and the Unraid template need:
//! `cargo run -p da-render --example brand -- docs/brand`
fn main() {
let dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "docs/brand".into());
let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg");
for size in [512u32, 256, 128] {
let png = da_render::rasterise_svg(&svg, size, size).expect("render");
let path = format!("{dir}/icon-{size}.png");
std::fs::write(&path, &png).expect("write");
println!("wrote {path} ({} bytes)", png.len());
}
}
+35
View File
@@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
Ok(out.into_inner())
}
/// Rasterise arbitrary SVG source to a PNG at a given size.
///
/// Exposed so the brand assets (README image, Gitea avatar, Unraid template
/// icon) are generated from the same `icon.svg` the app ships, rather than
/// being hand-exported and drifting out of sync with it.
pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
let tree = usvg::Tree::from_str(svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?;
// Scale the document to fill the requested pixmap.
let size = tree.size();
let scale = (width as f32 / size.width()).min(height as f32 / size.height());
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
width,
height,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
let svg = build_svg(tile);
let tree = usvg::Tree::from_str(&svg, options())?;
+28 -3
View File
@@ -160,15 +160,39 @@ a:hover { text-decoration: underline; }
.shell { display: flex; flex-direction: column; min-height: 100vh; }
/* Three columns: the search occupies the middle one, so it lands on the same
centre line as the account tiles below no matter how wide the brand or the
action cluster get. The outer columns are equal-width and allowed to
shrink, which keeps the centring exact rather than approximate. */
.topbar {
position: sticky; top: 0; z-index: 30;
display: flex; align-items: center; gap: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
align-items: center; gap: 12px;
height: var(--topbar-h);
padding: 0 16px;
background: color-mix(in oklab, var(--page) 82%, transparent);
backdrop-filter: saturate(1.6) blur(12px);
border-bottom: 1px solid var(--line);
}
.topbar .brand { justify-self: start; min-width: 0; }
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
.topbar .actions {
justify-self: end;
display: flex; align-items: center; gap: 2px;
}
/* True centring needs room for all three: the brand wordmark (~210px), the
420px search, the action cluster (~110px), plus gaps and padding — about
800px in total. Below that the grid starts clipping "Discord Authenticator",
so fall back to a plain row where the brand keeps its space and the search
takes whatever is left. */
@media (max-width: 820px) {
.topbar { display: flex; }
.topbar .brand { flex: 0 0 auto; }
.topbar .search { flex: 1 1 auto; justify-self: auto; }
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
}
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
.brand svg { width: 26px; height: 26px; display: block; }
@@ -192,8 +216,9 @@ a:hover { text-decoration: underline; }
.search {
position: relative;
flex: 1 1 auto;
width: 100%;
max-width: 420px;
justify-self: center;
}
.search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
@@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
.fab {
position: fixed;
right: max(20px, calc(50vw - 344px));
right: 24px;
bottom: 24px;
z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
+38 -24
View File
@@ -89,6 +89,7 @@ const ICON = {
back: 'M19 12H5m7 7-7-7 7-7',
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'],
};
// ── State ──────────────────────────────────────────────────────────────────
@@ -349,27 +350,31 @@ function renderShell() {
const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 });
// Three columns so the search sits on the page's centre line, matching the
// centred account tiles below it, regardless of how wide the brand or the
// action cluster happen to be.
const topbar = h('header.topbar', {}, [
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
logoImg, h('strong', { text: 'Authenticator' }),
logoImg, h('strong', { text: 'Discord Authenticator' }),
]),
searchWrap,
h('div.spacer'),
h('button.icon-btn.theme-toggle', {
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
onclick: toggleTheme,
}, [
icon(ICON.sun, { className: 't-sun', width: 2 }),
icon(ICON.moon, { className: 't-moon', width: 2 }),
h('div.actions', {}, [
h('button.icon-btn.theme-toggle', {
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
onclick: toggleTheme,
}, [
icon(ICON.sun, { className: 't-sun', width: 2 }),
icon(ICON.moon, { className: 't-moon', width: 2 }),
]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Settings', title: 'Settings',
onclick: () => go('settings'),
}, [icon(ICON.settings, { width: 1.7 })]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
}, [icon(ICON.logout, { width: 2 })]),
]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Settings', title: 'Settings',
onclick: () => go('settings'),
}, [icon(ICON.settings, { width: 1.7 })]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
}, [icon(ICON.logout, { width: 2 })]),
]);
root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })]));
@@ -480,9 +485,9 @@ function acctRow(a) {
codeBtn,
ring,
h('button.icon-btn', {
type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options',
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.settings, { width: 1.7 })]),
}, [icon(ICON.edit, { width: 1.9 })]),
]),
]);
return row;
@@ -1057,15 +1062,21 @@ function openAccountSheet(a) {
const label = h('input.input', { value: a.label || '' });
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
const dlg = openModal(a.issuer || 'Account', [
const dlg = openModal('Edit account', [
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
h('div.field', {}, [h('label', { text: 'Account' }), label]),
h('div.field', {}, [
h('label', { text: 'Name' }), issuer,
h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }),
]),
h('div.field', {}, [
h('label', { text: 'Account' }), label,
h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }),
]),
h('label.switch', { style: 'margin:16px 0 4px' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream this code to Discord' }),
]),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }),
], [
h('button.btn.btn-danger', { onclick: async () => {
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
@@ -1075,14 +1086,17 @@ function openAccountSheet(a) {
} catch (e) { toast(e.message, 'err'); }
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
h('div', { style: 'flex:1' }),
h('button.btn', { onclick: dlg.close, text: 'Cancel' }),
// Deferred: `dlg` is still in its temporal dead zone while these
// arguments are being built, so `onclick: dlg.close` throws and the whole
// sheet fails to open. Every other handler here is already a closure.
h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }),
h('button.btn.btn-primary', { onclick: async () => {
try {
await api('/api/accounts/' + encodeURIComponent(a.id), {
method: 'PATCH',
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
});
dlg.close(); toast('Saved'); await reloadAll();
dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll();
} catch (e) { toast(e.message, 'err'); }
}, text: 'Save' }),
]);
+1 -1
View File
@@ -45,7 +45,7 @@
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Discord Authenticator">
<!-- App icon: the shield-and-arc mark on the product's own dark surface.
Colours are literal rather than currentColor so this renders correctly
as a favicon, a Gitea repo avatar and an Unraid template icon, none of
which supply a CSS context. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#232833"/>
<stop offset="1" stop-color="#161a22"/>
</linearGradient>
<linearGradient id="mark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6ea3ff"/>
<stop offset="1" stop-color="#3d78e8"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<rect x="1" y="1" width="510" height="510" rx="113"
fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
<g transform="translate(96 96) scale(10)">
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="url(#mark)" fill-opacity=".18"/>
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="none" stroke="url(#mark)" stroke-width="1.9" stroke-linejoin="round"/>
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
fill="none" stroke="url(#mark)" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="url(#mark)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+87
View File
@@ -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.
+4 -4
View File
@@ -13,9 +13,9 @@
# GITEA_OWNER default: mward4
# GITEA_REPO default: Discord-Authenticator
# IMAGE_NAME default: discord-authenticator
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
# IMAGE_TAG default: 0.1.3 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1 (tag/branch to build)
# GIT_REF default: v0.1.3 (tag/branch to build)
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
@@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
IMAGE_TAG=${IMAGE_TAG:-0.1.3}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1}
GIT_REF=${GIT_REF:-v0.1.3}
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
# OCI distribution rejects uppercase repository paths, so the image name is