Rust workspace serving a Google/Microsoft-Authenticator-style web UI, with
each account's rotating code mirrored into Discord as a message that
rewrites itself in place as the code rolls.
The live mechanism is webhook message editing: POST ?wait=true returns the
message id, PATCH .../messages/{id} rewrites it thereafter. Two approaches
were evaluated and rejected — an externally-hosted image URL is cached by
Discord's media proxy and, worse, must be anonymously reachable, which would
publish live codes on an unauthenticated endpoint; and an animated countdown
drifts out of phase because playback starts at decode time, not on a wall
clock. The countdown instead uses Discord's <t:unix:R> markup, which the
client re-renders every second for free.
Codes are also sent as text in a fenced block so they stay copyable, with
the grouped, readable form on a rendered WebP tile.
- core: RFC 6238 TOTP over HMAC, lenient otpauth:// parsing (issuer conflicts
and non-canonical base32 both occur in the wild), XChaCha20-Poly1305 vault,
Argon2id admin auth
- discord: webhook client with a serialising rate limiter (~2 req/s) so
globally-aligned rollovers don't burst, and error classification that
distinguishes a deleted message (repost) from a dead webhook (stop)
- render: SVG -> WebP tiles via resvg with embedded JetBrains Mono
- http-api: axum, session auth, QR decode with a rescale/contrast/rotation
retry ladder
- webui: offline vanilla JS, Beautiful UI design language, light and dark
Ships as a 27 MB Alpine image (about 8 MB to pull); the binary is fully static musl with no
OpenSSL and no C dependencies, cross-compiled via cargo-zigbuild so building
linux/amd64 on Apple Silicon needs no QEMU.
101 tests, including the RFC 6238 vectors, QR round-trips, and the live push
cycle exercised against a mock Discord.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
82 lines
3.4 KiB
Docker
82 lines
3.4 KiB
Docker
# syntax=docker/dockerfile:1.7
|
||
#
|
||
# Discord Authenticator — multi-stage build.
|
||
#
|
||
# build : cross-compiles to x86_64-unknown-linux-musl with cargo-zigbuild.
|
||
# runtime : Alpine + the static binary. ~15 MB total.
|
||
#
|
||
# ## Why cross-compile instead of building for the target platform
|
||
#
|
||
# The obvious `FROM --platform=linux/amd64 rust` runs the entire compiler
|
||
# under QEMU when the builder is an Apple-Silicon Mac. That is roughly 15×
|
||
# slower, and the emulated linker intermittently SIGSEGVs part-way through.
|
||
#
|
||
# Pinning this stage to $BUILDPLATFORM instead runs rustc natively at full
|
||
# speed and emits x86_64 via cargo-zigbuild, which uses `zig cc` as the
|
||
# cross-linker — zig ships a musl sysroot for every target, so there is no
|
||
# cross-gcc toolchain to assemble. Deterministic, and minutes rather than
|
||
# half an hour.
|
||
|
||
ARG RUST_VERSION=1.95
|
||
|
||
########## build ##########
|
||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
|
||
WORKDIR /src
|
||
|
||
# zig (via the `ziglang` pip package — cargo-zigbuild discovers it as
|
||
# `python3 -m ziglang`) supplies the musl sysroot and the linker.
|
||
RUN apt-get update \
|
||
&& apt-get install -y --no-install-recommends python3 python3-pip \
|
||
&& rm -rf /var/lib/apt/lists/* \
|
||
&& rustup target add x86_64-unknown-linux-musl \
|
||
&& pip3 install --no-cache-dir --break-system-packages ziglang \
|
||
&& cargo install --locked cargo-zigbuild
|
||
|
||
# Deliberately NOT copying rust-toolchain.toml: it pins `channel = "stable"`
|
||
# for local development, and inside the image that makes rustup download a
|
||
# second full toolchain — slow, and enough to fill a small Colima disk.
|
||
COPY Cargo.toml Cargo.lock ./
|
||
COPY crates/ crates/
|
||
|
||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||
--mount=type=cache,target=/src/target,sharing=locked \
|
||
cargo zigbuild --release --target x86_64-unknown-linux-musl \
|
||
--bin discord-authenticator \
|
||
&& cp target/x86_64-unknown-linux-musl/release/discord-authenticator /discord-authenticator \
|
||
&& ls -l /discord-authenticator
|
||
|
||
########## runtime ##########
|
||
# Alpine, not Debian: the binary is fully static musl and shells out to
|
||
# nothing, and its TLS roots are compiled in via webpki-roots — so the image
|
||
# needs no ca-certificates package and no runtime libraries at all. What it
|
||
# does need is a shell, for the bind-mount chown in the entrypoint (see there
|
||
# for why that matters on Unraid).
|
||
FROM alpine:3.21 AS runtime
|
||
|
||
RUN apk add --no-cache tini su-exec \
|
||
&& addgroup -g 10001 -S da \
|
||
&& adduser -u 10001 -S -G da -h /var/lib/discord-authenticator da \
|
||
&& mkdir -p /var/lib/discord-authenticator \
|
||
&& chown -R da:da /var/lib/discord-authenticator
|
||
|
||
COPY --from=build /discord-authenticator /usr/local/bin/discord-authenticator
|
||
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/discord-authenticator
|
||
|
||
# NOT `USER da` — the entrypoint starts as root to fix bind-mount ownership,
|
||
# then drops to uid 10001 via su-exec. Its non-root branch handles the case
|
||
# where the platform has already assigned a uid.
|
||
WORKDIR /var/lib/discord-authenticator
|
||
|
||
ENV DA_WORK_DIR=/var/lib/discord-authenticator \
|
||
DA_HTTP_ADDR=0.0.0.0:8710 \
|
||
DA_LOG=info,da=info
|
||
|
||
EXPOSE 8710/tcp
|
||
VOLUME ["/var/lib/discord-authenticator"]
|
||
|
||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||
CMD wget -qO- http://127.0.0.1:8710/healthz || exit 1
|
||
|
||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|