c68fcc9ddb
* Fix TLS handshake failure for any host without a cached target Reported: fallback SSL doesn't work in the Docker build. Reproduced — it's worse than the fallback specifically: TLS was broken for nearly every connection, including ones with no SNI at all: $ curl -vk https://127.0.0.1/ * TLSv1.3 (IN), TLS alert, internal error (592) * OpenSSL/3.0.13: error:0A000438:SSL routines::tlsv1 alert internal error Root cause: targetinfo.lua's M.get() is shared by two call sites in two incompatible nginx phases — - proxy.conf's access_by_lua_block (a normal HTTP request phase, where ngx.exit() is valid) - nginx.conf's request_domain callback, which runs during the TLS handshake itself (ssl_certificate_by_lua*), where ngx.exit() is NOT a supported API M.get() called ngx.exit() on every lookup failure (no domain/SNI, a Redis error, or an unregistered host). When invoked from the SSL phase, that aborted the handshake with a bare "internal error" alert and produced no log output anywhere — silent and total, not limited to the unregistered-domain case, since even a connection with no SNI hits the same code path immediately. Fix: M.get() no longer calls ngx.exit() itself — it returns (nil, httpStatus) on failure. proxy.conf now checks the return value and calls ngx.exit() itself (the phase where that's actually supported). nginx.conf's request_domain guards the now-possibly-nil result before indexing it, and leaves ngx.ctx.toAllow unset on failure so allow_domain() correctly denies issuance and auto-ssl falls through to the static fallback cert in autossl.conf. Verified end to end against a running Docker build (deployed the changed files into a live container and reloaded, rather than relying on a full rebuild each iteration): - No SNI at all: TLS now completes; HTTP layer correctly returns 406 (previously: broken handshake, no response at all) - Unregistered SNI: same — TLS completes, 406, and openssl s_client confirms the cert served is genuinely the fallback (CN=sni-support-required-for-valid-ssl) - A real registered Host: TLS completes and proxies through to the backend correctly (confirms the success path is unaffected) - npm test: 192/192 pass * Fix footer not sticking to the bottom on short pages body had no sticky-footer layout at all (sso-manager-node already had this; proxy never did), so on any page with little content (e.g. /login) the footer sat right after the content instead of at the bottom of the viewport, leaving a large gap below it. Added the same flex-based pattern already used in sso-manager-node: body is a column flex container, #spa-shell grows to fill the remaining space, pushing the footer (the next sibling) to the bottom. Verified visually (screenshot) and via computed layout (footer.getBoundingClientRect().bottom === window.innerHeight) before and after. * Fix commit hash not showing in Docker builds build_info.js computed buildHash via `git rev-parse --short HEAD` at runtime, but the final image intentionally has no git binary and no .git directory (kept lean, per .dockerignore) — so this always failed silently and the footer's version line showed "unknown" for every Docker deployment. Working correctly only for bare-metal/dev, where git + .git are actually present. Added a throwaway gitinfo build stage that reuses the main base image (no extra pull) with git installed just for this stage, reads .git from the build context (now no longer excluded — see .dockerignore), and bakes the resolved short hash into a small file that IS copied into the final image. build_info.js reads that file first, falling back to the old git-rev-parse behavior (still needed for bare-metal). Verified against a real build: `docker exec proxy cat /app/.build_commit` matches `git rev-parse --short HEAD` on the host, and the footer now shows the real hash instead of "unknown". * Allow the local anti-lockout admin's initial password to be configured The local "proxyadmin2" bootstrap account was always created with username == password == "proxyadmin2" — a hardcoded, publicly-known default with no way to set it to something else before first boot. Fine for a quick local test, not for anything exposed publicly, and orchestrators like theta-env's setup.sh (which already generates a random password for the SSO admin) had no way to do the same here. Added conf.auth.localAdminPass (proxy-secrets.js / app_auth__localAdminPass): if set, it's used as the initial password instead of the hardcoded default. Only read on first creation — once the account exists this is never consulted again, so it's safe to leave set. Falls back to the previous behavior (password == username) when unset, so this is fully backward compatible. Verified: with app_auth__localAdminPass set, login with the new password succeeds and the old default ("proxyadmin2") is correctly rejected. Confirmed in a real Docker build too (secrets.js auth.localAdminPass), and npm test 192/192 pass. * Support GIT_COMMIT build-arg override for submodule builds The gitinfo stage from the previous commit works for a standalone clone (.git is a real directory) but not when this repo is built as a git submodule (e.g. from theta-env): a submodule's .git is a pointer FILE, not a directory — the real object database lives in the superproject's .git/modules/, outside this repo's own directory and therefore outside Docker's build context entirely. `git rev-parse` can never resolve it from in here no matter what, so builds via theta-env still baked in "unknown" despite the earlier fix. Add an optional GIT_COMMIT build-arg that, when set, wins over the in-context git resolution. theta-env's setup.sh now computes it on the host (where the submodule DOES resolve correctly) and passes it via docker-compose.yml's build.args. Verified via theta-env's actual setup.sh end to end: rebuilding with this change, `docker exec proxy cat /app/.build_commit` now matches `git -C proxy rev-parse --short HEAD` on the host (previously: "unknown", confirmed via the "[Warning] One or more build-args [GIT_COMMIT] were not consumed" message before this fix synced into the docker-compose.yml side).
163 lines
9.3 KiB
Docker
163 lines
9.3 KiB
Docker
# Theta42 Proxy — All-in-One Dockerfile
|
|
# OpenResty (front) + Node management app + Redis in a single container, mirroring
|
|
# the bare-metal ops/install.sh layout. Intended for self-contained single-node
|
|
# deployments (home labs / small businesses). Each process is supervised by
|
|
# dumb-init (PID 1); redis + the node app run in the background and OpenResty
|
|
# runs in the foreground as the primary process.
|
|
#
|
|
# Base image: the official openresty/openresty "-fat" variant bundles luarocks
|
|
# preconfigured for OpenResty's luajit, so `luarocks install` places rocks into
|
|
# /usr/local/openresty/lualib (which is on OpenResty's package.path) — no manual
|
|
# --lua-dir/--tree wrangling needed.
|
|
|
|
# ── Git commit hash (build-time only) ────────────────────────────────────────
|
|
# The final image intentionally has no git binary and no .git directory (kept
|
|
# lean, per .dockerignore), so `git rev-parse` always fails at runtime and
|
|
# build_info.js silently fell back to "unknown". Resolve it here instead,
|
|
# where .git IS available (build context), and bake just the short hash into
|
|
# a file — this stage itself is discarded, only /commit.txt survives via the
|
|
# COPY --from below. Reuses the main base image (already pulled for the real
|
|
# build below) rather than a separate one, so this adds no extra image pull.
|
|
#
|
|
# GIT_COMMIT lets a caller override the resolved hash instead of computing it
|
|
# from .git in this build context. Needed when this repo is built as a git
|
|
# submodule (e.g. from theta-env): a submodule's .git is a pointer FILE, not
|
|
# a directory — the real object database lives in the superproject's
|
|
# .git/modules/, outside this repo's own directory and therefore outside
|
|
# Docker's build context entirely, so `git rev-parse` can never resolve it
|
|
# from in here no matter what. theta-env's setup.sh passes
|
|
# --build-arg GIT_COMMIT=$(git -C proxy rev-parse --short HEAD), computed on
|
|
# the host where the submodule resolves correctly.
|
|
ARG GIT_COMMIT=""
|
|
FROM openresty/openresty:1.31.1.1-2-bookworm-fat AS gitinfo
|
|
ARG GIT_COMMIT
|
|
WORKDIR /repo
|
|
COPY .git ./.git
|
|
RUN if [ -n "$GIT_COMMIT" ]; then \
|
|
echo "$GIT_COMMIT" > /commit.txt; \
|
|
else \
|
|
{ apt-get update && apt-get install -y --no-install-recommends git \
|
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
|
fi
|
|
|
|
FROM openresty/openresty:1.31.1.1-2-bookworm-fat
|
|
|
|
# ── Tooling needed before adding apt repos ──────────────────────────────────
|
|
# The -fat base image lacks gnupg, which the NodeSource keyring setup needs.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# ── Node.js 22.x (NodeSource) ────────────────────────────────────────────────
|
|
# The management app + its native deps (bcrypt) need Node. Matches
|
|
# ops/install.sh NODE_MAJOR=22.
|
|
RUN install -d -m 0755 /etc/apt/keyrings \
|
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
|
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg \
|
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
|
|
> /etc/apt/sources.list.d/nodesource.list
|
|
|
|
# ── System packages ──────────────────────────────────────────────────────────
|
|
# build-essential g++ make python3 : native addon build for bcrypt
|
|
# libpam0g-dev : native build for linux-sys-user
|
|
# redis-server : bundled Redis (the app + lua-resty-auto-ssl
|
|
# + targetinfo.lua all reach 127.0.0.1:6379)
|
|
# dumb-init : PID 1 zombie reaping + signal forwarding
|
|
# openssl lsb-release wget : tooling
|
|
# nodejs : Node 22.x runtime
|
|
# luarocks ships in the -fat base image (configured for OpenResty's luajit).
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends \
|
|
build-essential g++ make python3 \
|
|
libpam0g-dev \
|
|
redis-server \
|
|
dumb-init \
|
|
openssl lsb-release wget \
|
|
nodejs \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# ── Lua modules ──────────────────────────────────────────────────────────────
|
|
# lua-resty-auto-ssl : Let's Encrypt automation (certs stored in the bundled redis)
|
|
# luasocket : socket helpers used by auto-ssl
|
|
# lua-resty-ipmatcher: CIDR matching for per-host IP allow/deny (hostfeatures.lua)
|
|
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
|
RUN luarocks install lua-resty-auto-ssl \
|
|
&& luarocks install luasocket \
|
|
&& luarocks install lua-resty-ipmatcher
|
|
|
|
# ── Node app ─────────────────────────────────────────────────────────────────
|
|
WORKDIR /app
|
|
|
|
# Install production deps first (layer cache: only rebuilds when package*.json
|
|
# changes). .dockerignore excludes nodejs/node_modules.
|
|
COPY nodejs/package*.json ./
|
|
RUN npm ci --omit=dev
|
|
|
|
# App source (mirror sso-manager's layout — flattened into /app).
|
|
COPY nodejs/app.js ./
|
|
COPY nodejs/bin ./bin
|
|
COPY nodejs/conf ./conf
|
|
COPY nodejs/controller ./controller
|
|
COPY nodejs/middleware ./middleware
|
|
COPY nodejs/migrations ./migrations
|
|
COPY nodejs/models ./models
|
|
COPY nodejs/routes ./routes
|
|
COPY nodejs/services ./services
|
|
COPY nodejs/utils ./utils
|
|
COPY nodejs/views ./views
|
|
COPY nodejs/public ./public
|
|
|
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
|
|
|
# ── OpenResty config (mirrors ops/install.sh symlink targets) ─────────────────
|
|
# The default OpenResty config lives at /usr/local/openresty/nginx/conf/nginx.conf
|
|
# (the prefix conf dir); relative `include` directives resolve there. We place:
|
|
# nginx.conf -> prefix conf dir (overwrites the stock config)
|
|
# autossl.conf -> prefix conf dir (included by proxy.conf)
|
|
# proxy.conf -> prefix conf dir/sites-enabled/000-proxy (included by nginx.conf)
|
|
# *.lua -> /usr/local/openresty/lualib (on OpenResty's package.path)
|
|
# The entrypoint sed-substitutes the env-specific real_ip/resolver values into
|
|
# the copied files at runtime, so the committed confs (which carry the bare-metal
|
|
# home-LAN values) are left untouched for bare-metal use.
|
|
RUN install -d /usr/local/openresty/nginx/conf/sites-enabled
|
|
COPY ops/nginx_conf/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
|
COPY ops/nginx_conf/autossl.conf /usr/local/openresty/nginx/conf/autossl.conf
|
|
COPY ops/nginx_conf/proxy.conf /usr/local/openresty/nginx/conf/sites-enabled/000-proxy
|
|
COPY ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
|
COPY ops/nginx_conf/hostfeatures.lua /usr/local/openresty/lualib/hostfeatures.lua
|
|
|
|
# ── Runtime dirs ─────────────────────────────────────────────────────────────
|
|
# nginx.conf writes access/error logs to /var/log/nginx and uses a response
|
|
# cache at /var/cache/nginx/proxy. OpenResty workers run as `nobody` (the
|
|
# compiled-in default — nginx.conf leaves `user` commented), so the cache dir
|
|
# must be owned by nobody:nogroup for proxy_cache_path to write to it. The node
|
|
# app creates /var/run/proxy_lookup.socket and chmods it 777 so the nobody
|
|
# workers can connect (see utils/unix_socket_json.js).
|
|
RUN install -d -m 0755 -o nobody -g nogroup /var/cache/nginx/proxy \
|
|
&& install -d /var/log/nginx /var/run /etc/ssl
|
|
|
|
# ── Entrypoint ───────────────────────────────────────────────────────────────
|
|
COPY docker-entrypoint.sh /usr/local/bin/
|
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
|
# 80/443/4443 : public proxy listeners (autossl.conf)
|
|
# 3000 : node management API + web UI (internal; the OpenResty front
|
|
# proxies /api/* and the UI. Expose it for first-run access /
|
|
# healthcheck — bind to localhost only in production via compose.)
|
|
EXPOSE 80 443 4443 3000
|
|
|
|
# Healthcheck: the node app's /health (added in routes/render.js). Confirms the
|
|
# management process is up and routing; OpenResty liveness is implied because it
|
|
# proxies /api/auth -> the node app.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
CMD curl -fsS http://localhost:3000/health || exit 1
|
|
|
|
# dumb-init reaps zombies and forwards SIGTERM to the OpenResty process the
|
|
# entrypoint execs into, so `docker stop` shuts down cleanly instead of hitting
|
|
# the 10s kill timeout.
|
|
ENTRYPOINT ["dumb-init", "/usr/local/bin/docker-entrypoint.sh"]
|
|
|
|
# The entrypoint starts redis + the node app in the background, then execs
|
|
# `openresty -g 'daemon off;'` in the foreground as the primary process.
|
|
CMD ["openresty", "-g", "daemon off;"] |