17 Commits
Author SHA1 Message Date
DSH Agent bd7f09095f v0.1.4: name the failure accurately, and add DA_VAULT_RESET
Build and release image / build (push) Successful in 1m9s
No change to how the vault is written or recovered. This fixes what the service
says when recovery is not possible, and adds a way out that is not "edit files
inside a container that will not start".

The refusal message was wrong in the case that actually matters. It said the
backup "could not be used either" whether the backup was unparseable, empty, or
simply absent — and absent is the common one, because a vault that has not
completed a single write since v0.1.3 introduced backups has no .bak at all.
That wording reads as though a file exists, and sends the operator hunting for
it. The message now names which of the three it is, and lists any
vault.json.corrupt-* files left by earlier recoveries.

DA_VAULT_RESET=1 boots an unreadable vault as empty. It is checked only on the
failure path, so it cannot affect a vault that loads, and it quarantines the
unreadable file *before* writing the fresh one — the first draft of this logged
that the file was preserved while persist would in fact have overwritten it.

Refusing by default is still right: booting empty is indistinguishable from a
vault emptied on purpose. But a refusal with no supported way forward is not a
safety feature, it is a dead end.

149 tests.
2026-09-20 04:08:10 -04:00
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
Miles WardandClaude Opus 4.8 eecdacd890 v0.1.1: import Google Authenticator exports
Google's "Transfer accounts -> Export accounts" produces an
otpauth-migration:// link wrapping a base64 protobuf that holds many accounts
at once, and splits large exports across several QR codes. v0.1 rejected these
outright; now they import.

The protobuf is decoded by hand rather than pulling prost: the schema is four
messages that have not changed since 2020, and the wire format needed is two
types (varint, length-delimited). Unknown fields are skipped properly, so a
future Google addition degrades rather than corrupting the parse.

Two details in this format silently produce plausible-but-wrong codes if taken
backwards, so both are pinned by tests: the `secret` field is RAW BYTES, not
base32, and the payload carries NO period field -- every Google export is
30-second TOTP.

- core/migration.rs: protobuf decode, lenient base64 (all four alphabet and
  padding spellings), issuer reconciliation between the `name` prefix and the
  dedicated `issuer` field, and batch metadata surfaced as "Part 1 of 2"
- vault: add_many() with duplicate detection, persisting once per batch rather
  than once per account; duplicates match on secret AND naming, so keeping one
  secret under two labels stays possible
- http-api: preview returns a list flagged with what is already stored; import
  takes the link plus selected indices and re-parses server-side, so secrets
  never travel out to the browser and back. /api/qr/decode now returns every
  payload in an image, not just the first
- webui: multi-account import checklist with select all/none, per-entry
  algorithm and digit chips, already-present entries pre-unticked, and skipped
  entries named with their reason (HOTP, MD5) instead of silently dropped

Verified end to end against real QR images: upload -> decode -> preview ->
import, with the resulting codes cross-checked against an independent RFC 6238
implementation, including an 8-digit account.

124 tests (was 101).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 00:44:59 -04:00
31 changed files with 4128 additions and 143 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
+7 -6
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "da-core"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"argon2",
"base32",
@@ -461,7 +461,7 @@ dependencies = [
[[package]]
name = "da-discord"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"axum",
"da-core",
@@ -478,9 +478,10 @@ dependencies = [
[[package]]
name = "da-http-api"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"axum",
"base64 0.22.1",
"da-core",
"da-discord",
"da-render",
@@ -499,7 +500,7 @@ dependencies = [
[[package]]
name = "da-render"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"image",
"resvg",
@@ -511,7 +512,7 @@ dependencies = [
[[package]]
name = "da-webui"
version = "0.1.0"
version = "0.1.4"
[[package]]
name = "data-url"
@@ -541,7 +542,7 @@ dependencies = [
[[package]]
name = "discord-authenticator"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.1.4"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
+182 -9
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
@@ -169,20 +242,57 @@ Settings.
photos of a screen, tiny crops and rotated images all work; the decoder walks
a ladder of rescale, contrast and rotation attempts before giving up. (HEIC
is rejected with a note to screenshot instead — iPhone screenshots are PNG.)
If the image holds several QR codes, all of them are read.
- **Camera** scanning, using the browser's native barcode detector where it
exists and falling back to server-side decoding where it doesn't, which
covers Safari and Firefox. Note that browsers only grant camera access over
HTTPS or on `localhost`.
- **Paste** an `otpauth://` link, or type a secret by hand.
### Importing from Google Authenticator
Google Authenticator's **Transfer accounts → Export accounts** produces a QR
code holding *many* accounts at once, not one — it's an
`otpauth-migration://` link wrapping a base64 protobuf. Upload or paste it and
you get a checklist of everything inside, with each entry's algorithm, digit
count and period shown, and an **Add N accounts** button.
Details worth knowing:
- **Large exports are split across several QR codes.** Google shows them as
separate screens. Each one reports its position, so the UI tells you
"Part 1 of 2" and prompts you to upload the rest. Import them in any order.
- **Re-importing is safe.** Anything already in your vault is detected and
pre-unticked, so scanning an export twice adds nothing. Duplicates are
matched on the secret *and* the naming, so deliberately keeping one secret
under two labels still works.
- **Entries that can't be imported are named, not silently dropped** — a
counter-based (HOTP) account or an MD5 one is listed with the reason.
- **Secrets never reach the browser.** The preview returns names and
parameters only; on import the client sends back the link plus the indices
it selected, and the server re-parses it.
Two traps this format sets, both of which silently produce *plausible but
wrong* codes if you get them backwards: the protobuf `secret` field is **raw
bytes**, not base32, and the payload carries **no period field** — every Google
export is 30-second TOTP.
The parser is deliberately forgiving of real-world URIs: it accepts lowercase
and `=`-padded secrets, and where the issuer in the path disagrees with the
`?issuer=` parameter — which Microsoft does — it takes the query parameter
rather than refusing the account.
**In the list**, click any code to copy it. `/` focuses the search box. The
gear on a row renames the account or pauses its Discord push; pausing removes
its tile from the channel.
**In the list**, click any code to copy it. `/` focuses the search box.
The pencil on a row opens **Edit account**. The *Name* is what shows in the
list and on the Discord tile; the *Account* field underneath (usually the
username or email the code belongs to) is left alone unless you change it —
it's editable, since providers sometimes put something unhelpful there. The
same sheet pauses Discord push for that account, or removes it entirely.
A rename reaches Discord within a second or two. It doesn't wait for the code
to roll: the push loop normally skips any account whose current code it has
already sent, so renaming clears that marker to force one refresh.
---
@@ -218,6 +328,7 @@ want the traffic minimal, Embed is the frugal choice.
```
crates/
core/ TOTP, vault, crypto, settings, admin auth — no network
(incl. Google Authenticator export parsing)
render/ SVG → WebP tile rendering (resvg + embedded fonts)
discord/ Webhook client, rate limiter, live-tile push engine
webui/ Embedded HTML/CSS/JS, compiled into the binary
@@ -241,14 +352,26 @@ JetBrains Mono. Light and dark both ship.
### Testing
101 tests, including:
143 tests, including:
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG.
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
and two codes stacked in one screenshot.
- **Google export decoding** driven from real protobuf wire bytes: multi-account
payloads, batch metadata, every algorithm and digit variant, all four base64
spellings, unknown-field forward compatibility, and truncated input. One test
asserts the raw-bytes secret handling against the RFC 6238 vector, because
that is the mistake that would otherwise ship working-looking wrong codes.
- The **live push cycle against a mock Discord**: that the first call carries
`?wait=true`, that later passes PATCH the same message rather than posting
again, that a deleted tile is reposted, that a dead webhook is surfaced
instead of hammered, and that concurrent rollovers are paced apart.
instead of hammered, that concurrent rollovers are paced apart, and that a
rename refreshes the tile immediately without re-posting it or looping.
- **Crash recovery**: that a zero-length `vault.json` is recovered from the
backup, that the damaged file is kept rather than deleted, that the recovered
accounts are written back so the next boot is ordinary, that an unusable
backup still refuses to boot rather than starting empty, and that a code
rotation does not rewrite the file holding the secrets.
```bash
cargo test --workspace
@@ -257,6 +380,56 @@ cargo clippy --workspace --all-targets
---
## Troubleshooting
### The container won't start: "could not load the vault"
The app refuses to start on an unreadable `vault.json` because the alternative
— booting with an empty vault — invites you to re-add accounts you already have.
Since v0.1.3 it recovers on its own first: if `vault.json.bak` parses, the
accounts are loaded from it, the damaged file is moved to
`vault.json.corrupt-<timestamp>`, and a fresh `vault.json` is written. You will
see one `ERROR` line saying so, then normal startup.
If it still refuses, the message names exactly which situation you are in,
because they need different responses:
- **"there is no backup at …"** — no `.bak` was ever created, which means this
vault has not completed a single write since backups were introduced. Nothing
on disk is recoverable; the unreadable file is all there is.
- **"exists but could not be parsed either"** — both copies are gone.
- **"is empty as well"** — both are zero bytes.
Either way, the service will not boot with an empty vault on its own, because
that looks identical to a vault you emptied on purpose. Two ways forward:
```bash
# 1. by hand
cd /mnt/user/appdata/discord-authenticator
mv vault.json "vault.json.broken-$(date +%s)"
docker restart Discord-Authenticator
```
Or set `DA_VAULT_RESET=1` in the Unraid template (Docker → the container → Add
another Path, Variable, Port or Label → Variable, Key `DA_VAULT_RESET`, Value
`1`) and restart. The service boots empty and moves the unreadable file to
`vault.json.corrupt-<timestamp>` before writing a fresh vault, so nothing is
overwritten. **Remove the variable afterwards** — it only has an effect on the
failure path, but leaving it set means the next genuinely-broken vault boots
empty instead of telling you about it.
Then re-import your accounts from your own backups (`otpauth://` links or a
Google Authenticator export). Keep `vault.key` — without it, nothing in an old
vault file can be decrypted even if the bytes turn up later.
**If you hit this, upgrading matters more than the workaround.** The cause was
a rename being journalled ahead of the data it published, which is fixed by
fsyncing before the rename; see `crates/core/src/store_io.rs` for the ordering
and why it is the whole trick.
---
## Development
```bash
+22 -1
View File
@@ -10,16 +10,37 @@
pub mod auth;
pub mod crypto;
pub mod error;
pub mod migration;
pub mod settings;
pub mod store_io;
pub mod totp;
pub mod vault;
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use crypto::{Sealed, VaultKey};
pub use error::{Error, Result};
pub use migration::{is_migration_uri, parse_migration, ImportBatch, Skipped};
pub use settings::{validate_webhook_url, PushMode, Settings, SettingsStore};
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
pub use vault::{Account, AccountPublic, DiscordState, PushTarget, Vault};
pub use vault::{
Account, AccountPublic, DiscordState, ImportOutcome, NewAccount, PushTarget, Vault,
};
/// Parse any link the "add an account" flow accepts.
///
/// A plain `otpauth://` URI yields a batch of one; a Google Authenticator
/// `otpauth-migration://` export yields however many accounts it carries. The
/// callers upstream — preview and import — therefore only ever deal in
/// batches and need no special case for the single-account path.
pub fn parse_import(uri: &str) -> Result<ImportBatch> {
if is_migration_uri(uri) {
return parse_migration(uri);
}
Ok(ImportBatch {
accounts: vec![parse_otpauth(uri)?],
..ImportBatch::default()
})
}
/// Seconds since the Unix epoch.
///
+733
View File
@@ -0,0 +1,733 @@
//! Google Authenticator export parsing (`otpauth-migration://offline?data=…`).
//!
//! The payload is a base64 protobuf holding many accounts at once. Google
//! splits large exports across several QR codes, each carrying `batch_index`
//! and `batch_size` so the user knows how many screens to scan.
//!
//! ## Why the protobuf is hand-decoded
//!
//! Pulling `prost` would mean either a `protoc` build dependency or a
//! generated file checked in, for a schema that is four messages long and has
//! not changed since 2020. The wire format we need is two wire types (varint
//! and length-delimited), so the reader below is short enough to audit.
//! Unknown fields are skipped properly, so a future Google addition won't
//! break parsing.
//!
//! Schema (Google Authenticator 5.10, reverse-engineered but stable):
//!
//! ```text
//! MigrationPayload { otp_parameters=1 (repeated), version=2,
//! batch_size=3, batch_index=4, batch_id=5 }
//! OtpParameters { secret=1 (bytes), name=2, issuer=3,
//! algorithm=4, digits=5, type=6, counter=7 }
//! Algorithm { UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4 }
//! DigitCount { UNSPECIFIED=0, SIX=1, EIGHT=2 }
//! OtpType { UNSPECIFIED=0, HOTP=1, TOTP=2 }
//! ```
//!
//! Two traps worth naming, because both silently produce wrong codes:
//!
//! * **`secret` is raw bytes, not base32.** It has to be base32-encoded to
//! become the secret an `otpauth://` URI would carry.
//! * **There is no period field.** Google only ever exports 30-second TOTP,
//! so 30 is assumed rather than guessed at.
use crate::totp::{Algorithm, OtpAuth, Totp};
use crate::{Error, Result};
/// Google never exports a period; every entry is the 30-second default.
const ASSUMED_PERIOD: u64 = 30;
/// An account present in the export that we deliberately did not import.
#[derive(Debug, Clone)]
pub struct Skipped {
pub name: String,
pub reason: String,
}
/// One decoded export QR code.
#[derive(Debug, Clone, Default)]
pub struct ImportBatch {
pub accounts: Vec<OtpAuth>,
pub skipped: Vec<Skipped>,
/// 0-based index of this QR within the export, when present.
pub batch_index: Option<i32>,
/// Total number of QR codes in the export, when present.
pub batch_size: Option<i32>,
/// Identifies which export a QR belongs to, so codes from two different
/// export sessions aren't mistaken for parts of one.
pub batch_id: Option<i32>,
}
impl ImportBatch {
/// Human-readable "Part 2 of 3", when the export was split.
pub fn part_label(&self) -> Option<String> {
match (self.batch_index, self.batch_size) {
(Some(i), Some(n)) if n > 1 => Some(format!("Part {} of {n}", i + 1)),
_ => None,
}
}
}
/// Is this a Google Authenticator export link?
pub fn is_migration_uri(uri: &str) -> bool {
uri.trim()
.to_ascii_lowercase()
.starts_with("otpauth-migration://")
}
/// Parse an `otpauth-migration://offline?data=…` link into its accounts.
pub fn parse_migration(uri: &str) -> Result<ImportBatch> {
let uri = uri.trim();
let parsed = url::Url::parse(uri)
.map_err(|e| Error::invalid(format!("not a valid export link: {e}")))?;
if !parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
return Err(Error::invalid(
"that is not a Google Authenticator export link",
));
}
// `url` percent-decodes query values for us. Google's base64 contains
// `+` and `/`, which arrive percent-encoded in a well-formed URI.
let data = parsed
.query_pairs()
.find(|(k, _)| k.eq_ignore_ascii_case("data"))
.map(|(_, v)| v.into_owned())
.ok_or_else(|| Error::invalid("the export link has no ?data= parameter"))?;
if data.trim().is_empty() {
return Err(Error::invalid("the export link's data parameter is empty"));
}
let raw = decode_base64_lenient(&data)?;
decode_payload(&raw)
}
/// Decode base64 across the variants that turn up in the wild.
///
/// Google emits standard-alphabet padded base64, but the link is often
/// copied through tools that swap in the URL-safe alphabet or strip padding.
/// Trying all four costs nothing and avoids an unhelpful failure on a link
/// that is perfectly recoverable.
fn decode_base64_lenient(s: &str) -> Result<Vec<u8>> {
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
// Whitespace can be introduced by line-wrapping when a link is pasted.
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
// A macro rather than a slice of trait objects: base64's `Engine` is not
// dyn-compatible, and the alternatives are all zero-sized consts anyway.
macro_rules! try_decode {
($engine:expr) => {
if let Ok(bytes) = $engine.decode(&cleaned) {
if !bytes.is_empty() {
return Ok(bytes);
}
}
};
}
try_decode!(STANDARD);
try_decode!(STANDARD_NO_PAD);
try_decode!(URL_SAFE);
try_decode!(URL_SAFE_NO_PAD);
Err(Error::invalid(
"the export link's data is not valid base64 — copy the whole link, \
including everything after data=",
))
}
// ── Minimal protobuf reader ───────────────────────────────────────────────
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn done(&self) -> bool {
self.pos >= self.buf.len()
}
fn varint(&mut self) -> Option<u64> {
let mut value: u64 = 0;
let mut shift = 0u32;
loop {
let byte = *self.buf.get(self.pos)?;
self.pos += 1;
// 10 groups of 7 bits is the most a u64 can hold; more than that
// is a malformed (or hostile) message rather than a big number.
if shift > 63 {
return None;
}
value |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(value);
}
shift += 7;
}
}
/// Returns `(field_number, wire_type)`.
fn tag(&mut self) -> Option<(u32, u8)> {
let key = self.varint()?;
let field = u32::try_from(key >> 3).ok()?;
let wire = (key & 0x07) as u8;
if field == 0 {
return None; // field 0 is illegal; treat as corruption
}
Some((field, wire))
}
fn length_delimited(&mut self) -> Option<&'a [u8]> {
let len = usize::try_from(self.varint()?).ok()?;
let end = self.pos.checked_add(len)?;
let slice = self.buf.get(self.pos..end)?;
self.pos = end;
Some(slice)
}
/// Advance past a field we don't care about, so unknown additions to the
/// schema don't desynchronise the parse.
fn skip(&mut self, wire: u8) -> Option<()> {
match wire {
0 => {
self.varint()?;
}
1 => self.pos = self.pos.checked_add(8)?,
2 => {
self.length_delimited()?;
}
5 => self.pos = self.pos.checked_add(4)?,
// 3/4 are the deprecated start/end-group types, which this schema
// never uses. Anything else is corruption.
_ => return None,
}
if self.pos > self.buf.len() {
return None;
}
Some(())
}
}
fn corrupt() -> Error {
Error::invalid(
"the export data could not be decoded — re-export from Google \
Authenticator and scan the fresh QR code",
)
}
fn decode_payload(raw: &[u8]) -> Result<ImportBatch> {
let mut out = ImportBatch::default();
let mut r = Reader::new(raw);
while !r.done() {
let (field, wire) = r.tag().ok_or_else(corrupt)?;
match (field, wire) {
(1, 2) => {
let entry = r.length_delimited().ok_or_else(corrupt)?;
match decode_entry(entry) {
Ok(Some(account)) => out.accounts.push(account),
Ok(None) => {}
Err(skipped) => out.skipped.push(skipped),
}
}
(3, 0) => out.batch_size = r.varint().ok_or_else(corrupt)?.try_into().ok(),
(4, 0) => out.batch_index = r.varint().ok_or_else(corrupt)?.try_into().ok(),
(5, 0) => out.batch_id = r.varint().ok_or_else(corrupt)?.try_into().ok(),
// version (2) and anything Google adds later.
_ => r.skip(wire).ok_or_else(corrupt)?,
}
}
if out.accounts.is_empty() && out.skipped.is_empty() {
return Err(Error::invalid("that export link contains no accounts"));
}
Ok(out)
}
/// Decode one `OtpParameters`. `Err(Skipped)` means "present but not
/// importable", which the UI reports rather than silently dropping.
fn decode_entry(buf: &[u8]) -> std::result::Result<Option<OtpAuth>, Skipped> {
let mut secret: Vec<u8> = Vec::new();
let mut name = String::new();
let mut issuer = String::new();
let mut algorithm = Algorithm::Sha1;
let mut digits: u8 = 6;
let mut otp_type: u64 = 2; // default to TOTP when unspecified
let mut bad_algorithm: Option<u64> = None;
let mut r = Reader::new(buf);
while !r.done() {
let Some((field, wire)) = r.tag() else { break };
match (field, wire) {
(1, 2) => secret = r.length_delimited().unwrap_or_default().to_vec(),
(2, 2) => {
name =
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
}
(3, 2) => {
issuer =
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
}
(4, 0) => match r.varint().unwrap_or(1) {
0 | 1 => algorithm = Algorithm::Sha1, // unspecified means SHA1
2 => algorithm = Algorithm::Sha256,
3 => algorithm = Algorithm::Sha512,
other => bad_algorithm = Some(other), // MD5 and anything new
},
(5, 0) => {
digits = match r.varint().unwrap_or(1) {
2 => 8,
_ => 6, // unspecified and SIX both mean 6
};
}
(6, 0) => otp_type = r.varint().unwrap_or(2),
_ => {
if r.skip(wire).is_none() {
break;
}
}
}
}
// Split "Issuer:account", the same shape an otpauth:// label uses. The
// dedicated issuer field wins when both are present.
let (label_issuer, account) = match name.split_once(':') {
Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()),
None => (None, name.trim().to_string()),
};
let issuer_final = if issuer.trim().is_empty() {
label_issuer.unwrap_or_default()
} else {
issuer.trim().to_string()
};
let display = if account.is_empty() && issuer_final.is_empty() {
"(unnamed)".to_string()
} else if account.is_empty() {
issuer_final.clone()
} else {
account.clone()
};
if otp_type == 1 {
return Err(Skipped {
name: display,
reason: "counter-based (HOTP) codes aren't supported".into(),
});
}
if let Some(alg) = bad_algorithm {
let which = if alg == 4 {
"MD5"
} else {
"an unknown algorithm"
};
return Err(Skipped {
name: display,
reason: format!("uses {which}, which isn't supported"),
});
}
if secret.is_empty() {
return Err(Skipped {
name: display,
reason: "has no secret".into(),
});
}
// The protobuf carries raw secret bytes; Totp expects the same, so no
// base32 round-trip is needed here (only for display/export).
match Totp::new(secret, algorithm, digits, ASSUMED_PERIOD) {
Ok(totp) => Ok(Some(OtpAuth {
totp,
issuer: if issuer_final.is_empty() {
account.clone()
} else {
issuer_final
},
label: account,
})),
Err(e) => Err(Skipped {
name: display,
reason: e.to_string(),
}),
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::totp::encode_secret;
// Minimal protobuf *encoder*, so these tests drive the parser with real
// wire bytes rather than a mock.
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut byte = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
byte |= 0x80;
}
out.push(byte);
if v == 0 {
break;
}
}
}
fn put_tag(field: u32, wire: u8, out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | u64::from(wire), out);
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_tag(field, 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_tag(field, 0, out);
put_varint(v, out);
}
#[derive(Clone)]
struct Entry {
secret: Vec<u8>,
name: &'static str,
issuer: &'static str,
algorithm: u64,
digits: u64,
otp_type: u64,
}
impl Default for Entry {
fn default() -> Self {
Self {
secret: b"12345678901234567890".to_vec(),
name: "[email protected]",
issuer: "Cloudflare",
algorithm: 1, // SHA1
digits: 1, // SIX
otp_type: 2, // TOTP
}
}
}
fn encode_entry(e: &Entry) -> Vec<u8> {
let mut b = Vec::new();
put_bytes(1, &e.secret, &mut b);
put_bytes(2, e.name.as_bytes(), &mut b);
put_bytes(3, e.issuer.as_bytes(), &mut b);
put_uint(4, e.algorithm, &mut b);
put_uint(5, e.digits, &mut b);
put_uint(6, e.otp_type, &mut b);
b
}
fn encode_payload(entries: &[Entry], batch: Option<(i32, i32, i32)>) -> Vec<u8> {
let mut b = Vec::new();
for e in entries {
put_bytes(1, &encode_entry(e), &mut b);
}
put_uint(2, 1, &mut b); // version
if let Some((size, index, id)) = batch {
put_uint(3, size as u64, &mut b);
put_uint(4, index as u64, &mut b);
put_uint(5, id as u64, &mut b);
}
b
}
/// Build a link the way Google does: standard base64, percent-encoded
/// into the query string.
fn make_uri(payload: &[u8]) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
format!("otpauth-migration://offline?data={escaped}")
}
fn uri_for(entries: &[Entry]) -> String {
make_uri(&encode_payload(entries, None))
}
#[test]
fn detects_migration_links() {
assert!(is_migration_uri("otpauth-migration://offline?data=abc"));
assert!(is_migration_uri("OTPAUTH-MIGRATION://offline?data=abc"));
assert!(!is_migration_uri("otpauth://totp/X?secret=A"));
}
#[test]
fn imports_a_single_account() {
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
assert_eq!(b.accounts.len(), 1);
assert!(b.skipped.is_empty());
let a = &b.accounts[0];
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(a.totp.digits, 6);
assert_eq!(a.totp.period, 30, "Google exports are always 30s");
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
}
#[test]
fn secret_is_raw_bytes_not_base32() {
// The single most dangerous trap: treating the protobuf secret as if
// it were already base32 yields plausible-looking but wrong codes.
// The RFC 6238 vector secret is ASCII "12345678901234567890".
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
let totp = &b.accounts[0].totp;
assert_eq!(totp.secret, b"12345678901234567890");
// And it base32-encodes to the well-known test value.
assert_eq!(
encode_secret(&totp.secret),
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
);
// Which means it produces the official RFC 6238 code at t=59.
assert_eq!(totp.code_at(59), "287082");
}
#[test]
fn imports_several_accounts_from_one_qr() {
let entries = vec![
Entry {
name: "[email protected]",
issuer: "Cloudflare",
..Entry::default()
},
Entry {
name: "[email protected]",
issuer: "Plex",
..Entry::default()
},
Entry {
name: "[email protected]",
issuer: "Ubisoft",
..Entry::default()
},
];
let b = parse_migration(&uri_for(&entries)).unwrap();
assert_eq!(b.accounts.len(), 3);
let issuers: Vec<_> = b.accounts.iter().map(|a| a.issuer.as_str()).collect();
assert_eq!(issuers, ["Cloudflare", "Plex", "Ubisoft"]);
}
#[test]
fn reports_batch_position() {
let uri = make_uri(&encode_payload(&[Entry::default()], Some((3, 1, 42))));
let b = parse_migration(&uri).unwrap();
assert_eq!(b.batch_size, Some(3));
assert_eq!(b.batch_index, Some(1));
assert_eq!(b.batch_id, Some(42));
// batch_index is 0-based; humans count from one.
assert_eq!(b.part_label().as_deref(), Some("Part 2 of 3"));
// A single-QR export shouldn't claim to be part of a series.
let solo = parse_migration(&uri_for(&[Entry::default()])).unwrap();
assert!(solo.part_label().is_none());
}
#[test]
fn honours_algorithm_and_digit_variants() {
for (code, want) in [(2, Algorithm::Sha256), (3, Algorithm::Sha512)] {
let e = Entry {
algorithm: code,
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.algorithm, want);
}
let e = Entry {
digits: 2,
..Entry::default()
}; // EIGHT
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.digits, 8);
}
#[test]
fn unspecified_enums_fall_back_to_the_common_defaults() {
let e = Entry {
algorithm: 0,
digits: 0,
otp_type: 0,
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.algorithm, Algorithm::Sha1);
assert_eq!(b.accounts[0].totp.digits, 6);
}
#[test]
fn skips_unsupported_entries_but_keeps_the_rest() {
let entries = vec![
Entry {
name: "[email protected]",
..Entry::default()
},
Entry {
name: "[email protected]",
otp_type: 1,
..Entry::default()
}, // HOTP
Entry {
name: "[email protected]",
algorithm: 4,
..Entry::default()
}, // MD5
Entry {
name: "[email protected]",
secret: vec![],
..Entry::default()
},
];
let b = parse_migration(&uri_for(&entries)).unwrap();
// One good account survives; the other three are reported, not dropped.
assert_eq!(b.accounts.len(), 1);
assert_eq!(b.accounts[0].label, "[email protected]");
assert_eq!(b.skipped.len(), 3);
let reason = |n: &str| {
b.skipped
.iter()
.find(|s| s.name == n)
.map(|s| s.reason.clone())
.unwrap()
};
assert!(reason("[email protected]").contains("HOTP"));
assert!(reason("[email protected]").contains("MD5"));
assert!(reason("[email protected]").contains("no secret"));
}
#[test]
fn name_may_carry_the_issuer_prefix() {
let e = Entry {
name: "Cloudflare:[email protected]",
issuer: "",
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].issuer, "Cloudflare");
assert_eq!(b.accounts[0].label, "[email protected]");
}
#[test]
fn issuer_field_wins_over_the_name_prefix() {
let e = Entry {
name: "Stale:[email protected]",
issuer: "Microsoft",
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].issuer, "Microsoft");
assert_eq!(b.accounts[0].label, "[email protected]");
}
#[test]
fn accepts_every_base64_variant() {
use base64::engine::general_purpose::{
STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD,
};
use base64::Engine;
// A payload whose base64 is guaranteed to contain +/ (or -_).
let entries: Vec<Entry> = (0..4)
.map(|i| Entry {
secret: vec![0xfb, 0xff, 0xbf, i],
..Entry::default()
})
.collect();
let raw = encode_payload(&entries, None);
for (name, encoded) in [
("standard", STANDARD.encode(&raw)),
("standard-nopad", STANDARD_NO_PAD.encode(&raw)),
("urlsafe", URL_SAFE.encode(&raw)),
("urlsafe-nopad", URL_SAFE_NO_PAD.encode(&raw)),
] {
let escaped = encoded
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
let uri = format!("otpauth-migration://offline?data={escaped}");
let b = parse_migration(&uri).unwrap_or_else(|e| panic!("{name} should decode: {e}"));
assert_eq!(b.accounts.len(), 4, "{name}");
}
}
#[test]
fn unknown_fields_do_not_break_parsing() {
// Forward compatibility: if Google adds a field, we must skip it
// rather than desynchronise and mangle everything after it.
let mut entry = encode_entry(&Entry::default());
put_bytes(99, b"some future string", &mut entry);
put_uint(98, 1234, &mut entry);
let mut payload = Vec::new();
put_bytes(1, &entry, &mut payload);
put_bytes(77, b"future payload field", &mut payload);
put_uint(76, 5, &mut payload);
let b = parse_migration(&make_uri(&payload)).unwrap();
assert_eq!(b.accounts.len(), 1);
assert_eq!(b.accounts[0].issuer, "Cloudflare");
}
#[test]
fn rejects_malformed_input_clearly() {
assert!(parse_migration("otpauth-migration://offline").is_err());
assert!(parse_migration("otpauth-migration://offline?data=").is_err());
assert!(parse_migration("otpauth-migration://offline?data=!!!not base64!!!").is_err());
assert!(parse_migration("otpauth://totp/X?secret=JBSWY3DPEHPK3PXP").is_err());
assert!(parse_migration("nonsense").is_err());
// Truncated protobuf: a length prefix that runs past the buffer.
let uri = make_uri(&[0x0a, 0x7f, 0x01, 0x02]);
assert!(parse_migration(&uri).is_err());
}
#[test]
fn an_export_with_no_accounts_is_an_error() {
// Version field only, no entries.
let mut payload = Vec::new();
put_uint(2, 1, &mut payload);
let e = parse_migration(&make_uri(&payload))
.unwrap_err()
.to_string();
assert!(e.contains("no accounts"), "got: {e}");
}
#[test]
fn a_large_export_round_trips() {
// Google splits at roughly ten per QR; make sure nothing chokes on a
// full batch, and that every secret survives intact.
let entries: Vec<Entry> = (0..10)
.map(|i| Entry {
secret: vec![i; 20],
name: "[email protected]",
..Entry::default()
})
.collect();
let b = parse_migration(&uri_for(&entries)).unwrap();
assert_eq!(b.accounts.len(), 10);
for (i, a) in b.accounts.iter().enumerate() {
assert_eq!(
a.totp.secret,
vec![i as u8; 20],
"entry {i} secret corrupted"
);
}
}
}
+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());
}
}
+5 -3
View File
@@ -178,10 +178,12 @@ pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
// Callers should route these to `crate::parse_import`, which
// understands them. Reaching here means a single-URI code path
// was handed a multi-account link.
return Err(Error::invalid(
"this is a Google Authenticator export link, which contains several \
accounts at once. Export them individually, or add each account's \
own QR code.",
"this is a Google Authenticator export link — use the import flow, \
which reads all of its accounts at once.",
));
}
return Err(Error::invalid("expected a link starting with otpauth://"));
+1073 -28
View File
File diff suppressed because it is too large. Load diff
+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();
+1
View File
@@ -29,3 +29,4 @@ workspace = true
[dev-dependencies]
qrcode = { version = "0.14", default-features = false, features = ["image"] }
base64.workspace = true
@@ -0,0 +1,83 @@
//! Emit a Google Authenticator export QR for manual testing:
//! `cargo run -p da-http-api --example make-google-export -- out.png 8`
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_varint(u64::from(field) << 3, out);
put_varint(v, out);
}
fn main() {
let mut a = std::env::args().skip(1);
let out = a
.next()
.expect("usage: make-google-export <out.png> [count]");
let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap();
let names = [
"Cloudflare",
"Plex",
"Ubisoft",
"GitHub",
"Steam",
"Proton",
"Fastmail",
"Namecheap",
"Vercel",
"Backblaze",
];
let mut payload = Vec::new();
for i in 0..n {
let mut e = Vec::new();
put_bytes(1, &[0x41 + i as u8; 20], &mut e);
put_bytes(2, b"[email protected]", &mut e);
put_bytes(3, names[i % names.len()].as_bytes(), &mut e);
put_uint(4, 1, &mut e);
// Give one entry 8 digits so the UI's variant chips get exercised.
put_uint(5, if i == 2 { 2 } else { 1 }, &mut e);
// And make one HOTP so the "skipped" path shows up.
put_uint(6, if i == 4 { 1 } else { 2 }, &mut e);
put_bytes(1, &e, &mut payload);
}
put_uint(2, 1, &mut payload);
put_uint(3, 2, &mut payload); // batch_size
put_uint(4, 0, &mut payload); // batch_index
put_uint(5, 77, &mut payload);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
let uri = format!("otpauth-migration://offline?data={escaped}");
let code = qrcode::QrCode::new(uri.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(6, 6)
.quiet_zone(true)
.build();
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
println!(
"wrote {out} ({n} entries, {} bytes of payload)",
payload.len()
);
}
+111 -40
View File
@@ -24,8 +24,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/login", post(auth::api_login))
.route("/api/logout", post(auth::api_logout))
.route("/api/credentials", post(auth::api_update_credentials))
.route("/api/accounts", get(list_accounts).post(add_account))
.route("/api/accounts", get(list_accounts))
.route("/api/accounts/preview", post(preview_account))
.route("/api/accounts/import", post(import_accounts))
.route(
"/api/accounts/{id}",
axum::routing::patch(update_account).delete(delete_account),
@@ -114,27 +115,59 @@ struct PreviewBody {
uri: String,
}
/// Parse an `otpauth://` URI and report what it contains, without storing it.
/// Describe what a link contains, without storing anything.
///
/// Lets the "add account" flow show the issuer, algorithm and digits for
/// confirmation before anything touches the vault.
async fn preview_account(Json(body): Json<PreviewBody>) -> Response {
match da_core::parse_otpauth(&body.uri) {
Ok(a) => Json(json!({
"issuer": a.issuer,
"label": a.label,
"algorithm": a.totp.algorithm.as_str(),
"digits": a.totp.digits,
"period": a.totp.period,
}))
.into_response(),
Err(e) => crate::error_response(&e),
}
/// Handles both a plain `otpauth://` URI (one account) and a Google
/// Authenticator `otpauth-migration://` export (many), so the UI has a single
/// code path. Each entry is flagged if it is already in the vault, letting the
/// import screen pre-uncheck it rather than failing later.
async fn preview_account(State(state): State<AppState>, Json(body): Json<PreviewBody>) -> Response {
let batch = match da_core::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
};
let accounts: Vec<_> = batch
.accounts
.iter()
.enumerate()
.map(|(index, a)| {
json!({
"index": index,
"issuer": a.issuer,
"label": a.label,
"algorithm": a.totp.algorithm.as_str(),
"digits": a.totp.digits,
"period": a.totp.period,
"duplicate": state.vault.contains(&a.totp, &a.issuer, &a.label),
})
})
.collect();
let skipped: Vec<_> = batch
.skipped
.iter()
.map(|s| json!({ "name": s.name, "reason": s.reason }))
.collect();
(
// Names and parameters of the user's accounts — never cache.
[(header::CACHE_CONTROL, "no-store")],
Json(json!({
"accounts": accounts,
"skipped": skipped,
"part": batch.part_label(),
"batch_id": batch.batch_id,
"is_export": da_core::is_migration_uri(&body.uri),
})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct AddBody {
uri: String,
struct ImportItem {
/// Index into the preview's `accounts` array.
index: usize,
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
@@ -143,25 +176,44 @@ struct AddBody {
push_enabled: bool,
}
fn default_true() -> bool {
true
#[derive(Debug, Deserialize)]
struct ImportBody {
uri: String,
items: Vec<ImportItem>,
}
async fn add_account(State(state): State<AppState>, Json(body): Json<AddBody>) -> Response {
let parsed = match da_core::parse_otpauth(&body.uri) {
Ok(p) => p,
/// Import the selected accounts from a link.
///
/// The client sends back the original link plus the indices it wants, and the
/// server re-parses it. That keeps secrets from ever making the round trip
/// out to the browser and back, which they would if the preview had returned
/// them.
async fn import_accounts(State(state): State<AppState>, Json(body): Json<ImportBody>) -> Response {
let batch = match da_core::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
};
match state.vault.add(
&parsed,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
// The push loop picks the new account up on its next tick and posts
// the tile; we deliberately don't kick a sync from here, so there is
// only ever one pass in flight.
Ok(a) => (StatusCode::CREATED, Json(a)).into_response(),
let mut queued = Vec::with_capacity(body.items.len());
for item in &body.items {
let Some(parsed) = batch.accounts.get(item.index) else {
return bad_request(&format!(
"selection {} is out of range for this link — reload and try again",
item.index
));
};
queued.push(da_core::NewAccount {
parsed: parsed.clone(),
issuer: item.issuer.clone(),
label: item.label.clone(),
push_enabled: item.push_enabled,
});
}
match state.vault.add_many(&queued) {
// The push loop picks new accounts up on its next tick; we don't kick
// a sync from here, so only one pass is ever in flight.
Ok(outcome) => (StatusCode::CREATED, Json(outcome)).into_response(),
Err(e) => crate::error_response(&e),
}
}
@@ -207,6 +259,10 @@ async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -
}
}
fn default_true() -> bool {
true
}
// ── Codes ─────────────────────────────────────────────────────────────────
/// Current and next code for every account, plus the server clock.
@@ -275,14 +331,29 @@ async fn decode_qr(mut multipart: Multipart) -> Response {
match crate::qr::decode(&bytes) {
Ok(found) => {
// An image can hold several codes; prefer an otpauth one over
// whatever else happens to be in frame (a Wi-Fi QR, a URL).
let best = found
// An image can hold several codes — a Google export screenshot
// legitimately does. Return them all, OTP links first, so the
// caller can import every account in one pass instead of only
// whichever happened to be detected first.
let mut uris: Vec<String> = found
.iter()
.find(|s| s.to_ascii_lowercase().starts_with("otpauth"))
.or_else(|| found.first());
match best {
Some(uri) => Json(json!({ "uri": uri })).into_response(),
.filter(|s| {
let l = s.to_ascii_lowercase();
l.starts_with("otpauth://") || l.starts_with("otpauth-migration://")
})
.cloned()
.collect();
if uris.is_empty() {
// Nothing OTP-shaped; hand back whatever was there so the UI
// can say what it actually found.
uris = found;
}
match uris.first() {
Some(first) => (
[(header::CACHE_CONTROL, "no-store")],
Json(json!({ "uri": first, "uris": uris })),
)
.into_response(),
None => bad_request("no QR code found in that image"),
}
}
+98
View File
@@ -282,6 +282,104 @@ mod tests {
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
}
/// Build a Google Authenticator export QR the way the real app emits
/// one, so the upload path is exercised end to end: image bytes -> QR
/// decode -> protobuf parse -> accounts.
fn google_export_png(n: usize, module_px: u32) -> Vec<u8> {
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_varint(u64::from(field) << 3, out);
put_varint(v, out);
}
let mut payload = Vec::new();
for i in 0..n {
let mut e = Vec::new();
put_bytes(1, &[0x41 + i as u8; 20], &mut e); // secret
put_bytes(2, format!("user{i}@example.com").as_bytes(), &mut e);
put_bytes(3, format!("Service{i}").as_bytes(), &mut e);
put_uint(4, 1, &mut e); // SHA1
put_uint(5, 1, &mut e); // 6 digits
put_uint(6, 2, &mut e); // TOTP
put_bytes(1, &e, &mut payload);
}
put_uint(2, 1, &mut payload);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
qr_png(
&format!("otpauth-migration://offline?data={escaped}"),
module_px,
)
}
#[test]
fn decodes_a_google_export_qr_end_to_end() {
let png = google_export_png(5, 4);
let found = decode(&png).unwrap();
assert_eq!(found.len(), 1, "one QR, one payload");
assert!(found[0].starts_with("otpauth-migration://"));
// And that payload really does carry five importable accounts.
let batch = da_core::parse_import(&found[0]).unwrap();
assert_eq!(batch.accounts.len(), 5);
assert_eq!(batch.accounts[0].issuer, "Service0");
assert_eq!(batch.accounts[4].label, "[email protected]");
}
#[test]
fn decodes_several_qr_codes_from_one_screenshot() {
// A screenshot of a multi-part export can contain more than one code.
// Stacking two here proves `decode` returns both rather than the
// first, which is what lets the UI import every account in one pass.
let a = image::load_from_memory(&qr_png(URI, 5)).unwrap().to_luma8();
let b = image::load_from_memory(&google_export_png(2, 5))
.unwrap()
.to_luma8();
let w = a.width().max(b.width());
let mut canvas =
GrayImage::from_pixel(w + 40, a.height() + b.height() + 60, image::Luma([255]));
image::imageops::replace(&mut canvas, &a, 20, 20);
image::imageops::replace(&mut canvas, &b, 20, i64::from(a.height()) + 40);
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(canvas)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let found = decode(&buf.into_inner()).unwrap();
assert_eq!(
found.len(),
2,
"both codes should be returned, got {found:?}"
);
assert!(found.iter().any(|u| u.starts_with("otpauth://")));
assert!(found.iter().any(|u| u.starts_with("otpauth-migration://")));
}
#[test]
fn empty_input_is_rejected() {
assert!(decode(&[]).is_err());
+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())?;
+81 -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;
@@ -607,6 +632,59 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
0 0 0 9999px oklch(0% 0 0 / .45);
}
/* ── Import list (Google export / multi-account) ───────────────────── */
.import-head {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; margin: 14px 0 8px;
}
.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
/* Capped so a 20-account export scrolls inside the modal rather than
pushing the Import button off the bottom of the screen. */
.import-list {
max-height: 320px;
overflow-y: auto;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px var(--line);
background: var(--inset);
}
.import-row {
display: grid;
grid-template-columns: auto 30px 1fr auto;
align-items: center;
gap: 11px;
padding: 9px 12px;
cursor: pointer;
transition: background .12s var(--ease);
}
.import-row + .import-row { border-top: 1px solid var(--line-soft); }
.import-row:hover { background: var(--hover); }
.import-row input[type="checkbox"] {
width: 16px; height: 16px; margin: 0;
accent-color: var(--accent);
cursor: pointer;
flex: none;
}
.import-row .avatar {
width: 30px; height: 30px; border-radius: 8px; font-size: 13px;
}
.import-row .meta { min-width: 0; }
.import-row .issuer { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .label { font-size: 11.5px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.import-row .tags .chip { height: 19px; padding: 0 7px; font-size: 10.5px; }
/* An account already in the vault is de-emphasised but still selectable. */
.import-row:has(input:not(:checked)) .meta { opacity: .55; }
/* ── Toast ────────────────────────────────────────────────────────────── */
.toasts {
+230 -45
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;
@@ -697,7 +702,7 @@ function openAddSheet() {
]));
try {
const res = await api('/api/qr/decode', { method: 'POST', body: fd });
confirmPane(res.uri);
previewUris(res.uris && res.uris.length ? res.uris : [res.uri]);
} catch (e) {
panel.replaceChildren(
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
@@ -777,7 +782,7 @@ function openAddSheet() {
} catch (_) { /* transient decode error — keep going */ }
timer = setTimeout(scan, detector ? 180 : 650);
};
const done = (uri) => { stop(); confirmPane(uri); };
const done = (uri) => { stop(); previewUris([uri]); };
scan();
})();
@@ -809,13 +814,13 @@ function openAddSheet() {
onclick: () => {
err.classList.add('hidden');
const uri = ta.value.trim();
if (uri) return confirmPane(uri);
if (uri) return previewUris([uri]);
if (mSecret.value.trim()) {
const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') });
if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim());
const path = encodeURIComponent(
(mIssuer.value.trim() ? mIssuer.value.trim() + ':' : '') + (mLabel.value.trim() || 'account'));
return confirmPane(`otpauth://totp/${path}?${q}`);
return previewUris([`otpauth://totp/${path}?${q}`]);
}
err.replaceChildren(icon(ICON.alert), h('span', { text: 'Paste an otpauth:// link or fill in the manual fields.' }));
err.classList.remove('hidden');
@@ -824,36 +829,105 @@ function openAddSheet() {
return h('div', {}, [err, h('div.field', {}, [h('label', { text: 'otpauth:// link' }), ta]), manual, go]);
}
/** Preview what we parsed, let the user rename, then commit. */
async function confirmPane(uri) {
/** Fetch a preview for every link found, then show the import list. */
async function previewUris(uris) {
panel.replaceChildren(h('div.dropzone', {}, [
icon(ICON.search, { width: 1.7, className: 'spin' }),
h('strong', { text: 'Checking…' }),
h('strong', { text: 'Reading…' }),
]));
let parsed;
try {
parsed = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
} catch (e) {
const sources = [];
const errors = [];
for (const uri of uris) {
try {
const res = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
sources.push(Object.assign({ uri: uri }, res));
} catch (e) { errors.push(e.message); }
}
if (!sources.length) {
panel.replaceChildren(
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
h('div.alert.err', {}, [
icon(ICON.alert),
h('span', { text: errors[0] || 'Nothing importable was found in that.' }),
]),
pastePane(),
);
return;
}
const issuer = h('input.input', { value: parsed.issuer || '' });
const label = h('input.input', { value: parsed.label || '' });
renderImport(sources, errors);
}
/** One account gets the detail form; several get a checklist. */
function renderImport(sources, errors) {
const rows = [];
for (const src of sources) {
for (const acct of src.accounts) rows.push({ src: src, acct: acct });
}
const skipped = sources.reduce((all, s) => all.concat(s.skipped || []), []);
if (!rows.length) {
panel.replaceChildren(
h('div.alert.warn', {}, [
icon(ICON.alert),
h('span', { text: 'No importable accounts in that link.' }),
]),
skipped.length ? skippedList(skipped) : null,
pastePane(),
);
return;
}
if (rows.length === 1) return singleForm(rows[0], skipped);
return multiForm(rows, sources, skipped, errors);
}
function skippedList(skipped) {
return h('div', { style: 'margin-top:14px' }, [
h('div.small.muted', {
style: 'margin-bottom:6px',
text: skipped.length + ' entr' + (skipped.length === 1 ? 'y was' : 'ies were') + ' skipped:',
}),
h('div.stack-s', {}, skipped.map((s) =>
h('div.small.muted', { text: '• ' + s.name + ' — ' + s.reason }))),
]);
}
/** Google splits large exports across several QR codes; say so. */
function partNotice(sources) {
const part = sources.map((s) => s.part).find(Boolean);
if (!part) return null;
return h('div.alert.info', {}, [
icon(ICON.info),
h('span', { text: part + ' of a Google Authenticator export. Import this one, then upload the remaining QR codes to add the rest.' }),
]);
}
/** The familiar single-account confirm screen. */
function singleForm(row, skipped) {
const src = row.src, acct = row.acct;
const issuer = h('input.input', { value: acct.issuer || '' });
const label = h('input.input', { value: acct.label || '' });
const push = h('input', { type: 'checkbox', checked: true });
const save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' });
save.addEventListener('click', async () => {
save.disabled = true; save.textContent = 'Adding…';
try {
await api('/api/accounts', {
const out = await api('/api/accounts/import', {
method: 'POST',
body: { uri, issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
body: {
uri: src.uri,
items: [{
index: acct.index,
issuer: issuer.value.trim(),
label: label.value.trim(),
push_enabled: push.checked,
}],
},
});
dlg.close();
toast('Account added');
toast(out.added.length ? 'Account added' : 'That account is already in your vault',
out.added.length ? 'ok' : 'err');
await reloadAll();
} catch (e) {
save.disabled = false; save.textContent = 'Add account';
@@ -862,21 +936,123 @@ function openAddSheet() {
});
panel.replaceChildren(h('div', {}, [
h('div.alert.info', {}, [icon(ICON.check), h('span', { text: 'Found a valid one-time-password secret.' })]),
acct.duplicate
? h('div.alert.warn', {}, [icon(ICON.alert),
h('span', { text: 'This account is already in your vault. Adding it again creates a second copy.' })])
: h('div.alert.info', {}, [icon(ICON.check),
h('span', { text: 'Found a valid one-time-password secret.' })]),
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
h('div.field', {}, [h('label', { text: 'Account' }), label]),
h('dl', {}, [
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: parsed.algorithm })]),
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(parsed.digits) })]),
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: parsed.period + 's' })]),
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: acct.algorithm })]),
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(acct.digits) })]),
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: acct.period + 's' })]),
]),
h('label.switch', { style: 'margin:16px 0' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream this code to Discord' }),
]),
save,
skipped.length ? skippedList(skipped) : null,
]));
}
/** Multi-account import — where a Google export lands. */
function multiForm(rows, sources, skipped, errors) {
// Anything already in the vault starts unticked, so re-scanning an export
// you partly imported doesn't quietly create duplicates.
const boxes = rows.map((r) => h('input', { type: 'checkbox', checked: !r.acct.duplicate }));
const push = h('input', { type: 'checkbox', checked: true });
const go = h('button.btn.btn-primary.btn-block');
const selected = () => boxes.reduce((n, b) => n + (b.checked ? 1 : 0), 0);
const sync = () => {
const n = selected();
go.textContent = n ? 'Add ' + n + ' account' + (n === 1 ? '' : 's') : 'Select accounts to add';
go.disabled = n === 0;
};
boxes.forEach((b) => b.addEventListener('change', sync));
const list = h('div.import-list', {}, rows.map((r, i) => {
const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) });
av.setAttribute('style', avatarStyle(r.acct.issuer || r.acct.label || String(i)));
return h('label.import-row', {}, [
boxes[i],
av,
h('div.meta', {}, [
h('div.issuer', { text: r.acct.issuer || 'Unnamed' }),
h('div.label', { text: r.acct.label || '—' }),
]),
h('div.tags', {}, [
r.acct.duplicate ? h('span.chip.warn', { text: 'already added' }) : null,
r.acct.digits !== 6 ? h('span.chip', { text: r.acct.digits + ' digits' }) : null,
r.acct.algorithm !== 'SHA1' ? h('span.chip', { text: r.acct.algorithm }) : null,
r.acct.period !== 30 ? h('span.chip', { text: r.acct.period + 's' }) : null,
]),
]);
}));
const setAll = (v) => { boxes.forEach((b) => { b.checked = v; }); sync(); };
go.addEventListener('click', async () => {
go.disabled = true;
const original = go.textContent;
go.textContent = 'Adding…';
// Group by source link: each import call re-parses exactly one link
// server-side, so secrets never travel back out to the browser.
const bySource = new Map();
rows.forEach((r, i) => {
if (!boxes[i].checked) return;
if (!bySource.has(r.src.uri)) bySource.set(r.src.uri, []);
bySource.get(r.src.uri).push({ index: r.acct.index, push_enabled: push.checked });
});
let added = 0, dupes = 0;
try {
for (const entry of bySource) {
const out = await api('/api/accounts/import', {
method: 'POST', body: { uri: entry[0], items: entry[1] },
});
added += out.added.length;
dupes += out.duplicates.length;
}
dlg.close();
toast('Added ' + added + ' account' + (added === 1 ? '' : 's') +
(dupes ? ' · ' + dupes + ' already present' : ''));
await reloadAll();
} catch (e) {
go.disabled = false; go.textContent = original;
toast(e.message, 'err');
}
});
const dupeCount = rows.filter((r) => r.acct.duplicate).length;
panel.replaceChildren(h('div', {}, [
partNotice(sources),
h('div.alert.info', {}, [icon(ICON.check),
h('span', { text: 'Found ' + rows.length + ' accounts.' +
(dupeCount ? ' ' + dupeCount + ' already in your vault ' + (dupeCount === 1 ? 'is' : 'are') + ' unticked.' : '') })]),
errors && errors.length
? h('div.alert.warn', {}, [icon(ICON.alert),
h('span', { text: errors.length + ' code in that image could not be read.' })])
: null,
h('div.import-head', {}, [
h('span.small.muted', { text: 'Select accounts to import' }),
h('div', {}, [
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(true), text: 'All' }),
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(false), text: 'None' }),
]),
]),
list,
h('label.switch', { style: 'margin:16px 0' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream these codes to Discord' }),
]),
go,
skipped.length ? skippedList(skipped) : null,
]));
sync();
}
}
// ── Per-account sheet ──────────────────────────────────────────────────────
@@ -886,15 +1062,21 @@ function openAccountSheet(a) {
const label = h('input.input', { value: a.label || '' });
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
const dlg = openModal(a.issuer || 'Account', [
const dlg = openModal('Edit account', [
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
h('div.field', {}, [h('label', { text: 'Account' }), label]),
h('div.field', {}, [
h('label', { text: 'Name' }), issuer,
h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }),
]),
h('div.field', {}, [
h('label', { text: 'Account' }), label,
h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }),
]),
h('label.switch', { style: 'margin:16px 0 4px' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream this code to Discord' }),
]),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }),
], [
h('button.btn.btn-danger', { onclick: async () => {
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
@@ -904,14 +1086,17 @@ function openAccountSheet(a) {
} catch (e) { toast(e.message, 'err'); }
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
h('div', { style: 'flex:1' }),
h('button.btn', { onclick: dlg.close, text: 'Cancel' }),
// Deferred: `dlg` is still in its temporal dead zone while these
// arguments are being built, so `onclick: dlg.close` throws and the whole
// sheet fails to open. Every other handler here is already a closure.
h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }),
h('button.btn.btn-primary', { onclick: async () => {
try {
await api('/api/accounts/' + encodeURIComponent(a.id), {
method: 'PATCH',
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
});
dlg.close(); toast('Saved'); await reloadAll();
dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll();
} catch (e) { toast(e.message, 'err'); }
}, text: 'Save' }),
]);
+6 -3
View File
@@ -24,8 +24,11 @@
Self-hosted TOTP authenticator with live Discord delivery.
Add accounts by uploading a QR code, scanning with the camera, or pasting
an otpauth:// link. The web UI lists every account with its rotating code,
the way Google or Microsoft Authenticator does.
an otpauth:// link. Google Authenticator exports are supported too: one
export QR carries many accounts, and you get a checklist to pick which to
import, with duplicates detected automatically. The web UI lists every
account with its rotating code, the way Google or Microsoft Authenticator
does.
The distinctive part: each account keeps ONE message in your Discord
channel, delivered through an incoming webhook and rewritten in place
@@ -42,7 +45,7 @@
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+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.
+52
View File
@@ -0,0 +1,52 @@
## An accurate message, and a way out
No functional change to how the vault is written or recovered. This fixes what
the service tells you when recovery is not possible, and adds an escape hatch
for the case it is not.
### The message said the wrong thing
When neither `vault.json` nor `vault.json.bak` could be loaded, the error read:
> ...and the backup at /var/lib/discord-authenticator/vault.json.bak could not
> be used either.
That is true for three quite different situations, and it reads as though a
backup exists. The case that actually bites is the one where **no backup was
ever created**: a vault that has not completed a single write since v0.1.3
introduced backups has no `.bak` at all, so there is nothing on disk to recover
and no file to go looking for. The old wording sent you looking anyway.
It now says which situation you are in:
- `there is no backup at … — this vault has not been written since backups were
introduced, so none was ever created`
- `the backup at … exists but could not be parsed either`
- `the backup at … is empty as well, so there is nothing to recover`
It also lists any `vault.json.corrupt-<timestamp>` files left by earlier
recoveries, since those are the only other thing worth inspecting.
### DA_VAULT_RESET
Refusing to start on an unreadable vault is still the default — booting empty
would look identical to a vault you had emptied on purpose, and the whole point
is that the service cannot tell the difference. But refusing must not be a dead
end, and having to edit files by hand inside a container that will not start is
a poor answer at 2am.
Set `DA_VAULT_RESET=1` and the service boots with an empty vault instead. It
moves the unreadable file to `vault.json.corrupt-<timestamp>` *before* writing
the fresh one, so nothing is overwritten, and the log line names where it went.
Remove the variable afterwards: it has no effect on a vault that loads, but
leaving it set means the next genuinely-broken vault boots empty rather than
telling you about it.
In the Unraid template: Docker → the container → **Add another Path, Variable,
Port or Label** → Variable, Key `DA_VAULT_RESET`, Value `1`.
### Upgrading
Nothing to do. Vault format unchanged, no accounts to re-import. If you are
currently stuck on the old message, this release does not recover the accounts
either — it just tells you why, and lets you start clean without leaving the UI.
+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