Dockerize the proxy (all-in-one image) + Docker docs
All-in-one Dockerfile bundling OpenResty + the Node mgmt app + Redis in one container, mirroring the bare-metal ops/install.sh layout: - Dockerfile (openresty/openresty:1.31.1.1-2-bookworm-fat base; dumb-init PID 1; luarocks install lua-resty-auto-ssl/luasocket/lua-resty-ipmatcher; node 22.x; npm ci --omit=dev; OpenResty confs + lua copied into place). - docker-entrypoint.sh: fallback cert, sed-parameterize RESOLVER/REAL_IP_FROM, start bundled redis + node app, exec openresty foreground. - docker-compose.yml (standalone), .dockerignore, DEPLOYMENT.md. - nodejs/routes/render.js: /health endpoint for healthchecks. - nodejs/models/user_ldap.js: tlsOptions forwarded to ldapts Client so the proxy can bind ldaps:// with a self-signed cert (app_ldap__tlsOptions__*). - nodejs/package.json: bump @simpleworkjs/conf to ^1.1.0 (app_* env overrides). - docs/docker.md + index.md: Docker deployment guide + fronting an SSO Manager. - ops/proxy.service: add WorkingDirectory=/var/www/proxy/nodejs (bare-metal cwd fix so relative conf/ paths resolve). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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;"]
|
||||
@@ -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:
|
||||
Executable
+122
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -4,8 +4,13 @@ const { Client, Attribute, Change } = require('ldapts');
|
||||
const {Token, InviteToken} = 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 || {},
|
||||
});
|
||||
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user