diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a25d5e5..0754e2c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,7 +10,11 @@ on: jobs: build: - uses: daemonless/dbuild/.github/workflows/daemonless-build.yaml@main + # Trial of dbuild#feat/linux-images: back to @main (and no dbuild_ref) + # once that is merged. + uses: daemonless/dbuild/.github/workflows/daemonless-build.yaml@feat/linux-images with: image_name: reverse-proxy + dbuild_ref: feat/linux-images + linux: true secrets: inherit diff --git a/Containerfile.linux b/Containerfile.linux new file mode 100644 index 0000000..9580772 --- /dev/null +++ b/Containerfile.linux @@ -0,0 +1,68 @@ +# reverse-proxy on Linux, built only by dbuild's shared GitHub workflow +# (build-linux job): the same Caddy with the same DNS modules, the same +# admin and UI, on Alpine with s6. Caddy runs as root to bind 80/443; the +# admin runs as bsd (PUID/PGID), as on FreeBSD. Keep APP_VER with +# Containerfile.j2's: the manifest adds this image to a tag only when the +# versions agree. +ARG APP_VER=0.2.0 +ARG CADDY_VERSION= + +FROM golang:1.26-alpine AS caddy-build +ARG CADDY_VERSION +RUN apk add --no-cache git ca-certificates curl && \ + go install github.com/caddyserver/xcaddy/cmd/xcaddy@v0.4.7 +RUN v="${CADDY_VERSION:-$(curl -fsSI https://github.com/caddyserver/caddy/releases/latest | grep -io 'releases/tag/v[^[:space:]]*' | head -1 | cut -d/ -f3)}" && \ + test -n "$v" && \ + CGO_ENABLED=0 GOFLAGS=-buildvcs=false xcaddy build "$v" \ + --with github.com/caddy-dns/cloudflare \ + --with github.com/caddy-dns/route53 \ + --with github.com/caddy-dns/desec \ + --with github.com/caddy-dns/porkbun \ + --with github.com/caddy-dns/hetzner \ + --with github.com/caddy-dns/ovh \ + --with github.com/caddy-dns/gandi \ + --with github.com/caddy-dns/rfc2136 \ + --with github.com/caddy-dns/acmedns \ + --with github.com/caddy-dns/duckdns@v0.5.0 \ + --output /out/caddy && \ + /out/caddy version + +FROM node:24-alpine AS ui-build +COPY admin/ui/package.json admin/ui/package-lock.json /src/ui/ +RUN cd /src/ui && npm ci --no-fund --no-audit +COPY admin/ui/ /src/ui/ +RUN cd /src/ui && npm run build + +FROM golang:1.26-alpine AS admin-build +ARG APP_VER +COPY admin/ /src/ +COPY --from=ui-build /src/ui/dist/ /src/ui/dist/ +RUN cd /src && CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=${APP_VER}" \ + -o /out/reverse-proxy ./cmd/reverse-proxy + +FROM alpine:3.24 +ARG APP_VER +LABEL org.opencontainers.image.title="Daemonless Reverse Proxy" \ + org.opencontainers.image.description="Friendly HTTPS names for the services on your network: a managed Caddy reverse proxy with a web UI, secure by default." \ + org.opencontainers.image.source="https://github.com/daemonless/reverse-proxy" \ + org.opencontainers.image.url="https://daemonless.io/images/reverse-proxy/" \ + org.opencontainers.image.licenses="BSD-2-Clause" \ + org.opencontainers.image.vendor="daemonless" \ + org.opencontainers.image.authors="daemonless" \ + io.daemonless.category="Network" \ + io.daemonless.port="8443" \ + io.daemonless.volumes="/config,/data" +RUN apk add --no-cache s6 ca-certificates tzdata curl && \ + addgroup -g 1000 bsd && adduser -D -H -u 1000 -G bsd -s /sbin/nologin bsd && \ + mkdir -p /app /config /data && echo "${APP_VER}" > /app/version +COPY --from=caddy-build /out/caddy /usr/local/bin/caddy +COPY --from=admin-build /out/reverse-proxy /usr/local/bin/reverse-proxy +COPY --from=ui-build /src/ui/dist/ /usr/local/share/reverse-proxy/ui/ +COPY root/etc/reverse-proxy/bootstrap.json /etc/reverse-proxy/bootstrap.json +COPY root-linux/ / +RUN chmod +x /init /healthz /usr/local/bin/s6-ready-when /etc/cont-init.d/* /etc/services.d/*/run +EXPOSE 80 443 8443 +VOLUME ["/config", "/data"] +HEALTHCHECK --interval=30s --timeout=10s CMD /healthz +ENTRYPOINT ["/init"] diff --git a/root-linux/etc/cont-init.d/10-users b/root-linux/etc/cont-init.d/10-users new file mode 100755 index 0000000..2503044 --- /dev/null +++ b/root-linux/etc/cont-init.d/10-users @@ -0,0 +1,7 @@ +#!/bin/sh +# PUID/PGID mapping for the admin, as the FreeBSD image does. +PUID="${PUID:-1000}"; PGID="${PGID:-1000}" +if [ "$(id -g bsd)" != "$PGID" ]; then delgroup bsd 2>/dev/null || true; addgroup -g "$PGID" bsd; fi +if [ "$(id -u bsd)" != "$PUID" ]; then deluser bsd 2>/dev/null || true; adduser -D -H -u "$PUID" -G bsd -s /sbin/nologin bsd; fi +echo "[usermod] User bsd configured (uid=$(id -u bsd), gid=$(id -g bsd))" +mkdir -p /config/logs && chown -R bsd:bsd /config diff --git a/root-linux/etc/cont-init.d/20-reverse-proxy-dirs b/root-linux/etc/cont-init.d/20-reverse-proxy-dirs new file mode 100755 index 0000000..bf6f77a --- /dev/null +++ b/root-linux/etc/cont-init.d/20-reverse-proxy-dirs @@ -0,0 +1,17 @@ +#!/bin/sh +# Socket directory: root-owned, group bsd. setgid so the sockets Caddy (root) +# makes get the group bsd: FreeBSD does that by default, Linux needs the bit. +# Nothing else runs here, so a socket left from an unclean stop is stale. +rm -rf /var/run/reverse-proxy +mkdir -p /var/run/reverse-proxy +chown root:bsd /var/run/reverse-proxy +chmod 2770 /var/run/reverse-proxy + +mkdir -p /config/secrets +chown bsd:bsd /config/secrets +chmod 0700 /config/secrets + +# Caddy (root) owns /data: certificates, ACME account, CA intermediate. +mkdir -p /data +chown root:root /data +chmod 0700 /data diff --git a/root-linux/etc/services.d/admin/notification-fd b/root-linux/etc/services.d/admin/notification-fd new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/root-linux/etc/services.d/admin/notification-fd @@ -0,0 +1 @@ +3 diff --git a/root-linux/etc/services.d/admin/run b/root-linux/etc/services.d/admin/run new file mode 100755 index 0000000..5ed07f9 --- /dev/null +++ b/root-linux/etc/services.d/admin/run @@ -0,0 +1,10 @@ +#!/bin/sh +# The admin: UI, API, config renderer. Runs as bsd (PUID/PGID) and drives +# Caddy over the unix sockets in /var/run/reverse-proxy. +export HOME=/config +s6-ready-when /healthz +exec s6-setuidgid bsd /usr/local/bin/reverse-proxy \ + -state /config \ + -caddy-socket /var/run/reverse-proxy/caddy.sock \ + -listen /var/run/reverse-proxy/admin.sock \ + -probe-socket /var/run/reverse-proxy/tls.sock 2>&1 diff --git a/root-linux/etc/services.d/caddy/notification-fd b/root-linux/etc/services.d/caddy/notification-fd new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/root-linux/etc/services.d/caddy/notification-fd @@ -0,0 +1 @@ +3 diff --git a/root-linux/etc/services.d/caddy/run b/root-linux/etc/services.d/caddy/run new file mode 100755 index 0000000..30e7386 --- /dev/null +++ b/root-linux/etc/services.d/caddy/run @@ -0,0 +1,9 @@ +#!/bin/sh +export XDG_DATA_HOME=/data +export XDG_CONFIG_HOME=/data +SOCK=/var/run/reverse-proxy/caddy.sock +# Linux gives a new unix socket the creator's group, not the directory's +# (FreeBSD does the latter), so hand it to bsd once Caddy has made it. +( i=0; while [ ! -S "$SOCK" ] && [ $i -lt 300 ]; do sleep 0.2; i=$((i+1)); done; chgrp bsd "$SOCK" 2>/dev/null ) & +s6-ready-when test -S "$SOCK" +exec /usr/local/bin/caddy run --config /etc/reverse-proxy/bootstrap.json 2>&1 diff --git a/root-linux/healthz b/root-linux/healthz new file mode 100755 index 0000000..c431b8c --- /dev/null +++ b/root-linux/healthz @@ -0,0 +1,5 @@ +#!/bin/sh +# As on FreeBSD: the admin answers on its socket, and answers 503 while Caddy +# is not running. Asked directly, not through Caddy on :8443: a check before +# the admin listened made Caddy log "admin.sock: no such file" at every boot. +curl -sf -m 5 -o /dev/null --unix-socket /var/run/reverse-proxy/admin.sock http://admin/healthz diff --git a/root-linux/init b/root-linux/init new file mode 100755 index 0000000..432f569 --- /dev/null +++ b/root-linux/init @@ -0,0 +1,10 @@ +#!/bin/sh +# Minimal init: run the one-shot cont-init scripts, then supervise the two +# services with s6 (same layout as the FreeBSD image). +set -e +for f in /etc/cont-init.d/*; do + echo "[init] Running: $f" + "$f" +done +echo "[init] Initialization complete" +exec s6-svscan /etc/services.d diff --git a/root-linux/usr/local/bin/s6-ready-when b/root-linux/usr/local/bin/s6-ready-when new file mode 100755 index 0000000..9d40465 --- /dev/null +++ b/root-linux/usr/local/bin/s6-ready-when @@ -0,0 +1,17 @@ +#!/bin/sh +# Poll a command until it succeeds, then signal s6 readiness on fd 3 +# (needs notification-fd = 3 in the service directory). Bounded, and dies +# with its service. +[ -n "$1" ] || { echo "Usage: s6-ready-when "; exit 1; } +CHECK_CMD="$*" +SERVICE_PID=$PPID +( + tries="${S6_READY_TIMEOUT:-300}" + while ! eval "$CHECK_CMD" >/dev/null 2>&1; do + kill -0 "$SERVICE_PID" 2>/dev/null || exit 0 + tries=$((tries - 1)); [ "$tries" -gt 0 ] || exit 0 + sleep 1 + done + kill -0 "$SERVICE_PID" 2>/dev/null || exit 0 + printf "\n" >&3 +) &