Compare commits

..

25 Commits

Author SHA1 Message Date
wmantly 289a9587d6 chore(release): public-release readiness fixes for 1.1.16
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:14:36 -04:00
wmantly 6ede072213 security: swap sanitizer to xss for Jest-compatible SSR output
- Replace isomorphic-dompurify with xss to avoid ESM-only jsdom/htmlparser2
  transitive deps that break the existing Jest test suite.
- Keep rendered docs HTML sanitized via xss() in routes/docs.js.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:02:11 -04:00
wmantly bdaba513a7 security: sanitize markdown output and harden unix socket permissions
- Add isomorphic-dompurify to sanitize rendered docs HTML
- Tighten SocketServerJson socket permissions from 777 to 660
- Keep package.json version at 1.1.16

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:41:47 -04:00
wmantly d1dd40d60a chore(release): public-release readiness fixes for 1.1.16
- Fix MIT LICENSE copyright placeholder
- Remove private flag and correct GitHub repository URL in package.json
- Bump version to 1.1.16
- Genericize committed config defaults (example.com/localhost)
- Harden global error handler against information leakage
- Generate random initial password for proxyadmin2 bootstrap account
- Correct docs to describe CONF_SECRETS instead of symlink behavior

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:02:27 -04:00
wmantly c0e1aa666e Merge pull request #168 from theta42/release-1.1.15
Bump version to 1.1.15
2026-07-18 01:16:49 -04:00
wmantly f8d620f4d3 Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:15:31 -04:00
wmantly 899c4d91d6 Merge pull request #167 from theta42/install-script-rework
Move bare-metal install path to /opt/theta42/proxy, secrets to /etc/proxy/secrets.js
2026-07-18 01:14:34 -04:00
wmantly 0f268fdcae install.sh: never block on an interactive debconf prompt
redis-server (and other base packages) can pull in tzdata as a fresh
dependency on a box that's never configured it, which prompts
interactively (geographic area / city) and hangs the installer with
no TTY attached -- confirmed by reproducing the hang in a clean
container. DEBIAN_FRONTEND=noninteractive avoids it, matching the
same fix applied to sso-manager-node's install.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:12:25 -04:00
wmantly da0ed0e2ad Move install path to /opt/theta42/proxy, secrets to /etc/proxy/secrets.js
- ops/install.sh now installs to /opt/theta42/proxy (was /var/www/proxy)
  and seeds /etc/proxy/secrets.js from secrets.js.example on first run
  (never overwritten on later runs), instead of requiring a manual
  nodejs/conf/secrets.js edit inside the repo checkout.
- ops/proxy.service points at the new install path and sets
  CONF_SECRETS=/etc/proxy/secrets.js (requires @simpleworkjs/conf >=
  1.2.0, already the pinned version) so the app picks up the secrets
  file with no symlink into the repo checkout.
- install.sh now prints the version it's updating from/to (or "Already
  up to date") on every run, instead of a silent update.
- Updated README/DEPLOYMENT/installation docs to match the new paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 00:47:51 -04:00
wmantly f0eadbc2d7 Merge pull request #166 from theta42/release-1.1.14
Bump version to 1.1.14
2026-07-17 23:43:25 -04:00
wmantly a02ca4d3e7 Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:42:00 -04:00
wmantly 2ff2bf9ea7 Merge pull request #165 from theta42/bump-conf-jqrepeat
Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
2026-07-17 23:40:17 -04:00
wmantly 355a9d68e5 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:35:24 -04:00
wmantly 11f6c4df36 Merge pull request #164 from theta42/fix-changelog-corruption
Fix CHANGELOG.md corruption (v1.1.7-1.1.13 merged headings)
2026-07-17 22:21:30 -04:00
wmantly a567bf6c51 Fix CHANGELOG.md: restore separate headings for v1.1.7-1.1.13
Repeated Edit-tool bumps had been overwriting the previous top version
heading instead of inserting a new one above it, silently merging
each version's release notes into the next under one heading. All the
underlying content was still present in the file, just missing its
separating "## [X.Y.Z]" headers -- reconstructed the correct
structure with no content loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:19:54 -04:00
wmantly b9bdf36638 Merge pull request #163 from theta42/concept-docs
Add plain-language concept docs; fix docs viewer rendering; link API tokens
2026-07-17 22:11:28 -04:00
wmantly 426fa111ec Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{hosts,dns,access,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing system-design-level doc for anyone who wants that detail.
  Card help links (Proxy List, Add/Edit host, DNS Provider cards,
  Users/Permissions/Groups cards) now point here instead of straight at
  Installation/Architecture.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer.
- Fixed cross-doc links never resolving in-app, since this viewer serves
  docs at /docs/<slug> with no .html suffix: rewritten to the correct
  in-app URL, first by registered slug, falling back to the doc's real
  filename (the correct, working link form on the Jekyll/GitHub Pages
  build) -- same idea as the existing image-path fix, and lets one link
  written in a doc work on both targets.

Bumps to v1.1.13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 22:09:53 -04:00
wmantly f0b282b679 Merge pull request #162 from theta42/wildcard-edit-and-nginx-fix
Fix wildcard-parent edit greying and deprecated nginx http2 directive
2026-07-17 21:35:28 -04:00
wmantly 4f1fce367e Fix wildcard-parent edit greying and deprecated nginx http2 directive
- The edit form's "Parent Wildcard" option stayed greyed out even when a
  valid wildcard existed, since hostEditOpen() never ran the eligibility
  check (only the host field's keyup handler did, which setting .val()
  programmatically doesn't fire) -- and the check itself, GET
  /host/lookup/:item, had the same self-match bug as the recently-fixed
  Host.prototype.update() case: it resolves an already-existing host to
  its own record instead of a sibling wildcard. Added a dedicated
  /host/wildcard-parent/:item route combining lookUp() (handles a
  brand-new subdomain) with lookUpWildcardParent() (handles an
  already-existing host), and hostEditOpen() now actually runs it.

- Migrated ops/nginx_conf/autossl.conf's deprecated "listen ... http2"
  directive to the standalone "http2 on;" directive (nginx 1.25.1+).

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:33:26 -04:00
wmantly 9eb3dfa2e6 Merge pull request #161 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 20:02:08 -04:00
wmantly a40da55993 Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics. Removed it and added a small help icon directly to each card
that has real corresponding doc content, linking straight to that doc:
Proxy List + Add/Edit host modal (hosts.ejs), Add DNS Provider + Dynamic
A Records (dns.ejs), Add New User + User List (users.ejs), Add
Permission + Permissions (permissions.ejs), Add Group (groups.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 20:00:34 -04:00
wmantly e5df0d3370 Merge pull request #160 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:29:22 -04:00
wmantly fcd73169e0 Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:28:07 -04:00
wmantly 876ea6cfd0 Merge pull request #159 from theta42/host-form-ux-polish
Host form/list UX polish: editable hostname, created-by column, mobile tabs, more help text
2026-07-17 19:05:09 -04:00
wmantly 9100e92549 Host form/list UX polish: editable hostname, created-by column, mobile tabs, more help text
- Plain hosts can now be renamed after creation (wildcard/child/cache hosts
  stay locked, since other records reference them by name). Migrates the
  cert cache key on rename.
- Along the way, found and fixed a real bug in the vendored model-redis
  library: its rename path leaves a stray, incomplete hash behind under
  the old key when an `always`-type field (updated_on) is defined earlier
  in the schema than the primary key -- silently blocking that hostname
  from ever being reused. Worked around at the Host model level (can't
  patch node_modules).
- Host list now shows who created each host, and when.
- Host modal's tabs now scroll horizontally on narrow screens instead of
  overflowing awkwardly.
- Added missing inline help text (Target SSL, wildcard matching behavior).

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:03:26 -04:00
36 changed files with 873 additions and 101 deletions
+71 -1
View File
@@ -6,6 +6,67 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.1.16] - 2026-07-18
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json`, corrected the repository URL to `https://github.com/theta42/proxy.git`, and fixed the MIT `LICENSE` copyright line.
- Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure.
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file.
### Fixed
- The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients.
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior.
## [1.1.15] - 2026-07-18
### Changed
- `ops/install.sh` now installs to `/opt/theta42/proxy` (was `/var/www/proxy`) and seeds `/etc/proxy/secrets.js` from `secrets.js.example` on first run (never overwritten on later runs), instead of requiring a manual `nodejs/conf/secrets.js` edit inside the repo checkout. `ops/proxy.service` sets `CONF_SECRETS=/etc/proxy/secrets.js` to match.
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run, instead of updating silently.
### Fixed
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency — it prompted interactively for a timezone with no TTY attached. Set `DEBIAN_FRONTEND=noninteractive`.
## [1.1.14] - 2026-07-17
### Changed
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `proxy-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
## [1.1.13] - 2026-07-17
### Added
- Four new plain-language docs aimed at less technical readers, replacing the system-design-level Architecture/Installation docs as the target of most card help links: **Hosts & HTTPS**, **DNS Providers**, **Users, Groups & Permissions**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed entirely) now has a help link.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
## [1.1.12] - 2026-07-17
### Fixed
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
## [1.1.11] - 2026-07-17
### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
## [1.1.10] - 2026-07-17
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
## [1.1.9] - 2026-07-17
### Added
- The host list now shows who created each host, and when.
- Plain (non-wildcard) hosts can now be renamed after creation — the hostname field is no longer permanently locked. Wildcard hosts, wildcard children, and auto-created subdomain cache entries stay locked, since other records reference them by name.
- More inline help text on the host create/edit form (Target SSL, wildcard matching behavior).
### Fixed
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
## [1.1.8] - 2026-07-17
### Fixed
@@ -14,6 +75,8 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record.
## [1.1.7] - 2026-07-16
### Changed
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
@@ -60,7 +123,14 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.8...HEAD
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.15...HEAD
[1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/proxy/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/proxy/compare/v1.1.10...v1.1.11
[1.1.10]: https://github.com/theta42/proxy/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/proxy/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/proxy/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/proxy/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/proxy/compare/v1.1.5...v1.1.6
+20 -10
View File
@@ -75,11 +75,12 @@ $EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindP
docker compose up -d --build
```
`docker-entrypoint.sh` symlinks `/config/proxy-secrets.js` `/app/conf/secrets.js`
so `@simpleworkjs/conf` reads it. No `app_*` env is passed — `app_*` env would
override the file (env beats secrets.js in `@simpleworkjs/conf`), so the file is
kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` / `NODE_PORT` are
OpenResty-runtime / process env, not `app_*` config, so they stay in the compose.
`docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
`@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
`NODE_PORT` are OpenResty-runtime / process env, not `app_*` config, so they
stay in the compose.
> Running the unified `theta-env` stack? Its `setup.sh` generates
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
@@ -227,16 +228,25 @@ docker compose logs --tail=200 --since=10m proxy # recent context
`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.
`/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
starts `proxy.service`. Re-run it to update — it prints the version you're
updating from and to (or "Already up to date" if there's nothing new).
```bash
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
or, if you already have the repo checked out:
```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`.
Configuration is file-based: on first run the installer seeds
`/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
in — OIDC + LDAP values, see `nodejs/conf/base.js` for the shape). Edit it,
then `sudo systemctl restart proxy`. Later runs never touch an existing
secrets file.
---
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2026 theta42
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+17 -3
View File
@@ -127,10 +127,15 @@ This installer will:
- Install and configure Redis
- Set up SSL fallback certificates
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
- Clone and install the proxy application
- Clone/update the proxy application at `/opt/theta42/proxy`
- Seed `/etc/proxy/secrets.js` on first run (edit it, then re-run or `systemctl restart proxy`)
- Configure systemd service
- Start the proxy service
It's idempotent and safe to re-run — re-running it updates the app in place and
prints the version you're updating from and to (e.g. `Updated v1.1.13 ->
v1.1.14`), or `Already up to date` if there's nothing new.
## Logs (Docker)
The all-in-one image runs OpenResty in the foreground and the Node app in the
@@ -224,15 +229,24 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
Clone and install:
```bash
cd /var/www
mkdir -p /opt/theta42
cd /opt/theta42
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
npm install
```
Configure secrets:
```bash
mkdir -p /etc/proxy
cp ../secrets.js.example /etc/proxy/secrets.js
chmod 600 /etc/proxy/secrets.js
$EDITOR /etc/proxy/secrets.js
```
Create systemd service:
```bash
cp ops/proxy.service /etc/systemd/system/proxy.service
cp ../ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload
systemctl enable proxy.service
systemctl start proxy.service
+10 -9
View File
@@ -9,13 +9,14 @@
# 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 conf/secrets.js
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in
# The app reads its config from conf/base.js deep-merged with a secrets file
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.2.0, pinned in
# nodejs/package-lock.json). No secrets.js is baked into the image. The unified
# theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint
# symlinks it into /app/conf/secrets.js so the app reads oidc/ldap/auth config
# from the file (no app_* env needed). Without the mount, supply the same config
# via `app_*` env (compose `environment:` / `env_file:`).
# points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
# from the mounted file (no app_* env needed, no write access to /app/conf
# required). Without the mount, supply the same config via `app_*` env
# (compose `environment:` / `env_file:`).
#
# 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
@@ -30,14 +31,14 @@ error() { echo "[ERROR] $*" >&2; }
# ── Optional: mount proxy secrets.js ─────────────────────────────────────────
# When /config/proxy-secrets.js is present (unified theta-env stack, or any
# deployment that bind-mounts ./config), symlink it into /app/conf/secrets.js so
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_* env
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
# deployment that bind-mounts ./config), point CONF_SECRETS at it so
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_*
# env should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
# (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is
# authoritative only if the matching app_* env is absent. When the file is
# absent the app falls back to app_* env (compose environment / env_file).
if [[ -f /config/proxy-secrets.js ]]; then
ln -sf /config/proxy-secrets.js /app/conf/secrets.js
export CONF_SECRETS=/config/proxy-secrets.js
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
fi
+5
View File
@@ -8,6 +8,11 @@ description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit
[← Back to Home](index.html)
> Looking for a plainer explanation of hosts, HTTPS, or the local
> permission model instead of internals? See
> [Hosts & HTTPS](concepts-hosts.html) and
> [Users, Groups & Permissions](concepts-access.html).
## System Overview
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
+75
View File
@@ -0,0 +1,75 @@
---
layout: default
title: Users, Groups & Permissions
description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy.
---
# Users, Groups & Permissions
This page explains, in plain language, who can manage what in this app. For
the deeper system-design detail, see [Architecture](architecture.html).
## Two different ways to log in
Most people who use apps you've proxied through this app never see this
app's own login at all — they use whatever authentication you set up on
the *individual host* (basic auth, or single sign-on through your SSO
Manager). This page is about a different, smaller group: the people who
manage the proxy itself — adding hosts, registering DNS providers, and so
on.
There are two ways someone gets into the proxy's own management UI:
- **A local account**, created on the **Users** page — a username and
password specific to this app.
- **Single sign-on**, if you've connected this proxy to an SSO Manager (or
another OIDC provider) — the same login your other connected apps use.
Either way, once logged in, what they're actually *allowed to do* here is
controlled by permissions, described below.
## Groups
A **group** here is just a named list of local usernames, used to grant
the same permission to several people at once instead of one at a time.
If you're using SSO instead of local accounts, group membership normally
comes from your identity provider instead — local groups exist mainly for
the local-account case.
## Permissions: scope + role
Each **permission** entry grants one subject (a user or a group) one
**role**, at one **scope** — the two are independent choices:
**Scope***where* the role applies:
- **Domain** — only hosts under one specific domain (e.g. someone can
manage everything under `example.com`, but can't see or touch a
completely different domain you also proxy).
- **Global** — everywhere, across every domain this proxy manages.
**Role***what* they can do within that scope:
- **Viewer** — read-only. Can see hosts and their settings, but not
change anything.
- **Manager** — full control over hosts (create, edit, delete) within
that scope.
- **Admin** — same host control as Manager, **plus**, but *only when
granted at Global scope*, the ability to manage other people's
permissions, DNS providers, and local user accounts. An Admin role
granted at Domain scope instead of Global behaves exactly like Manager
for that one domain — it does not unlock those extra admin-only pages.
In practice: give someone **Manager** on just the domain(s) they're
responsible for to delegate day-to-day host management without handing
them the keys to everything. Reserve **Global Admin** for people who
should be able to change anything, anywhere, including who else has
access.
## Want more detail?
This page doesn't cover the exact permission-checking implementation or
how SSO group membership maps into this system internally — for that, see
[Architecture](architecture.html).
[← Back to Home](index.html)
+60
View File
@@ -0,0 +1,60 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in theta42/proxy.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](api.html).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that registers or updates hosts automatically (say, spinning up
a new service and wanting the proxy entry created for it without a
manual step).
- A monitoring or backup job that checks this app's health via its API.
- A configuration-management tool that keeps your host list in sync with
something else.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** [permissions](concepts-access.html)
— if you're only a Manager on one domain, a token you create can't touch
any other domain either. If you ever suspect a token has leaked (ended up
somewhere it shouldn't have, like a public script or log file), revoke it
immediately from your Profile page; it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](api.html).
[← Back to Home](index.html)
+49
View File
@@ -0,0 +1,49 @@
---
layout: default
title: DNS Providers
description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates.
---
# DNS Providers
This page explains, in plain language, what a "DNS provider" is for in this
app and when you actually need one. For setup steps, see
[Installation](installation.html).
## Do you need this at all?
**Only if you want a [wildcard host](concepts-hosts.html)** (something like
`*.example.com` covering every subdomain with one certificate). A normal,
single-name host doesn't need a DNS provider configured at all — skip this
page entirely if that's all you're setting up.
## Why a wildcard cert needs this extra step
To prove you actually own `example.com` before issuing a certificate that
covers *every* possible subdomain of it, Let's Encrypt needs to see a
specific, temporary DNS record appear on that domain — something only the
real owner of the domain could add. A normal single-host certificate
doesn't need this because it can prove ownership a simpler way (by
responding to a web request instead).
So: to get a wildcard certificate, this app needs to be able to add (and
later remove) that one temporary DNS record on your domain automatically,
which means it needs your domain registrar or DNS host's API credentials —
that's what registering a **DNS provider** here does.
## What you're actually giving it access to
A DNS provider entry only needs enough access to add/remove TXT records —
it's not given your registrar account's full login, and it can't do
anything to your domain besides that one narrow task (and, for some
providers, keeping a dynamic A record updated if you use that feature
separately). Check your specific provider's page in the
[Installation guide](installation.html) for exactly what kind of
credential to generate and how narrowly you can scope it.
## Want more detail?
For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun,
DuckDNS, etc.), see [Installation](installation.html).
[← Back to Home](index.html)
+77
View File
@@ -0,0 +1,77 @@
---
layout: default
title: Hosts & HTTPS
description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy.
---
# Hosts & HTTPS
This page explains, in plain language, what a "host" is and how this app
gets you working HTTPS without you having to think about certificates. For
the deeper system-design detail, see [Architecture](architecture.html); for
step-by-step setup, see [Installation](installation.html).
## What's a "host"?
A **host** is one entry telling the proxy: "when someone requests *this*
public address, send them to *that* server." For example: requests for
`photos.example.com` get sent to the little box in your closet running your
photo app on port 8080. Each app or service you want to reach from outside
your network — a home automation dashboard, a media server, this proxy's
own management UI — gets its own host entry.
Two settings on a host are easy to mix up:
- **Incoming host name** — the public address people type in their
browser (`photos.example.com`).
- **Target IP/port** — where the proxy actually sends the request behind
the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`).
Everything else on the host form (traffic limits, access rules,
authentication) is optional — a bare host with just those two fields
already works.
## HTTPS certificates: mostly automatic
Every public website needs an HTTPS certificate so browsers show the lock
icon instead of a scary warning. This app gets one for you automatically
from [Let's Encrypt](https://letsencrypt.org) the first time a host is
actually requested — you don't manually request, install, or renew
anything for a normal host. This happens behind the scenes using a method
called **HTTP-01**, and it's the default for every new host.
## Wildcards: one certificate for a whole family of hosts
Sometimes you want *every* subdomain under one name to work — `app1.`,
`app2.`, `anything.example.com` — without registering each one by hand and
waiting for its own certificate. That's what a **wildcard** host does: a
single host entry named `*.example.com` gets one certificate that covers
the whole family at once. Setting one up needs one extra piece of
information the automatic method above doesn't need — see
[DNS Providers](concepts-dns.html) for why.
Once a wildcard exists, you have two ways to actually use it:
- **Register nothing else, and turn on "Match any subdomain"** on the
wildcard host itself — *any* subdomain that doesn't already have its own
entry gets automatically routed to the wildcard's target the first time
it's requested. Convenient, but it means literal typos and random scan
traffic get routed too, not just the subdomains you meant to use.
- **Register each subdomain as its own host, as a "Parent Wildcard"
child** — more setup, but each subdomain can point at a different
target/server while still reusing the one wildcard certificate instead
of getting its own. This is the recommended default and is what
"Match only subdomains defined here" (the host form's default) does.
You'll see the **"Parent Wildcard"** option light up automatically on the
host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`).
## Want more detail?
This page skips the system-internals (Redis, OpenResty, the lookup service)
and the exact install steps. For those, see
[Architecture](architecture.html) and [Installation](installation.html).
[← Back to Home](index.html)
+5 -5
View File
@@ -39,11 +39,11 @@ which deep-merges, in order:
3. `conf/secrets.js` (gitignored)
4. **`app_*` environment variables** — the highest-precedence layer
The bundled `docker-compose.yml` mount `./config/proxy-secrets.js` at `/config`,
and `docker-entrypoint.sh` symlinks it into `/app/conf/secrets.js` so the app
reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is passed**
`app_*` env beats `secrets.js`, so the file is authoritative only if the matching
`app_*` env is absent. See `secrets.js.example` for the shape.
The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
the matching `app_*` env is absent. See `secrets.js.example` for the shape.
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
+20 -3
View File
@@ -8,6 +8,10 @@ description: Installing the proxy — Docker, bare metal, or as part of the unif
[← Back to Home](index.html)
> Looking for a plainer explanation of hosts, HTTPS, and DNS providers
> instead of install steps? See [Hosts & HTTPS](concepts-hosts.html) and
> [DNS Providers](concepts-dns.html).
## Quick Install (Recommended)
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
@@ -142,7 +146,8 @@ openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
Clone the repository and copy configuration files:
```bash
cd /var/www
mkdir -p /opt/theta42
cd /opt/theta42
git clone https://github.com/theta42/proxy.git
cd proxy
@@ -157,14 +162,26 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
### Step 7: Install Application
```bash
cd /var/www/proxy/nodejs
cd /opt/theta42/proxy/nodejs
npm install
```
### Step 7b: Configure Secrets
```bash
mkdir -p /etc/proxy
cp /opt/theta42/proxy/secrets.js.example /etc/proxy/secrets.js
chmod 600 /etc/proxy/secrets.js
$EDITOR /etc/proxy/secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
```
`@simpleworkjs/conf` reads this file via the `CONF_SECRETS` env var, which the
systemd unit below sets to `/etc/proxy/secrets.js`.
### Step 8: Configure Systemd Service
```bash
cp /var/www/proxy/ops/proxy.service /etc/systemd/system/proxy.service
cp /opt/theta42/proxy/ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload
systemctl enable proxy.service
systemctl start proxy.service
+5
View File
@@ -1,5 +1,10 @@
# API Documentation
> Looking for a plainer explanation of what API tokens are and when you'd
> want one, instead of a full endpoint reference? See
> [API Tokens](/docs/api-tokens) (in-app) or
> [concepts-api-tokens.md](../docs/concepts-api-tokens.md) (repo).
All API endpoints require authentication unless otherwise noted. Three
authentication methods are supported:
+12 -6
View File
@@ -100,15 +100,21 @@ app.use(async function(req, res, next) {
// Error handler. This is where `next()` will go on error
app.use(async function(err, req, res, next) {
try{
console.error(err.status || res.status, err.name, req.method, req.url);
try{
const status = err.status || 500;
console.error(status, err.name, req.method, req.url);
console.error(err.message);
console.error(err.stack);
if (err.stack) console.error(err.stack);
console.error('=========================================');
res.status(err.status || 500);
res.json({name: err.name, message: err.message, keys: err.keys});
res.status(status);
// Only expose safe, non-internal fields to the client.
const body = { name: err.name, message: err.message };
res.json(body);
}catch(error){
console.log('error in the catch all error fn....', error);
console.error('error in the catch-all error handler', error);
if (!res.headersSent) {
res.status(500).json({ name: 'Error', message: 'Internal server error' });
}
}
});
+8 -8
View File
@@ -6,10 +6,10 @@ module.exports = {
logo: "/static/img/theta42.svg", // shown in the nav; point at your own file under public/ (or an absolute URL) to white-label
userModel: 'redis', // pam, redis, ldap
ldap: {
url: 'ldap://192.168.1.55:389',
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
url: 'ldap://localhost',
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
bindPassword: '__IN SRECREST FILE__',
searchBase: 'ou=people,dc=theta42,dc=com',
searchBase: 'ou=people,dc=example,dc=com',
userFilter: '(objectClass=inetOrgPerson)',
userNameAttribute: 'uid'
},
@@ -29,11 +29,11 @@ module.exports = {
// redirectUri MUST be registered on the SSO client and match exactly.
oidc: {
enabled: true,
issuer: 'https://sso.theta42.com',
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize',
tokenEndpoint: 'https://sso.theta42.com/oauth/token',
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo',
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout',
issuer: 'https://sso.example.com',
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
tokenEndpoint: 'https://sso.example.com/oauth/token',
userinfoEndpoint: 'https://sso.example.com/oauth/userinfo',
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
clientId: '__SET_ME__',
// Where the SSO sends the user back. Must be an absolute URL reachable
// by the browser and registered on the SSO client.
+3 -3
View File
@@ -4,10 +4,10 @@
module.exports = {
userModel: 'redis', // pam, redis, ldap
ldap: {
url: 'ldap://192.168.1.55:389',
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
url: 'ldap://localhost',
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
bindPassword: '__IN SRECREST FILE__',
searchBase: 'ou=people,dc=theta42,dc=com',
searchBase: 'ou=people,dc=example,dc=com',
userFilter: '(objectClass=inetOrgPerson)',
userNameAttribute: 'uid'
},
+9 -1
View File
@@ -14,6 +14,14 @@ async function getCert(host){
}
}
async function setCert(host, cert){
try{
return await client.SET(`${host}:latest`, JSON.stringify(cert));
}catch(error){
return {}
}
}
async function deleteCert(host){
try{
console.log('looking for', host);
@@ -23,4 +31,4 @@ async function deleteCert(host){
}
}
module.exports = {getCert, deleteCert};
module.exports = {getCert, setCert, deleteCert};
+34 -1
View File
@@ -2,7 +2,7 @@
const Table = require('.');
const {Domain} = require('.').models;
const {deleteCert} = require('./cert');
const {getCert, setCert, deleteCert} = require('./cert');
const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
@@ -343,10 +343,43 @@ class Host extends Table{
}
}
// Real hostname rename. model-redis's own update() (see super.update()
// below) already handles the Redis primary-key RENAME + collision
// check, and Host.buildLookUpObj() below already rebuilds the lookup
// tree afterward -- but the cert cache (models/cert.js, `${host}:latest`)
// is a separate record keyed by hostname string that the generic field
// system doesn't know about, so it doesn't move on its own. Only
// wildcard hosts (createWildcardCert) ever populate this key -- for a
// plain HTTP-01 host this is a no-op (nothing to migrate; auto-ssl
// transparently issues a fresh cert under the new name on first
// access, same as it does for any newly-created host).
let oldHost = this.host;
let renaming = data && typeof data.host === 'string' && data.host !== oldHost;
if(renaming){
let cert = await getCert(oldHost);
if(cert && Object.keys(cert).length) await setCert(data.host, cert);
}
let out = await super.update(data, ...args)
await this.bustCache(this.host);
await Host.buildLookUpObj();
if(renaming){
await deleteCert(oldHost);
// Work around a model-redis bug (as of ^1.5.0): super.update()'s
// field-application loop iterates _keyMap's definition order and
// only reassigns this[_key] (this.host) to the NEW value once it
// reaches the `host` field itself -- but `updated_on` (always:
// true, so always included) is defined BEFORE `host` in _keyMap,
// so it gets HSET while this.host is still the OLD name. Redis's
// HSET on a non-existent key (the old hash, just RENAMEd away)
// silently recreates it -- leaving a stray, incomplete hash under
// the old hostname that makes Host.exists(oldHost) wrongly return
// true forever, blocking that name from ever being reused.
await this.constructor.redisClient.DEL(`${conf.redis.prefix || ''}Host_${oldHost}`);
}
return out;
} catch(error){
throw error;
+14 -5
View File
@@ -90,10 +90,19 @@ User.register();
var defaultUser = 'proxyadmin2'
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in proxy-secrets.js to a generated password so this
// bootstrap account isn't left at the well-known default (username ==
// password == "proxyadmin2"). Only used on first creation -- once the
// account exists this is never read again, so it's safe to leave set.
var defaultPass = (conf.auth && conf.auth.localAdminPass) || defaultUser;
// bootstrap account isn't left at a well-known default. Only used on first
// creation -- once the account exists this is never read again, so it's
// safe to leave set. If unset, a random password is generated and printed
// once; save it from the log or set auth.localAdminPass explicitly.
var defaultPass = (conf.auth && conf.auth.localAdminPass);
if (!defaultPass) {
defaultPass = crypto.randomBytes(16).toString('hex');
console.warn(`====================================================================`);
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
console.warn(`${defaultPass}`);
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
console.warn(`====================================================================`);
}
try{
let user = await User.get(defaultUser);
}catch(error){
@@ -103,7 +112,7 @@ User.register();
password: defaultPass,
created_by: defaultUser
});
console.log(defaultUser, 'created', user);
console.log(defaultUser, 'created');
}catch(error){
console.error(error)
}
+40 -11
View File
@@ -1,17 +1,17 @@
{
"name": "proxy-api",
"version": "1.1.8",
"version": "1.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.1.8",
"version": "1.1.16",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/conf": "^1.2.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -21,7 +21,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
"jq-repeat": "^2.1.0",
"jq-repeat": "^2.2.0",
"jquery": "^4.0.0",
"ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0",
@@ -32,7 +32,8 @@
"p2psub": "^0.2.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"tld-extract": "^2.1.0"
"tld-extract": "^2.1.0",
"xss": "^1.0.15"
},
"devDependencies": {
"nodemon": "^3.1.11"
@@ -281,9 +282,9 @@
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
"integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
@@ -611,6 +612,12 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -722,6 +729,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1375,9 +1388,9 @@
"license": "MIT"
},
"node_modules/jq-repeat": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
"integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -2251,6 +2264,22 @@
"optional": true
}
}
},
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
"license": "MIT",
"dependencies": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"bin": {
"xss": "bin/xss"
},
"engines": {
"node": ">= 0.10.0"
}
}
}
}
+6 -6
View File
@@ -1,7 +1,6 @@
{
"name": "proxy-api",
"version": "1.1.8",
"private": true,
"version": "1.1.16",
"author": [
{
"name": "William Mantly",
@@ -22,7 +21,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/conf": "^1.2.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -32,7 +31,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
"jq-repeat": "^2.1.0",
"jq-repeat": "^2.2.0",
"jquery": "^4.0.0",
"ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0",
@@ -43,12 +42,13 @@
"p2psub": "^0.2.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"tld-extract": "^2.1.0"
"tld-extract": "^2.1.0",
"xss": "^1.0.15"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.theta42.com/wmantly/proxy.git"
"url": "https://github.com/theta42/proxy.git"
},
"devDependencies": {
"nodemon": "^3.1.11"
+72 -2
View File
@@ -5,6 +5,7 @@ const path = require('path');
const router = require('express').Router();
const {rateLimit} = require('express-rate-limit');
const {marked} = require('marked');
const xss = require('xss');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
@@ -33,6 +34,15 @@ const values = {
// An explicit slug -> file allowlist, never a user-suppliable path, so
// there's no way to make this read outside the doc set below.
const DOCS = {
// Plain-language "what is this and why would I use it" guides -- linked
// directly from the relevant card in the UI (see the help icon on each
// card). Each links onward to the deeper technical doc below for readers
// who want the system-design/protocol-level detail.
hosts: {title: 'Hosts & HTTPS', file: path.join(__dirname, '../../docs/concepts-hosts.md')},
dns: {title: 'DNS Providers', file: path.join(__dirname, '../../docs/concepts-dns.md')},
access: {title: 'Users, Groups & Permissions', file: path.join(__dirname, '../../docs/concepts-access.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
@@ -54,24 +64,84 @@ function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
// docs/*.md files (not the repo-root README/CHANGELOG/api.md) carry Jekyll
// front matter for the GitHub Pages build and a "← Back to Home" link back
// to that site's index -- both meaningless here (this viewer has its own
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
// matter isn't regular markdown: it rendered as a garbled heading + stray
// <hr> at the top of every page. Strip both before rendering.
function stripJekyllCruft(content) {
return content
.replace(/^---\n[\s\S]*?\n---\n/, '')
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
}
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
// Pages build, which is what these same .md files also feed) and
// "index.html" for the docs home -- neither resolves here, where a doc lives
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
// actually exist, so an unrelated "foo.html" link is left alone.
// Docs are also linked by their real filename stem (e.g. "concepts-hosts.html"
// for docs/concepts-hosts.md) -- the correct, working link on the Jekyll/
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
// this viewer's own short slugs (DOCS keys, e.g. "hosts"), so also resolve by
// filename as a fallback -- one link written in a doc works correctly on
// both targets, rather than needing two different link forms.
const slugByFilename = Object.fromEntries(
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
);
function fixDocLinks(html) {
return html
.replace(/href="index\.html"/g, 'href="/docs"')
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
const slug = DOCS[name] ? name : slugByFilename[name];
return slug ? `href="/docs/${slug}"` : match;
});
}
router.use(docsLimiter);
router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList});
});
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'});
try {
const content = fs.readFileSync(doc.file, 'utf8');
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
res.render('docs_page', {
...values,
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixImagePaths(marked(content)),
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
});
} catch (error) {
next(error);
+23
View File
@@ -128,6 +128,29 @@ router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.host
}
});
// Is there a wildcard host that could serve as :item's parent (i.e. an
// already-issued cert :item could reuse instead of getting its own)? Two
// cases, covered by two different lookups: a brand-new subdomain that has
// never been created (lookUp()'s normal wildcard fallback finds it, since
// the name has no leaf of its own yet), and an ALREADY-EXISTING host or the
// wildcard's own base domain (lookUp() would just resolve to that host's
// own leaf -- lookUpWildcardParent() checks the sibling "*" slot instead;
// see its comment in models/host.js). Used by the host create/edit form to
// decide whether to offer "Parent Wildcard" as a challenge type.
router.get('/wildcard-parent/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
try{
let match = Model.lookUp(req.params.item);
if(!match || !match.is_wildcard){
match = Model.lookUpWildcardParent(req.params.item);
}
return res.json({
results: (match && match.is_wildcard) ? match : null,
});
}catch(error){
return next(error);
}
});
// The full lookup tree exposes every host, so restrict it to admins.
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
try{
+51 -1
View File
@@ -187,6 +187,52 @@ describe('Host wildcard base-domain lookup', () => {
});
});
/**
* Tests for the exact fallback combination used by
* routes/host.js's GET /wildcard-parent/:item (and, via hostMatchWildcard(),
* the host create/edit form's "Parent Wildcard" option) -- lookUp() first
* (handles a brand-new subdomain that has no leaf of its own yet), falling
* back to lookUpWildcardParent() only when lookUp() didn't resolve to a
* wildcard (handles an ALREADY-EXISTING host, which lookUp() would resolve
* to its own record). Regression coverage for the edit-form bug where the
* "Parent Wildcard" option stayed permanently greyed out for an existing
* host, because the route only ever tried lookUp().
*/
describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent)', () => {
let Host;
before(async () => {
Host = createMockHostClassWithWildcardParentFix();
});
function findWildcardParent(host){
let match = Host.lookUp(host);
if(!match || !match.is_wildcard) match = Host.lookUpWildcardParent(host);
return (match && match.is_wildcard) ? match : null;
}
test('finds the wildcard for a brand-new subdomain that was never created', async () => {
await populateTree(Host, ['*.cool.mysite.com']);
const result = findWildcardParent('newthing.cool.mysite.com');
assert.ok(result);
assert.strictEqual(result.host, '*.cool.mysite.com');
});
test('finds the wildcard for the wildcard\'s own base domain, whether or not it is already a plain host', async () => {
await populateTree(Host, ['*.cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com').host, '*.cool.mysite.com');
await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com').host, '*.cool.mysite.com');
});
test('returns null when the host has no wildcard sibling at all', async () => {
await populateTree(Host, ['cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com'), null);
});
});
/**
* Same mock shape as createMockHostClass() above, plus the parent-record
* stamp in the tree-population loop and the lookUpWildcardParent() method --
@@ -243,7 +289,11 @@ async function populateTree(Host, hosts) {
}
if(fragments.length === 0){
pointer[fragment]['#record'] = {host};
// is_wildcard mirrors the real Host model's own field (set
// whenever a host is DNS-01 wildcard-issued, i.e. starts with
// "*."), needed by tests that check it the same way the real
// /wildcard-parent/:item route does.
pointer[fragment]['#record'] = {host, is_wildcard: host.startsWith('*.')};
if(fragment === '*' && !pointer['#record']){
pointer['#record'] = pointer[fragment]['#record'];
+6 -4
View File
@@ -27,12 +27,14 @@ class SocketServerJson {
this.onClientClose = new CallbackQueue(args.onClientClose);
this.onClientError = new CallbackQueue(args.onClientError);
// Set socket file permissions after listening
// 777 is acceptable here for single-use container environments
// Wrapped in try-catch as chmod may fail in test/restricted environments
// Set socket file permissions after listening. 660 (owner + group read/write)
// is the safest default; the Docker image runs both processes as root, and
// bare-metal operators should ensure the proxy service and openresty share a
// group when running as separate users. Wrapped in try-catch as chmod may
// fail in test/restricted environments.
this.onListen.push(() => {
try {
fs.chmodSync(this.socketFile, '777');
fs.chmodSync(this.socketFile, '660');
} catch(err) {
// Chmod may fail in test environments or certain filesystems
// Socket will still work with default permissions
+2
View File
@@ -125,6 +125,7 @@
Add DNS Provider
</span>
<span class="float-end">
<a href="/docs/dns" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
@@ -226,6 +227,7 @@
<div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="card-title">Dynamic A Records</span>
<a href="/docs/dns" class="text-reset ms-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<span class="ms-auto text-muted small">
This server's public IP:
<span class="badge text-bg-primary fs-6"><i class="fa-solid fa-globe me-1"></i><span id="ddns-current-ip">…</span></span>
+42 -1
View File
@@ -11,7 +11,12 @@
A local copy of this project's documentation, readable from the
running app -- no internet access required.
</p>
<ul class="list-group">
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
</div>
<div id="docs-search-results" style="display:none"></div>
<ul id="docs-list" class="list-group">
<% docs.forEach(function(doc){ %>
<li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -22,5 +27,41 @@
</div>
</div>
</div>
<script type="text/javascript">
var docsSearchTimer;
function docsSearch(q){
clearTimeout(docsSearchTimer);
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
}
function docsSearchRun(q){
q = (q || '').trim();
var $results = $('#docs-search-results');
var $list = $('#docs-list');
if(!q){
$results.hide().empty();
$list.show();
return;
}
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
// not under /api, unlike the rest of this app's endpoints.
$.getJSON('/docs/search', {q: q}, function(data){
$list.hide();
$results.empty().show();
var hits = (data && data.results) || [];
if(!hits.length){
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
return;
}
var $ul = $('<ul class="list-group"></ul>');
hits.forEach(function(hit){
var $li = $('<li class="list-group-item"></li>');
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
$ul.append($li);
});
$results.append($ul);
});
}
</script>
<%- include('bottom') %>
+1
View File
@@ -86,6 +86,7 @@
<div class="card-header text-center">
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
<span class="card-title">Add Group</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
+65 -9
View File
@@ -39,6 +39,7 @@
// Parse the JSON object for a host to something the UI wants
function hostParseRow(host) {
host['created_on_text'] = moment(host['created_on'], "x").fromNow();
host['updated_on_text'] = moment(host['updated_on'], "x").fromNow();
host['wildcard_expires_text'] = moment(host['wildcard_expires'], "x").fromNow();
host['targetssl_text'] = host['targetssl'] ? 'https://' : 'http://';
@@ -191,6 +192,7 @@
let $f = $(form);
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
$f.find('[name=host]').prop('disabled', false);
$('#host-rename-help').hide();
if($f.validateClear) $f.validateClear();
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
@@ -210,7 +212,7 @@
hostModal().show();
}
function hostEditOpen(host){
async function hostEditOpen(host){
hostFormReset();
let h = $.scope.hosts.getByKey(host);
let $f = $('#hostForm');
@@ -247,11 +249,40 @@
hostAuthModeChanged(authMode);
hostRenderBasicAuthUsers(host, h.basicauth_users);
// The host name is the key; it can't change on edit. Wildcard hosts can
// still toggle their matching mode.
$f.find('[name=host]').prop('disabled', true);
// The host name is the Redis record's key -- renaming it is a real
// migration (see Host.prototype.update() in models/host.js), scoped
// there to plain hosts only: a wildcard's children reference it by
// name (wildcard_parent) and a cache entry's parent likewise, so
// renaming either would orphan those pointers. Keep the field locked
// for those cases; a plain host can be renamed freely.
let hostRenameable = !h.is_wildcard && !h.wildcard_parent && !h.is_cache;
$f.find('[name=host]').prop('disabled', !hostRenameable);
$('#host-rename-help').toggle(!hostRenameable);
// Reflect + enable the challenge-type options actually available for
// this host. Setting the host field's .val() above does not fire a
// 'keyup' event, so without this the "Parent Wildcard" option stayed
// permanently greyed out on edit even when a valid parent wildcard
// existed -- it only ever got un-greyed by the user re-typing the
// hostname (the keyup handler further down).
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
.addClass('challengeType-container');
if(h.is_wildcard){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#challengeType-DNS-01-wildcard').prop('checked', true);
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}else{
let wildcardParent = await hostMatchWildcard(h.host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
}
if(h.wildcard_parent){
$('#challengeType-wildcardChild').prop('checked', true);
}else{
$('#challengeType-HTTP-01').prop('checked', true);
}
}
hostModal().show();
@@ -298,10 +329,12 @@
async function hostMatchWildcard(host){
try{
let res = await app.api.get(`host/lookup/${host}`);
if(res.results && res.results.is_wildcard){
return res.results;
}
// Not /host/lookup/ -- that resolves an ALREADY-EXISTING host to its
// own record, not a sibling wildcard (see the route's comment). This
// dedicated endpoint correctly finds a usable wildcard parent whether
// @host is brand new or already exists as its own host.
let res = await app.api.get(`host/wildcard-parent/${host}`);
return res.results || false;
}catch(error){
return false;
}
@@ -386,6 +419,7 @@
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
<span class="card-title fw-bold">Proxy List</span>
<span class="ms-auto">
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
<i class="fa-solid fa-broom"></i>
Clear cache
@@ -423,6 +457,7 @@
<th>SSL Expire</th>
<th>Host Name</th>
<th>target</th>
<th class="hidden-xs">Created</th>
<th class="hidden-xs">Updated</th>
<th>Actions</th>
</thead>
@@ -454,6 +489,11 @@
<td>
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
</td>
<td class="hidden-xs momentFromNow" data-date="{{ created_on }}" title="Created by {{ created_by }}">
{{ created_on_text }}
<br />
<small class="text-muted">{{ created_by }}</small>
</td>
<td class="hidden-xs momentFromNow" data-date="{{ updated_on }}" >
{{ updated_on_text }}
</td>
@@ -513,13 +553,14 @@
<div class="modal-content card border-0">
<div class="modal-header">
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs" role="tablist">
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
@@ -542,6 +583,12 @@
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
@@ -582,6 +629,7 @@
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
</div>
</div>
</div>
@@ -620,6 +668,14 @@
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>
+2
View File
@@ -85,6 +85,7 @@
<i class="fa-solid fa-user-shield"></i>
</span>
<span class="card-title">Add Permission</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
@@ -140,6 +141,7 @@
<i class="fa-solid fa-list-check"></i>
</span>
<span class="card-title">Permissions</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
+3 -1
View File
@@ -207,7 +207,9 @@
<div class="row mt-3">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div>
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
+2
View File
@@ -66,6 +66,7 @@
Add New User
</span>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
@@ -107,6 +108,7 @@
User List
</span>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
+48 -3
View File
@@ -9,19 +9,29 @@
# update is just "sync the repo + reload" -- the files under /etc always track
# the repo, so there is nothing to re-copy.
#
# Secrets live at $SECRETS_FILE (/etc/proxy/secrets.js by default), outside the
# repo checkout so they survive the hard reset below. First run seeds it from
# secrets.js.example (placeholders you must fill in); later runs never touch
# an existing file.
#
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
# the repo (any drift on the box is discarded).
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=)
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=)
set -euo pipefail
# Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}"
REPO_DIR="${REPO_DIR:-/var/www/proxy}"
REPO_DIR="${REPO_DIR:-/opt/theta42/proxy}"
BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2
@@ -34,6 +44,19 @@ link(){
echo "linked $2 -> $1"
}
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi
echo "==> Base packages"
apt-get update
apt-get install -y --no-install-recommends \
@@ -134,6 +157,20 @@ else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
echo "==> Secrets file at ${SECRETS_FILE}"
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
if [ ! -f "$SECRETS_FILE" ]; then
cp "$REPO_DIR/secrets.js.example" "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} from secrets.js.example -- EDIT IT before the proxy will work:"
echo " \$EDITOR ${SECRETS_FILE}"
echo " then re-run this script (or: sudo systemctl restart proxy)"
else
echo " ${SECRETS_FILE} already exists, leaving it untouched"
fi
echo "==> Symlink config from the repo"
install -d /etc/openresty/sites-enabled /var/log/nginx
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
@@ -162,4 +199,12 @@ else
exit 1
fi
echo "==> Done. Update later with: sudo BRANCH=${BRANCH} $0"
echo "==> Done."
if [ -z "$CURRENT_VERSION" ]; then
echo " Installed v${NEW_VERSION}."
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo " Already up to date (v${NEW_VERSION})."
else
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
+5 -1
View File
@@ -1,5 +1,9 @@
listen 443 ssl http2;
listen 443 ssl;
listen 4443 ssl;
# The "http2" listen parameter is deprecated since nginx 1.25.1 in favor of
# this standalone directive, which applies to every "listen ... ssl" in the
# server block (both 443 and 4443 here).
http2 on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
+3 -2
View File
@@ -8,9 +8,10 @@ Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/var/www/proxy/nodejs
WorkingDirectory=/opt/theta42/proxy/nodejs
Environment="NODE_ENV=production"
ExecStart=/usr/bin/env node /var/www/proxy/nodejs/bin/www
Environment="CONF_SECRETS=/etc/proxy/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/proxy/nodejs/bin/www
[Install]
WantedBy=multi-user.target
+7 -4
View File
@@ -6,13 +6,16 @@
// direct LDAP client for user lookups. This file supplies that wiring.
//
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
// CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
// file is authoritative only if the matching app_* env is absent.
//
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. Values
// here override conf/base.js and win over <environment>.js.
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
// run (with placeholders for the values it can't guess) and points the
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
// over <environment>.js.
//
// Only the keys the app reads are listed below. The `stack` key is read by the
// theta-env orchestrator (setup.sh) and ignored by the app.