From c68fcc9ddb539d1096d6f0152a9ada9cd52d1cd2 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 14 Jul 2026 22:32:24 -0400 Subject: [PATCH] Fix Docker sticky footer, commit hash, and configurable local admin password (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- .dockerignore | 5 ++++- DEPLOYMENT.md | 1 + Dockerfile | 33 +++++++++++++++++++++++++++++++++ nodejs/models/user_redis.js | 11 +++++++++-- nodejs/public/css/styles.css | 7 +++++++ nodejs/utils/build_info.js | 24 +++++++++++++++++++----- secrets.js.example | 8 ++++++++ 7 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index 194639e..8b934b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,10 @@ docs/ .github/ # Git + editor + secrets. -.git/ +# NOTE: .git/ is intentionally NOT excluded — the gitinfo build stage in the +# Dockerfile reads it to bake the commit hash into the image (see +# nodejs/utils/build_info.js), then it's discarded before the final stage. +# It never ends up in the final image. .gitignore *.md !README.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 69c5ddf..f32fbf5 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -40,6 +40,7 @@ are `JSON.parse`-coerced when possible and kept as raw strings otherwise. | `app_ldap__tlsOptions__ca` | `conf.ldap.tlsOptions.ca` | path to a CA cert for strict trust | | `app_auth__adminUsers` | `conf.auth.adminUsers` | local anti-lockout admin (uid) | | `app_auth__adminGroups` | `conf.auth.adminGroups` | SSO/LDAP groups that are global admin (JSON array) | +| `app_auth__localAdminPass` | `conf.auth.localAdminPass` | initial password for the local anti-lockout admin (used once, on first creation only — defaults to the username itself if unset) | | `app_redis__prefix` | `conf.redis.prefix` | default `proxy_` | See [`docs/docker.md`](docs/docker.md) for a shorter, container-focused version diff --git a/Dockerfile b/Dockerfile index dbdd6f0..942bf0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,36 @@ # /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 ────────────────────────────────── @@ -77,6 +107,9 @@ 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: diff --git a/nodejs/models/user_redis.js b/nodejs/models/user_redis.js index 8f66c11..90d64b8 100644 --- a/nodejs/models/user_redis.js +++ b/nodejs/models/user_redis.js @@ -3,6 +3,7 @@ const Table = require('.'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf'); const saltRounds = 10; class User extends Table{ @@ -87,16 +88,22 @@ User.register(); (async function(){ var defaultUser = 'proxyadmin2' + // Optional: an orchestrator (e.g. theta-env's setup.sh) can set + // auth.localAdminPass in proxy-secrets.js to a generated password so this + // bootstrap account isn't left at the well-known default (username == + // password == "proxyadmin2"). Only used on first creation -- once the + // account exists this is never read again, so it's safe to leave set. + var defaultPass = (conf.auth && conf.auth.localAdminPass) || defaultUser; try{ let user = await User.get(defaultUser); }catch(error){ try{ let user = await User.create({ username:defaultUser, - password: defaultUser, + password: defaultPass, created_by: defaultUser }); - console.log(defaultUser, 'created', user); + console.log(defaultUser, 'created', user); }catch(error){ console.error(error) } diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 7fa9bbc..804357f 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -3,9 +3,16 @@ nav.navbar{ padding-right: 1em; } +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + #spa-shell { margin-top: 4.5rem; padding-bottom: 1em; + flex-grow: 1; } .card-title{ diff --git a/nodejs/utils/build_info.js b/nodejs/utils/build_info.js index 933679b..66df054 100644 --- a/nodejs/utils/build_info.js +++ b/nodejs/utils/build_info.js @@ -1,15 +1,29 @@ 'use strict'; +const fs = require('fs'); +const path = require('path'); const { execSync } = require('child_process'); const { version: buildVersion } = require('../package.json'); -let buildHash = 'unknown'; -try { - buildHash = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); -} catch (_) {} +// Docker builds bake the commit hash into ../.build_commit (see the gitinfo +// stage in Dockerfile) -- the final image has no git binary and no .git +// directory, so `git rev-parse` below always fails there. Bare-metal/dev +// runs have no baked file, so they fall back to asking git directly. +function readBuildHash() { + try { + const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim(); + if (baked) return baked; + } catch (_) {} + + try { + return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); + } catch (_) { + return 'unknown'; + } +} module.exports = { buildVersion, - buildHash, + buildHash: readBuildHash(), buildYear: new Date().getFullYear(), }; diff --git a/secrets.js.example b/secrets.js.example index fcbff7e..b2f0157 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -60,6 +60,14 @@ module.exports = { adminGroups: [], adminUsers: ['proxyadmin'], groupRoleMap: {}, + // Optional: the local anti-lockout admin's initial password, used + // ONLY the first time that account is created. Leave unset and it + // defaults to the username itself ("proxyadmin2") — fine for a quick + // local test, but change it (or set this) before exposing the proxy + // publicly. Once the account exists, this key is never read again; + // change the password via the app itself (or delete the Redis user + // to force it to be re-bootstrapped with a new value here). + // localAdminPass: 'change-me', }, // ── Orchestrator-only (ignored by the app) ───────────────────────────────