diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1a25abd --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e027ac..3e06dbc 100755 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,6 @@ secrets.js openresty/** !openresty/README.md -le_key.cert \ No newline at end of file +le_key.cert +# Jekyll build artifact (GitHub Pages builds remotely; ignore locally) +docs/_site diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..88d9b1d --- /dev/null +++ b/DEPLOYMENT.md @@ -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/.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:///` — 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=`). + +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. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbdd6f0 --- /dev/null +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8618cc8 --- /dev/null +++ b/docker-compose.yml @@ -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= 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: \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..f27c341 --- /dev/null +++ b/docker-entrypoint.sh @@ -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;' \ No newline at end of file diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..f1f562d --- /dev/null +++ b/docs/docker.md @@ -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:///` +- 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/.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=`). + +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) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index f607f5b..77cfa78 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index 6c6dfa1..f852754 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -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 || {}, }); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a30d2d1..00bcaff 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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" diff --git a/nodejs/package.json b/nodejs/package.json index 45ef98e..3e89f4b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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", diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 57e8bc9..5b975fc 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -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}); }); diff --git a/ops/proxy.service b/ops/proxy.service index e2dc4a3..9398f09 100644 --- a/ops/proxy.service +++ b/ops/proxy.service @@ -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