Files
Discord-Authenticator/deploy/docker/Dockerfile
T
Miles WardandClaude Opus 4.8 f33effe20a v0.1: self-hosted TOTP authenticator with live Discord delivery
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]>
2026-08-28 16:06:08 -04:00

82 lines
3.4 KiB
Docker
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"]