Fix Docker sticky footer, commit hash, and configurable local admin password (#133)

* 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).
This commit is contained in:
2026-07-14 22:32:24 -04:00
committed by GitHub
parent 22f382b968
commit c68fcc9ddb
7 changed files with 81 additions and 8 deletions
+4 -1
View File
@@ -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
+1
View File
@@ -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
+33
View File
@@ -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:
+8 -1
View File
@@ -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,13 +88,19 @@ 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);
+7
View File
@@ -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{
+19 -5
View File
@@ -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(),
};
+8
View File
@@ -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) ───────────────────────────────