Merge branch 'master' into feat/issues-48-57

This commit is contained in:
2026-07-11 20:40:22 -04:00
committed by GitHub
13 changed files with 663 additions and 9 deletions
+32
View File
@@ -0,0 +1,32 @@
# Exclude everything not needed in the all-in-one image. The build context is
# the repo root; the Dockerfile COPYs nodejs/* (app) + ops/nginx_conf/* (OpenResty
# config). Keep those, drop the rest.
# Node deps are rebuilt in the image from the lockfile (clean tree).
nodejs/node_modules/
nodejs/test/
nodejs/npm-debug.log*
# Bare-metal installer + chef/vagrant ops not needed in the image.
ops/install.sh
ops/cert.sh
ops/cookbooks/
ops/roles/
ops/proxy.service
Vagrantfile
# Docs site (served via GitHub Pages, not from the image).
docs/
.github/
# Git + editor + secrets.
.git/
.gitignore
*.md
!README.md
secrets.js
secrets.json
*.env
.env
.DS_Store
*.swp
+3 -1
View File
@@ -89,4 +89,6 @@ secrets.js
openresty/**
!openresty/README.md
le_key.cert
le_key.cert
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
docs/_site
+145
View File
@@ -0,0 +1,145 @@
# Deployment Guide — theta42/proxy
The proxy is an OpenID Connect-protected reverse proxy (OpenResty front + Node
management app + Redis) that is **both** an OIDC client of an SSO Manager *and*
a direct LDAP client for user lookups. Two deployment methods:
1. **Docker** — a single all-in-one image bundling OpenResty + the app + Redis
(`docker compose up`).
2. **Bare metal**`ops/install.sh` on Debian/Ubuntu (installs Node.js,
OpenResty, Lua modules, Redis, and a systemd unit).
## How configuration works
The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
which deep-merges, in order:
1. `conf/base.js` (committed, generic defaults)
2. `conf/<NODE_ENV>.js` (optional)
3. `conf/secrets.js` (gitignored — secrets + per-deployment values)
4. **`app_*` environment variables** — the highest-precedence layer
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name is split on **double-underscore** (`__`) into a nested path. Values
are `JSON.parse`-coerced when possible and kept as raw strings otherwise.
| Env var | Sets | Notes |
|---------|------|-------|
| `app_oidc__issuer` | `conf.oidc.issuer` | browser-facing SSO URL |
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` | browser-facing |
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` | server-to-server; can be internal |
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` | server-to-server; can be internal |
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` | browser-facing |
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds | register in the SSO first |
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` | must match the SSO client exactly |
| `app_oidc__enabled` | `conf.oidc.enabled` | boolean |
| `app_ldap__url` | `conf.ldap.url` | `ldaps://…:636` or `ldap://…:389` |
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account | don't reuse the admin DN |
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search | |
| `app_ldap__tlsOptions__rejectUnauthorized` | `conf.ldap.tlsOptions.rejectUnauthorized` | `false` for self-signed LDAPS |
| `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_redis__prefix` | `conf.redis.prefix` | default `proxy_` |
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The Docker image will not honor
> `app_*` env vars on 1.0.0. The lock is already on `^1.1.0`; if you regenerate it:
> ```bash
> cd nodejs && npm install @simpleworkjs/conf@^1.1.0
> ```
---
## Method 1: Docker (all-in-one)
The image (`Dockerfile`) bundles OpenResty + the app + Redis in one container,
mirroring the bare-metal `ops/install.sh` layout. `docker-entrypoint.sh`:
generates the fallback SSL cert, parameterizes the OpenResty `resolver`/
`set_real_ip_from` directives, starts Redis + the node app, and execs OpenResty
in the foreground under `dumb-init`.
### Setup
```bash
# Minimal: set the OIDC + LDAP wiring, then build + start.
OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... LDAP_BIND_PASSWORD=... \
docker compose up -d --build
```
For a real deployment, put the overrides in a `.env` (or `environment:` in the
compose). See `docker-compose.yml` for the full set.
### Access
- Proxy (public): `https://<host>/` — OpenResty front, auto-SSL (Let's Encrypt)
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost; the front
proxies the UI under its own TLS)
- Health: `http://127.0.0.1:3000/health``{"status":"ok"}`
### OpenResty runtime env
| Variable | Default | Description |
|----------|---------|-------------|
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
### Auto-SSL / Let's Encrypt
`lua-resty-auto-ssl` stores certs in the bundled Redis (in-memory by default —
lost on container recreation). For cert persistence, enable Redis persistence
in `docker-entrypoint.sh` or mount a redis AOF/RDB volume. Port 80 is required
for HTTP-01 challenges (mapped in the compose).
---
## Method 2: Bare metal (Debian/Ubuntu)
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and
starts `proxy.service`. Re-run it to update.
```bash
sudo ./ops/install.sh
```
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC +
LDAP values (see `nodejs/conf/base.js` for the shape), then
`sudo systemctl restart proxy`.
---
## Fronting an SSO Manager
The proxy is a natural front for [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node):
it terminates TLS for the SSO's UI and protects it with OIDC login, while also
binding to the SSO's LDAP directly for user lookups. To run both together:
1. **One Docker network** so the proxy can reach the SSO internally at
`http://sso-manager:3001` (token/userinfo, server-to-server) and
`ldaps://sso-manager:636` (LDAP).
2. **Set the SSO's `OAUTH_ISSUER`** to the browser-facing HTTPS URL the proxy
serves the SSO at (e.g. `https://sso.example.com`).
3. **Register the proxy as an OIDC client** in the SSO, with a `redirectUri`
matching the proxy's callback (`https://proxy.example.com/api/auth/oidc/callback`).
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636` and create a
dedicated service account (`cn=ldapclient,ou=people,…`) — don't reuse the
admin DN. For the SSO's self-signed LDAPS cert, set
`app_ldap__tlsOptions__rejectUnauthorized=false` (or mount the cert and use
`app_ldap__tlsOptions__ca=<path>`).
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
automates all four steps with `./setup.sh`.
---
## Security notes
1. **Never commit `secrets.js`** — it's in `.gitignore`.
2. **Bind the management port to localhost** (the compose does: `127.0.0.1:3000`).
The OpenResty front proxies the UI under TLS; don't expose 3000 to the LAN.
3. **`REAL_IP_FROM` empty by default** — the proxy trusts no one to set `X-Real-IP`
(it's the front). Only set it if a trusted proxy sits in front.
4. **LDAPS for any LDAP that crosses the network.** Use `ldaps://`/StartTLS; plain
`ldap://` is fine only on a private docker network.
5. The image runs OpenResty workers as `nobody` and the node app as root (matches
the bare-metal systemd unit). Harden to a non-root user for production if needed.
+130
View File
@@ -0,0 +1,130 @@
# 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.
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
# ── 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;"]
+88
View File
@@ -0,0 +1,88 @@
# Docker Compose for the theta42/proxy all-in-one image
# (OpenResty + Node management app + Redis in one container).
#
# The proxy is an OIDC client of an SSO Manager (or any OIDC provider) AND a
# direct LDAP client for user lookups. Supply that wiring via `app_*`
# environment variables — the highest-precedence config layer in
# @simpleworkjs/conf (>= 1.1.0, pinned in nodejs/package-lock.json). No
# secrets.js is baked in; set the values here, in a .env file, or via an
# env_file (the theta42/theta-env unified repo generates one with setup.sh).
#
# Requires @simpleworkjs/conf >= 1.1.0 in the image (env overrides). The lock
# is already on ^1.1.0; rebuild with `docker compose up -d --build` after any
# package change.
services:
proxy:
build:
context: .
dockerfile: Dockerfile
container_name: proxy
restart: unless-stopped
ports:
# Public proxy listeners (autossl.conf). 80 is used for ACME HTTP-01 +
# redirecting to HTTPS; 443 is the primary; 4443 is the alternate HTTPS.
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${HTTPS_ALT_PORT:-4443}:4443"
# Management API + web UI. Bind to localhost so it isn't exposed to the
# LAN — the OpenResty front proxies the UI/api under its own TLS.
- "127.0.0.1:${MGMT_PORT:-3000}:3000"
environment:
# ── OpenResty runtime (see docker-entrypoint.sh) ──
# Resolver for upstream names in Host records (default = Docker DNS).
- RESOLVER=${RESOLVER:-127.0.0.11}
# Trusted range for X-Real-IP. Empty = proxy is the front (default,
# removes the real_ip block). Set to an upstream proxy's CIDR if one
# sits in front and sets X-Real-IP.
- REAL_IP_FROM=${REAL_IP_FROM:-}
# ── OIDC client config (app_oidc__*) ──
# Point at your SSO Manager. Issuer + authorization/endSession are the
# browser-facing URLs; token/userinfo can be the internal URL if the SSO
# is on the same docker network (avoids a TLS hairpin through the proxy).
- app_oidc__enabled=${OIDC_ENABLED:-true}
- app_oidc__issuer=${OIDC_ISSUER:-https://sso.example.com}
- app_oidc__authorizationEndpoint=${OIDC_AUTHORIZATION_ENDPOINT:-https://sso.example.com/oauth/authorize}
- app_oidc__tokenEndpoint=${OIDC_TOKEN_ENDPOINT:-http://sso-manager:3001/oauth/token}
- app_oidc__userinfoEndpoint=${OIDC_USERINFO_ENDPOINT:-http://sso-manager:3001/oauth/userinfo}
- app_oidc__endSessionEndpoint=${OIDC_ENDSESSION_ENDPOINT:-https://sso.example.com/oauth/logout}
- app_oidc__clientId=${OIDC_CLIENT_ID:-}
- app_oidc__clientSecret=${OIDC_CLIENT_SECRET:-}
- app_oidc__redirectUri=${OIDC_REDIRECT_URI:-https://proxy.example.com/api/auth/oidc/callback}
# ── LDAP client config (app_ldap__*) ──
# Direct user lookups. ldaps:// + rejectUnauthorized=false for a
# self-signed cert, or set app_ldap__tlsOptions__ca=<path> for strict.
- app_ldap__url=${LDAP_URL:-ldaps://sso-manager:636}
- app_ldap__bindDN=${LDAP_BIND_DN:-cn=ldapclient,ou=people,dc=example,dc=com}
- app_ldap__bindPassword=${LDAP_BIND_PASSWORD:-}
- app_ldap__searchBase=${LDAP_SEARCH_BASE:-ou=people,dc=example,dc=com}
- app_ldap__userFilter=${LDAP_USER_FILTER:-(objectClass=inetOrgPerson)}
- app_ldap__tlsOptions__rejectUnauthorized=${LDAP_REJECT_UNAUTHORIZED:-false}
# ── Authorization ──
# Local anti-lockout admin (matches auth.adminUsers in conf/base.js).
- app_auth__adminUsers=${AUTH_ADMIN_USERS:-proxyadmin}
- NODE_ENV=production
- NODE_PORT=3000
volumes:
# Let's Encrypt cert store + auto-ssl redis data live with the bundled
# redis (in-container, in-memory). Mount these to persist across recreation:
# proxy-cache -> /var/cache/nginx/proxy (response cache)
# proxy-logs -> /var/log/nginx (access/error logs)
# Auto-ssl certs are in the bundled redis (in-memory, lost on recreation)
# unless you enable redis persistence in docker-entrypoint.sh.
- proxy-cache:/var/cache/nginx/proxy
- proxy-logs:/var/log/nginx
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
proxy-cache:
proxy-logs:
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# docker-entrypoint.sh — start the theta42/proxy all-in-one container.
#
# Bundles three processes (mirrors the bare-metal ops/install.sh layout):
# 1. Redis (127.0.0.1:6379) — shared by the node app (model-redis),
# lua-resty-auto-ssl cert storage, and targetinfo.lua.
# 2. Node mgmt app (port 3000 + Unix socket /var/run/proxy_lookup.socket)
# — started in the background.
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
# dumb-init, PID 1) so it receives SIGTERM from `docker stop`.
#
# The app reads its config from conf/base.js deep-merged with `app_*` env vars
# (requires @simpleworkjs/conf >= 1.1.0, pinned in nodejs/package-lock.json). No
# secrets.js is baked into the image — supply oidc/ldap/auth config via `app_*`
# env (compose `environment:` / `env_file:`), or mount a conf/secrets.js.
#
# OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal
# home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They
# are copied into the image at build time; this entrypoint sed-substitutes the
# two env-specific values at runtime so the same committed files keep working
# for bare metal (untouched) and Docker (parameterized).
set -e
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Fallback SSL cert for lua-resty-auto-ssl ─────────────────────────────────
# autossl.conf references /etc/ssl/resty-auto-ssl-fallback.{crt,key} — auto-ssl
# serves this for unknown SNI before a real Let's Encrypt cert is issued.
# Generate on first start (idempotent); mount your own to override.
FALLBACK_CRT=/etc/ssl/resty-auto-ssl-fallback.crt
FALLBACK_KEY=/etc/ssl/resty-auto-ssl-fallback.key
if [[ ! -f "$FALLBACK_CRT" || ! -f "$FALLBACK_KEY" ]]; then
info "Generating fallback SSL cert for auto-ssl..."
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
-subj '/CN=sni-support-required-for-valid-ssl' \
-keyout "$FALLBACK_KEY" -out "$FALLBACK_CRT" >/dev/null 2>&1 || {
error "Failed to generate fallback SSL cert"; exit 1
}
else
info "Fallback SSL cert already present"
fi
# ── Parameterize env-specific OpenResty directives ───────────────────────────
# The committed confs hardcode the bare-metal home-LAN values. In Docker the
# proxy is the front (no upstream proxy setting X-Real-IP) and upstream names
# resolve via Docker's embedded DNS.
NGINX_CONF=/usr/local/openresty/nginx/conf/nginx.conf
PROXY_CONF=/usr/local/openresty/nginx/conf/sites-enabled/000-proxy
# RESOLVER: used by proxy_pass for upstream names recorded in Host records.
# Default 127.0.0.11 = Docker's embedded DNS. Set RESOLVER to use a different
# resolver (e.g. 8.8.8.8 for standalone, or your LAN DNS).
RESOLVER="${RESOLVER:-127.0.0.11}"
sed -i "s|resolver 8\.8\.4\.4 8\.8\.8\.8;|resolver ${RESOLVER};|" "$NGINX_CONF"
sed -i "s|resolver 192\.168\.1\.1 ipv6=off;|resolver ${RESOLVER} ipv6=off;|" "$PROXY_CONF"
# REAL_IP_FROM: trusted source range for the X-Real-IP header. Empty (default)
# removes the real_ip block entirely — correct when the proxy is the front
# (clients connect directly, $remote_addr is already the real client, and no
# one is trusted to forge X-Real-IP). Set REAL_IP_FROM to an upstream proxy's IP
# range if something sits in front of this proxy and sets X-Real-IP.
if [[ -z "${REAL_IP_FROM:-}" ]]; then
info "REAL_IP_FROM unset — proxy is the front; removing real_ip block"
sed -i '/set_real_ip_from\|real_ip_header\|real_ip_recursive/d' "$PROXY_CONF"
else
info "REAL_IP_FROM=${REAL_IP_FROM} — trusting X-Real-IP from that range"
sed -i "s|set_real_ip_from 192\.168\.1\.0/24;|set_real_ip_from ${REAL_IP_FROM};|" "$PROXY_CONF"
fi
# Validate the OpenResty config before starting anything else.
if ! openresty -t >/dev/null 2>&1; then
error "OpenResty config test failed:"
openresty -t || true
exit 1
fi
# ── Redis ───────────────────────────────────────────────────────────────────
# In-memory, no persistence (cache/session/cert data only). The app,
# auto-ssl, and targetinfo.lua all reach it at 127.0.0.1:6379 (the redis client
# default + the one literal in targetinfo.lua). Run in the background
# (--daemonize no, backgrounded by the shell); the container's lifecycle is
# owned by OpenResty (exec'd below), and `restart: unless-stopped` in compose
# handles full restarts.
info "Starting Redis..."
redis-server --save "" --appendonly no --daemonize no &
REDIS_PID=$!
for i in $(seq 1 15); do
if redis-cli ping >/dev/null 2>&1; then info "Redis is ready"; break; fi
sleep 0.5
done
if ! redis-cli ping >/dev/null 2>&1; then
error "Redis failed to start"; exit 1
fi
# ── Node management app ──────────────────────────────────────────────────────
# Matches ops/proxy.service: NODE_ENV=production, runs as root (the Unix socket
# is chmod'd 777 by the app so OpenResty's nobody workers can connect).
export NODE_ENV="${NODE_ENV:-production}"
export NODE_PORT="${NODE_PORT:-3000}"
info "Starting proxy management app on port ${NODE_PORT}..."
node bin/www &
NODE_PID=$!
# Give the app a moment to bind the socket + port before OpenResty fields
# requests that may fall back to the socket for host lookups.
for i in $(seq 1 20); do
if curl -fsS http://localhost:${NODE_PORT}/health >/dev/null 2>&1; then
info "Management app is ready"
break
fi
sleep 0.5
done
# ── OpenResty (foreground, primary process) ──────────────────────────────────
info "Starting OpenResty..."
# Replace the shell with openresty in the foreground so dumb-init forwards
# SIGTERM to it. When OpenResty exits the container stops, taking the
# backgrounded redis + node with it; `restart: unless-stopped` in compose
# handles full restarts.
exec openresty -g 'daemon off;'
+110
View File
@@ -0,0 +1,110 @@
---
layout: default
title: Docker
---
# Docker Deployment
[← Back to Home](index.html)
The proxy ships as a single all-in-one Docker image bundling **OpenResty + the
Node management app + Redis** in one container, mirroring the bare-metal
[`ops/install.sh`](https://github.com/theta42/proxy/blob/master/ops/install.sh)
layout. This is the easiest way to run the proxy standalone, or as part of the
unified [theta-env](https://github.com/theta42/theta-env) stack.
## Quick start (standalone)
```bash
git clone https://github.com/theta42/proxy.git
cd proxy
cp .env.example .env # optional: set OIDC/LDAP wiring + ports
docker compose up -d --build
```
- Proxy (public, auto-SSL): `https://<host>/`
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost)
- Health: `http://127.0.0.1:3000/health``{"status":"ok"}`
## How configuration works
The app loads config via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
which deep-merges, in order:
1. `conf/base.js` (committed defaults)
2. `conf/<NODE_ENV>.js` (optional)
3. `conf/secrets.js` (gitignored)
4. **`app_*` environment variables** — the highest-precedence layer
Any env var starting with `app_` overrides the merged config; the rest of the
name splits on **double-underscore** (`__`) into a nested path. Values are
`JSON.parse`-coerced when possible, kept as strings otherwise.
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The `app_*` env layer is not
> honored on 1.0.0. The lock is already on `^1.1.0`.
### Key `app_*` variables
| Env var | Sets |
|---------|------|
| `app_oidc__issuer` | `conf.oidc.issuer` (browser-facing SSO URL) |
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` |
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` (server-to-server; can be internal) |
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` (server-to-server) |
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` |
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds |
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` (must match the SSO client) |
| `app_oidc__enabled` | `conf.oidc.enabled` (boolean) |
| `app_ldap__url` | `conf.ldap.url` (`ldaps://…:636` or `ldap://…:389`) |
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account |
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search |
| `app_ldap__tlsOptions__rejectUnauthorized` | `false` for self-signed LDAPS |
| `app_ldap__tlsOptions__ca` | path to a CA cert for strict trust |
| `app_auth__adminUsers` | local anti-lockout admin (uid) |
| `app_auth__adminGroups` | SSO/LDAP groups that are global admin (JSON array) |
| `app_redis__prefix` | `conf.redis.prefix` (default `proxy_`) |
See [`DEPLOYMENT.md`](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)
for the complete reference.
## OpenResty runtime env
| Variable | Default | Description |
|----------|---------|-------------|
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = the proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
## Auto-SSL / Let's Encrypt
`lua-resty-auto-ssl` stores certs in the bundled Redis (in-memory by default —
lost on container recreation). For cert persistence, mount a Redis AOF/RDB
volume. Port 80 is required for HTTP-01 challenges (mapped in the compose).
## Fronting an SSO Manager
The proxy is a natural front for
[`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node): it
terminates TLS for the SSO's UI and protects it with OIDC login, while also
binding to the SSO's LDAP directly for user lookups. To run both together:
1. **One Docker network** so the proxy reaches the SSO internally at
`http://sso-manager:3001` (token/userinfo) and `ldaps://sso-manager:636`.
2. **Set the SSO's `app_oauth__issuer`** to the browser-facing HTTPS URL the
proxy serves the SSO at (e.g. `https://sso.example.com`).
3. **Register the proxy as an OIDC client** in the SSO, with `redirectUri`
matching `https://proxy.example.com/api/auth/oidc/callback`.
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636`, create a
dedicated service account (`cn=ldapclient,ou=people,…`), and for the SSO's
self-signed LDAPS cert set `app_ldap__tlsOptions__rejectUnauthorized=false`
(or mount the cert and use `app_ldap__tlsOptions__ca=<path>`).
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
automates all four steps with `./setup.sh` — see
[theta-env docs](https://theta42.github.io/theta-env/).
## Bare metal
Prefer a systemd install? See the [Installation Guide](installation.html) for
the `ops/install.sh` automated installer on Debian/Ubuntu.
[← Back to Home](index.html)
+16 -3
View File
@@ -19,7 +19,19 @@ A reverse proxy and HTTPS termination service using OpenResty/nginx with a manag
## Quick Start
### Automated Installation
### Docker (recommended for self-hosters)
A single all-in-one image bundling OpenResty + the app + Redis:
```bash
git clone https://github.com/theta42/proxy.git
cd proxy && docker compose up -d --build
```
See the [Docker Guide](docker.html) for configuration (OIDC/LDAP via `app_*` env)
and fronting an SSO Manager.
### Automated bare-metal installation
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
@@ -27,7 +39,7 @@ For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
### Requirements
### Requirements (bare metal)
- Node.js 18+ (tested with 18.x, 20.x, 22.x)
- OpenResty (nginx with Lua support)
@@ -36,7 +48,8 @@ wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh
## Documentation
- [Installation Guide](installation.html) - Detailed setup instructions
- [Docker Guide](docker.html) - All-in-one container deployment + configuration
- [Installation Guide](installation.html) - Bare-metal setup instructions
- [API Reference](api.html) - Complete API documentation
- [Architecture](architecture.html) - System design and components
- [Contributing](contributing.html) - Development and testing guide
+5
View File
@@ -4,8 +4,13 @@ const { Client, Attribute, Change } = require('ldapts');
const {Token} = require('./token');
const conf = require('@simpleworkjs/conf').ldap;
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
// ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or
// app_* env, e.g. app_ldap__tlsOptions__rejectUnauthorized=false, or
// app_ldap__tlsOptions__ca=/etc/ssl/sso-ldap.crt for strict trust.
const client = new Client({
url: conf.url,
tlsOptions: conf.tlsOptions || {},
});
+4 -4
View File
@@ -11,7 +11,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.0.0",
"@simpleworkjs/conf": "^1.1.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -279,9 +279,9 @@
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.0.0.tgz",
"integrity": "sha512-p1dQAELW0oUBRpDoz260TYw18IMI/Y11xYAb17P1MEPjsTAUB0LWE/6ZeA2VQmpU/LXoRnDysg0G/oASGILyUA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
+1 -1
View File
@@ -22,7 +22,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.0.0",
"@simpleworkjs/conf": "^1.1.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
+6
View File
@@ -29,6 +29,12 @@ router.get('/', (req, res) => {
res.redirect(301, '/hosts');
});
// Lightweight liveness probe for container healthchecks / monitoring. No auth,
// no dependencies — just confirms the Express process is up and routing.
router.get('/health', (req, res) => {
res.json({status: 'ok'});
});
router.get('/hosts', async function(req, res, next) {
res.render('hosts', {...values});
});
+1
View File
@@ -8,6 +8,7 @@ Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/var/www/proxy/nodejs
Environment="NODE_ENV=production"
ExecStart=/usr/bin/env node /var/www/proxy/nodejs/bin/www