Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e1aa666e | |||
| f8d620f4d3 | |||
| 899c4d91d6 | |||
| 0f268fdcae | |||
| da0ed0e2ad | |||
| f0eadbc2d7 | |||
| a02ca4d3e7 | |||
| 2ff2bf9ea7 | |||
| 355a9d68e5 | |||
| 11f6c4df36 | |||
| a567bf6c51 | |||
| b9bdf36638 | |||
| 426fa111ec | |||
| f0b282b679 | |||
| 4f1fce367e | |||
| 9eb3dfa2e6 | |||
| a40da55993 | |||
| e5df0d3370 | |||
| fcd73169e0 | |||
| 876ea6cfd0 | |||
| 9100e92549 | |||
| 9a83fb8252 | |||
| 17b903e228 | |||
| 11f44176c0 | |||
| b4d971b508 | |||
| 28f1c53d06 | |||
| 8c3a263937 | |||
| e249b4e168 | |||
| 34b1413c96 | |||
| eded87b6f9 | |||
| a57f3f03f6 | |||
| 5b08eecca9 | |||
| 7c0cb5eabd | |||
| 3f0d6fb438 | |||
| 6cd3a5bc58 | |||
| 88cf5cf281 | |||
| 526545ee68 | |||
| ecf064b9ae | |||
| fd71485960 |
+91
-1
@@ -6,6 +6,84 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`.
|
||||||
|
- **Couldn't register a wildcard's own base domain as a host.** A wildcard cert's `altNames` already cover both the base domain and `*.base domain`, but the lookup tree stores the wildcard one level below its base domain, and a lookup for the bare base domain landed on that empty parent node and found nothing — even though the already-issued cert covers it. `buildLookUpObj()` now also stamps the parent node so this resolves correctly, without re-issuing or duplicating the cert.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-07-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Hosts admin UI's Authentication tab radios (Off / Basic / SSO) had no shared `name`, so clicking one didn't uncheck the others -- multiple options could appear selected at once. Added `name="auth_mode"` to restore standard exclusive radio-group behavior.
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-07-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bumped `jq-repeat` 2.0.1 -> 2.1.0. Fixed real breakage: `users.ejs`/`groups.ejs`/`permissions.ejs` called the removed `$.scope.X.__setPut(fn)`/`__setTake(fn)` setter-method API; insert/remove row hooks are now set via direct property assignment (`$.scope.X.__put = fn`), matching 2.1.0's API.
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **White-label**: `<title>`, the navbar brand text, and the nav logo image were hardcoded "Proxy - Theta 42"/"Dynamic Proxy". Now driven by new `conf.name`/`conf.logo` keys (defaults unchanged). Footer attribution (copyright, `theta42.com` link, GitHub/license links) and favicon are left as-is. Closes [#45](https://github.com/theta42/proxy/issues/45).
|
||||||
|
|
||||||
## [1.1.3] - 2026-07-16
|
## [1.1.3] - 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -34,7 +112,19 @@ 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.
|
- 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.
|
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.3...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
|
||||||
|
[1.1.5]: https://github.com/theta42/proxy/compare/v1.1.4...v1.1.5
|
||||||
|
[1.1.4]: https://github.com/theta42/proxy/compare/v1.1.3...v1.1.4
|
||||||
[1.1.3]: https://github.com/theta42/proxy/compare/v1.1.2...v1.1.3
|
[1.1.3]: https://github.com/theta42/proxy/compare/v1.1.2...v1.1.3
|
||||||
[1.1.2]: https://github.com/theta42/proxy/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://github.com/theta42/proxy/compare/v1.1.1...v1.1.2
|
||||||
[1.1.1]: https://github.com/theta42/proxy/compare/v1.1.0...v1.1.1
|
[1.1.1]: https://github.com/theta42/proxy/compare/v1.1.0...v1.1.1
|
||||||
|
|||||||
+14
-5
@@ -227,16 +227,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
|
`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
|
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
|
||||||
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
`/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
||||||
starts `proxy.service`. Re-run it to update.
|
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
|
```bash
|
||||||
sudo ./ops/install.sh
|
sudo ./ops/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC +
|
Configuration is file-based: on first run the installer seeds
|
||||||
LDAP values (see `nodejs/conf/base.js` for the shape), then
|
`/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
|
||||||
`sudo systemctl restart proxy`.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,15 @@ This installer will:
|
|||||||
- Install and configure Redis
|
- Install and configure Redis
|
||||||
- Set up SSL fallback certificates
|
- Set up SSL fallback certificates
|
||||||
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
|
- 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
|
- Configure systemd service
|
||||||
- Start the proxy 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)
|
## Logs (Docker)
|
||||||
|
|
||||||
The all-in-one image runs OpenResty in the foreground and the Node app in the
|
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:
|
Clone and install:
|
||||||
```bash
|
```bash
|
||||||
cd /var/www
|
mkdir -p /opt/theta42
|
||||||
|
cd /opt/theta42
|
||||||
git clone https://github.com/theta42/proxy.git
|
git clone https://github.com/theta42/proxy.git
|
||||||
cd proxy/nodejs
|
cd proxy/nodejs
|
||||||
npm install
|
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:
|
Create systemd service:
|
||||||
```bash
|
```bash
|
||||||
cp ops/proxy.service /etc/systemd/system/proxy.service
|
cp ../ops/proxy.service /etc/systemd/system/proxy.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable proxy.service
|
systemctl enable proxy.service
|
||||||
systemctl start proxy.service
|
systemctl start proxy.service
|
||||||
|
|||||||
+10
-9
@@ -9,13 +9,14 @@
|
|||||||
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
|
# 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`.
|
# 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
|
# The app reads its config from conf/base.js deep-merged with a secrets file
|
||||||
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in
|
# 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
|
# 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
|
# 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
|
# points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
|
||||||
# from the file (no app_* env needed). Without the mount, supply the same config
|
# from the mounted file (no app_* env needed, no write access to /app/conf
|
||||||
# via `app_*` env (compose `environment:` / `env_file:`).
|
# 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
|
# 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
|
# 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 ─────────────────────────────────────────
|
# ── Optional: mount proxy secrets.js ─────────────────────────────────────────
|
||||||
# When /config/proxy-secrets.js is present (unified theta-env stack, or any
|
# 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
|
# 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
|
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_*
|
||||||
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
|
# 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
|
# (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
|
# 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).
|
# absent the app falls back to app_* env (compose environment / env_file).
|
||||||
if [[ -f /config/proxy-secrets.js ]]; then
|
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)"
|
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+41
-3
@@ -1,9 +1,47 @@
|
|||||||
title: Proxy
|
title: Proxy
|
||||||
description: A reverse proxy and HTTPS termination service using OpenResty/nginx with a management API and web GUI
|
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with an OIDC + LDAP-aware management API and web GUI.
|
||||||
theme: jekyll-theme-cayman
|
url: "https://theta42.github.io"
|
||||||
show_downloads: false
|
baseurl: "/proxy"
|
||||||
|
logo: /assets/img/theta42.svg
|
||||||
|
lang: en_US
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- jekyll-seo-tag
|
||||||
|
- jekyll-sitemap
|
||||||
|
|
||||||
github:
|
github:
|
||||||
repository_url: https://github.com/theta42/proxy
|
repository_url: https://github.com/theta42/proxy
|
||||||
zip_url: https://github.com/theta42/proxy/archive/refs/heads/master.zip
|
zip_url: https://github.com/theta42/proxy/archive/refs/heads/master.zip
|
||||||
tar_url: https://github.com/theta42/proxy/archive/refs/heads/master.tar.gz
|
tar_url: https://github.com/theta42/proxy/archive/refs/heads/master.tar.gz
|
||||||
repository_name: theta42/proxy
|
repository_name: theta42/proxy
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- title: Home
|
||||||
|
page: /
|
||||||
|
icon: fa-house
|
||||||
|
- title: Installation
|
||||||
|
page: /installation.html
|
||||||
|
icon: fa-download
|
||||||
|
- title: Architecture
|
||||||
|
page: /architecture.html
|
||||||
|
icon: fa-sitemap
|
||||||
|
- title: API
|
||||||
|
page: /api.html
|
||||||
|
icon: fa-code
|
||||||
|
- title: Docker
|
||||||
|
page: /docker.html
|
||||||
|
icon: fa-box
|
||||||
|
- title: Contributing
|
||||||
|
page: /contributing.html
|
||||||
|
icon: fa-code-branch
|
||||||
|
- title: Changelog
|
||||||
|
url: https://github.com/theta42/proxy/blob/master/CHANGELOG.md
|
||||||
|
icon: fa-list
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
- scope:
|
||||||
|
path: ""
|
||||||
|
type: "pages"
|
||||||
|
values:
|
||||||
|
layout: default
|
||||||
|
image: /assets/img/theta42.svg
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
|
||||||
|
|
||||||
|
{% seo title=false %}
|
||||||
|
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||||
|
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||||
|
{{ site.title }}
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% for item in site.nav %}
|
||||||
|
<li class="nav-item">
|
||||||
|
{% if item.page %}
|
||||||
|
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||||
|
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||||
|
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||||
|
<div class="container-fluid py-4 py-md-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-10 col-xl-8">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-body p-4 p-md-5 site-content">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="py-3 bg-dark text-light mt-auto">
|
||||||
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||||
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||||
|
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||||
|
</a>
|
||||||
|
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||||
|
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||||
|
</span>
|
||||||
|
<span class="d-flex align-items-center gap-3">
|
||||||
|
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
|
</a>
|
||||||
|
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-solid fa-list"></i> Changelog
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: API Reference
|
title: API Reference
|
||||||
|
description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# API Documentation
|
# API Documentation
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Architecture
|
title: Architecture
|
||||||
|
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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
|
## System Overview
|
||||||
|
|
||||||
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
|
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
||||||
|
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
||||||
|
generic Jekyll theme. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f4f5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand img {
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown content typography, scoped to the card body so it doesn't leak
|
||||||
|
into the nav/footer. */
|
||||||
|
.site-content h1:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h1,
|
||||||
|
.site-content h2,
|
||||||
|
.site-content h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h2 {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h3 {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a {
|
||||||
|
color: #a3671f;
|
||||||
|
text-decoration-color: rgba(163, 103, 31, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a:hover {
|
||||||
|
color: #8a5a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre {
|
||||||
|
background-color: #212529;
|
||||||
|
color: #f8f9fa;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content code {
|
||||||
|
color: #a3671f;
|
||||||
|
background-color: #f4f0e8;
|
||||||
|
padding: .15em .4em;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre code {
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th,
|
||||||
|
.site-content table td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content blockquote {
|
||||||
|
border-left: 4px solid #C59341;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
background-color: #f8f6f1;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
||||||
|
two-up desktop layout -- stack them on narrow screens instead of
|
||||||
|
squeezing to illegibility. */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.site-content img[width] {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content hr {
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Network nodes -->
|
||||||
|
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
||||||
|
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
||||||
|
|
||||||
|
<!-- Connection lines -->
|
||||||
|
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C59341" />
|
||||||
|
<stop offset="20%" stop-color="#E4B869" />
|
||||||
|
<stop offset="40%" stop-color="#FBF0B9" />
|
||||||
|
<stop offset="60%" stop-color="#DFB260" />
|
||||||
|
<stop offset="80%" stop-color="#BC8837" />
|
||||||
|
<stop offset="100%" stop-color="#A36F28" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" />
|
||||||
|
<stop offset="40%" stop-color="#F5E3B5" />
|
||||||
|
<stop offset="70%" stop-color="#D4A343" />
|
||||||
|
<stop offset="100%" stop-color="#8A5A16" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g filter="url(#drop-shadow)">
|
||||||
|
<g fill="url(#gold-grad)">
|
||||||
|
<path d="M 200,40
|
||||||
|
C 290,40 350,110 350,200
|
||||||
|
C 350,290 290,360 200,360
|
||||||
|
C 110,360 50,290 50,200
|
||||||
|
C 50,110 110,40 200,40 Z
|
||||||
|
M 200,75
|
||||||
|
C 130,75 88,130 88,200
|
||||||
|
C 88,270 130,325 200,325
|
||||||
|
C 270,325 312,270 312,200
|
||||||
|
C 312,130 270,75 200,75 Z"
|
||||||
|
fill-rule="evenodd" />
|
||||||
|
|
||||||
|
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||||
|
|
||||||
|
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="200" y="222"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="78"
|
||||||
|
font-weight="900"
|
||||||
|
fill="url(#text-grad)"
|
||||||
|
text-anchor="middle"
|
||||||
|
letter-spacing="-2">42</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Contributing
|
title: Contributing
|
||||||
|
description: How to contribute to the proxy — dev setup, tests, and code conventions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Contributing Guide
|
# Contributing Guide
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Docker
|
title: Docker
|
||||||
|
description: Running the proxy's all-in-one Docker image — OpenResty, the management app, and Redis in one container.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docker Deployment
|
# Docker Deployment
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Home
|
title: Home
|
||||||
|
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Proxy
|
# Proxy
|
||||||
|
|||||||
+21
-3
@@ -1,12 +1,17 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Installation
|
title: Installation
|
||||||
|
description: Installing the proxy — Docker, bare metal, or as part of the unified theta-env stack.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Installation Guide
|
# Installation Guide
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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)
|
## Quick Install (Recommended)
|
||||||
|
|
||||||
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
|
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
|
||||||
@@ -141,7 +146,8 @@ openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
|
|||||||
Clone the repository and copy configuration files:
|
Clone the repository and copy configuration files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www
|
mkdir -p /opt/theta42
|
||||||
|
cd /opt/theta42
|
||||||
git clone https://github.com/theta42/proxy.git
|
git clone https://github.com/theta42/proxy.git
|
||||||
cd proxy
|
cd proxy
|
||||||
|
|
||||||
@@ -156,14 +162,26 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
|||||||
### Step 7: Install Application
|
### Step 7: Install Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/proxy/nodejs
|
cd /opt/theta42/proxy/nodejs
|
||||||
npm install
|
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
|
### Step 8: Configure Systemd Service
|
||||||
|
|
||||||
```bash
|
```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 daemon-reload
|
||||||
systemctl enable proxy.service
|
systemctl enable proxy.service
|
||||||
systemctl start proxy.service
|
systemctl start proxy.service
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://theta42.github.io/proxy/sitemap.xml
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# API Documentation
|
# 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
|
All API endpoints require authentication unless otherwise noted. Three
|
||||||
authentication methods are supported:
|
authentication methods are supported:
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Using https://github.com/simpleworkjs/conf to handle configuration
|
// Using https://github.com/simpleworkjs/conf to handle configuration
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
name: "Dynamic Proxy", // displayed in the UI
|
||||||
|
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
|
userModel: 'redis', // pam, redis, ldap
|
||||||
ldap: {
|
ldap: {
|
||||||
url: 'ldap://192.168.1.55:389',
|
url: 'ldap://192.168.1.55:389',
|
||||||
|
|||||||
@@ -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){
|
async function deleteCert(host){
|
||||||
try{
|
try{
|
||||||
console.log('looking for', host);
|
console.log('looking for', host);
|
||||||
@@ -23,4 +31,4 @@ async function deleteCert(host){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {getCert, deleteCert};
|
module.exports = {getCert, setCert, deleteCert};
|
||||||
|
|||||||
+96
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const {Domain} = require('.').models;
|
const {Domain} = require('.').models;
|
||||||
const {deleteCert} = require('./cert');
|
const {getCert, setCert, deleteCert} = require('./cert');
|
||||||
const ModelPs = require('../utils/model_pubsub');
|
const ModelPs = require('../utils/model_pubsub');
|
||||||
|
|
||||||
const tldExtract = require('tld-extract').parse_host;
|
const tldExtract = require('tld-extract').parse_host;
|
||||||
@@ -320,12 +320,66 @@ class Host extends Table{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(...args){
|
async update(data, ...args){
|
||||||
try{
|
try{
|
||||||
let out = await super.update(...args)
|
// Mirror Host.create()'s challengeType handling (lines above) so an
|
||||||
|
// existing HTTP-01 host can be attached to a parent wildcard's cert
|
||||||
|
// after creation -- previously this was silently dropped since only
|
||||||
|
// create() understood challengeType, leaving no way to convert an
|
||||||
|
// existing host onto a wildcard once one was issued.
|
||||||
|
if(data && data.challengeType === 'wildcardChild'){
|
||||||
|
// Not Host.lookUp() -- this.host already has its own leaf in the
|
||||||
|
// tree (it already exists), so a plain lookUp() would just find
|
||||||
|
// itself. lookUpWildcardParent() checks the sibling "*" slot
|
||||||
|
// instead. See its comment for why create()'s own wildcardChild
|
||||||
|
// branch doesn't need this (a host being newly created hasn't
|
||||||
|
// claimed its own leaf yet, so plain lookUp() already falls
|
||||||
|
// through to the wildcard correctly there).
|
||||||
|
let parentHost = Host.lookUpWildcardParent(this.host);
|
||||||
|
if(parentHost && parentHost.is_wildcard){
|
||||||
|
data.wildcard_parent = parentHost.host;
|
||||||
|
}else{
|
||||||
|
throw new Error(`No parent wild card for ${this.host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 this.bustCache(this.host);
|
||||||
await Host.buildLookUpObj();
|
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;
|
return out;
|
||||||
} catch(error){
|
} catch(error){
|
||||||
throw error;
|
throw error;
|
||||||
@@ -385,6 +439,25 @@ class Host extends Table{
|
|||||||
// #record denotes a leaf node on this tree.
|
// #record denotes a leaf node on this tree.
|
||||||
if(fragments.length === 0){
|
if(fragments.length === 0){
|
||||||
pointer[fragment]['#record'] = await this.get(host)
|
pointer[fragment]['#record'] = await this.get(host)
|
||||||
|
|
||||||
|
// A single-level wildcard's issued cert also covers its own
|
||||||
|
// base domain (createWildcardCert requests altNames:
|
||||||
|
// [domain, *.domain] -- see utils/letsencrypt.js), but the
|
||||||
|
// base domain sits one level ABOVE the wildcard's own leaf
|
||||||
|
// in this tree (e.g. "*.cool.mysite.com" is a child of the
|
||||||
|
// node for "cool.mysite.com"). Without this, looking up the
|
||||||
|
// bare base domain when it has no host of its own falls
|
||||||
|
// through to nothing, even though the already-issued cert
|
||||||
|
// covers it. `pointer` here is still that parent node
|
||||||
|
// (reassigned to the child only below) -- stamp it too, but
|
||||||
|
// only if a real, explicitly-created host at that exact
|
||||||
|
// name hasn't already claimed this leaf (order-independent:
|
||||||
|
// this only ever fills a gap -- a real host's own pass
|
||||||
|
// through this loop always overwrites #record
|
||||||
|
// unconditionally when it's finalized, see above).
|
||||||
|
if(fragment === '*' && !pointer['#record']){
|
||||||
|
pointer['#record'] = pointer[fragment]['#record'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance the pointer to the next level of the tree.
|
// Advance the pointer to the next level of the tree.
|
||||||
@@ -445,6 +518,26 @@ class Host extends Table{
|
|||||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the wildcard covering @host as its own base domain (e.g.
|
||||||
|
// "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether
|
||||||
|
// @host is already registered as its own host. Unlike lookUp(), which
|
||||||
|
// walks to and returns @host's own exact-match leaf when one exists, this
|
||||||
|
// walks to that exact position and looks one level deeper at its "*"
|
||||||
|
// child -- the sibling wildcard slot -- so it still finds the parent
|
||||||
|
// wildcard even when @host already has its own (non-wildcard) record.
|
||||||
|
// Used when attaching an already-created host to a wildcard after the
|
||||||
|
// fact (see update() below); Host.create()'s own wildcardChild handling
|
||||||
|
// can keep using plain lookUp() since a host being newly created hasn't
|
||||||
|
// claimed its own leaf yet.
|
||||||
|
static lookUpWildcardParent(host){
|
||||||
|
let place = this.lookUpObj;
|
||||||
|
for(let fragment of host.split('.').reverse()){
|
||||||
|
if(!place[fragment]) return undefined;
|
||||||
|
place = place[fragment];
|
||||||
|
}
|
||||||
|
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||||
|
}
|
||||||
|
|
||||||
static async lookUpReady(){
|
static async lookUpReady(){
|
||||||
/*
|
/*
|
||||||
Wait for the lookup tree to be built.
|
Wait for the lookup tree to be built.
|
||||||
|
|||||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.3",
|
"version": "1.1.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.3",
|
"version": "1.1.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.1.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
@@ -281,9 +281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
|
"integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"extend": "^3.0.2"
|
"extend": "^3.0.2"
|
||||||
@@ -1375,9 +1375,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jq-repeat": {
|
"node_modules/jq-repeat": {
|
||||||
"version": "2.0.1",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
|
||||||
"integrity": "sha512-ATI25tKQG3uHW8f8XPqBe85JsH4PNGHA/YLy1KgMVeYDoUSf9cqGNBum+4A+Pg1WKh9PA6bYyWfYNsgktwIbSg==",
|
"integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.3",
|
"version": "1.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.1.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
|
|||||||
+73
-2
@@ -22,6 +22,8 @@ const docsLimiter = rateLimit({
|
|||||||
const values = {
|
const values = {
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
|
name: conf.name,
|
||||||
|
logo: conf.logo,
|
||||||
...buildInfo,
|
...buildInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +33,15 @@ const values = {
|
|||||||
// An explicit slug -> file allowlist, never a user-suppliable path, so
|
// An explicit slug -> file allowlist, never a user-suppliable path, so
|
||||||
// there's no way to make this read outside the doc set below.
|
// there's no way to make this read outside the doc set below.
|
||||||
const DOCS = {
|
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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
||||||
@@ -52,24 +63,84 @@ function fixImagePaths(html) {
|
|||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
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.use(docsLimiter);
|
||||||
|
|
||||||
router.get('/', function(req, res) {
|
router.get('/', function(req, res) {
|
||||||
res.render('docs_index', {...values, docs: docList});
|
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) {
|
router.get('/:slug', function(req, res, next) {
|
||||||
const doc = DOCS[req.params.slug];
|
const doc = DOCS[req.params.slug];
|
||||||
if (!doc) return next({status: 404, message: 'Doc not found'});
|
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(doc.file, 'utf8');
|
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
|
||||||
res.render('docs_page', {
|
res.render('docs_page', {
|
||||||
...values,
|
...values,
|
||||||
docs: docList,
|
docs: docList,
|
||||||
currentSlug: req.params.slug,
|
currentSlug: req.params.slug,
|
||||||
docTitle: doc.title,
|
docTitle: doc.title,
|
||||||
docHtml: fixImagePaths(marked(content)),
|
docHtml: fixDocLinks(fixImagePaths(marked(content))),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -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.
|
// The full lookup tree exposes every host, so restrict it to admins.
|
||||||
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
|
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const buildInfo = require('../utils/build_info');
|
|||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
|
name: conf.name,
|
||||||
|
logo: conf.logo,
|
||||||
...buildInfo,
|
...buildInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,175 @@ describe('Host Lookup Algorithm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the wildcard's-own-base-domain fix: a single-level wildcard's
|
||||||
|
* issued cert also covers its own base domain (altNames: [domain, *.domain],
|
||||||
|
* see utils/letsencrypt.js), but that base domain sits one tree level ABOVE
|
||||||
|
* the wildcard's own leaf. buildLookUpObj() now also stamps that parent
|
||||||
|
* node's #record, and lookUpWildcardParent() finds it even when the base
|
||||||
|
* domain is ALSO separately registered as its own plain host (the "attach an
|
||||||
|
* existing host to a parent wildcard" case, unlike lookUp() which would just
|
||||||
|
* resolve to that host's own record).
|
||||||
|
*/
|
||||||
|
describe('Host wildcard base-domain lookup', () => {
|
||||||
|
|
||||||
|
let Host;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
Host = createMockHostClassWithWildcardParentFix();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUp finds the wildcard record for its own bare base domain when no plain host exists', async () => {
|
||||||
|
await populateTree(Host, ['*.cool.mysite.com']);
|
||||||
|
const result = Host.lookUp('cool.mysite.com');
|
||||||
|
assert.ok(result, 'Should find a match');
|
||||||
|
assert.strictEqual(result.host, '*.cool.mysite.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUp still prefers an explicitly-created plain host over the wildcard, regardless of population order', async () => {
|
||||||
|
await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']);
|
||||||
|
assert.strictEqual(Host.lookUp('cool.mysite.com').host, 'cool.mysite.com');
|
||||||
|
|
||||||
|
await populateTree(Host, ['cool.mysite.com', '*.cool.mysite.com']);
|
||||||
|
assert.strictEqual(Host.lookUp('cool.mysite.com').host, 'cool.mysite.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUpWildcardParent finds the wildcard even when the base domain already has its own plain host', async () => {
|
||||||
|
await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']);
|
||||||
|
const result = Host.lookUpWildcardParent('cool.mysite.com');
|
||||||
|
assert.ok(result, 'Should find the sibling wildcard');
|
||||||
|
assert.strictEqual(result.host, '*.cool.mysite.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUpWildcardParent returns undefined when there is no wildcard sibling', async () => {
|
||||||
|
await populateTree(Host, ['cool.mysite.com']);
|
||||||
|
assert.strictEqual(Host.lookUpWildcardParent('cool.mysite.com'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUpWildcardParent returns undefined for an unrelated host', async () => {
|
||||||
|
await populateTree(Host, ['*.cool.mysite.com']);
|
||||||
|
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 --
|
||||||
|
* both copied from the real implementation in models/host.js.
|
||||||
|
*/
|
||||||
|
function createMockHostClassWithWildcardParentFix() {
|
||||||
|
return class MockHost {
|
||||||
|
static lookUpObj = {};
|
||||||
|
|
||||||
|
static lookUp(host) {
|
||||||
|
let place = this.lookUpObj;
|
||||||
|
let last_resort = {};
|
||||||
|
let parent = undefined;
|
||||||
|
|
||||||
|
for(let fragment of host.split('.').reverse()){
|
||||||
|
parent = place;
|
||||||
|
if(place['**']) last_resort = place['**'];
|
||||||
|
if({...last_resort, ...place}[fragment]){
|
||||||
|
place = {...last_resort, ...place}[fragment];
|
||||||
|
}else if(place['*']){
|
||||||
|
place = place['*']
|
||||||
|
}else if(last_resort){
|
||||||
|
place = last_resort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(place && place['#record']) return place['#record'];
|
||||||
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static lookUpWildcardParent(host) {
|
||||||
|
let place = this.lookUpObj;
|
||||||
|
for(let fragment of host.split('.').reverse()){
|
||||||
|
if(!place[fragment]) return undefined;
|
||||||
|
place = place[fragment];
|
||||||
|
}
|
||||||
|
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTree(Host, hosts) {
|
||||||
|
Host.lookUpObj = {};
|
||||||
|
|
||||||
|
for(let host of hosts){
|
||||||
|
let fragments = host.split('.');
|
||||||
|
let pointer = Host.lookUpObj;
|
||||||
|
|
||||||
|
while(fragments.length){
|
||||||
|
let fragment = fragments.pop();
|
||||||
|
|
||||||
|
if(!pointer[fragment]){
|
||||||
|
pointer[fragment] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(fragments.length === 0){
|
||||||
|
// 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer = pointer[fragment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock Host class with just the lookUp functionality
|
* Creates a mock Host class with just the lookUp functionality
|
||||||
* This allows us to test the algorithm without Redis dependencies
|
* This allows us to test the algorithm without Redis dependencies
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
Add DNS Provider
|
Add DNS Provider
|
||||||
</span>
|
</span>
|
||||||
<span class="float-end">
|
<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>
|
<i class="fa-solid fa-circle-minus"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +227,7 @@
|
|||||||
<div class="card-header d-flex align-items-center">
|
<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-icon me-2"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||||
<span class="card-title">Dynamic A Records</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">
|
<span class="ms-auto text-muted small">
|
||||||
This server's public IP:
|
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>
|
<span class="badge text-bg-primary fs-6"><i class="fa-solid fa-globe me-1"></i><span id="ddns-current-ip">…</span></span>
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
A local copy of this project's documentation, readable from the
|
A local copy of this project's documentation, readable from the
|
||||||
running app -- no internet access required.
|
running app -- no internet access required.
|
||||||
</p>
|
</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){ %>
|
<% docs.forEach(function(doc){ %>
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
||||||
@@ -22,5 +27,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -60,10 +60,10 @@
|
|||||||
|
|
||||||
loadUserSuggestions();
|
loadUserSuggestions();
|
||||||
|
|
||||||
$.scope.LocalGroup.__setTake(function($el){
|
$.scope.LocalGroup.__take = function($el){
|
||||||
$el.addClass('bg-danger');
|
$el.addClass('bg-danger');
|
||||||
$el.fadeOut(600, function(){ $el.remove(); });
|
$el.fadeOut(600, function(){ $el.remove(); });
|
||||||
});
|
};
|
||||||
|
|
||||||
app.subscribe(/^model:LocalGroup:create/, function(data){
|
app.subscribe(/^model:LocalGroup:create/, function(data){
|
||||||
$.scope.LocalGroup.remove(data.name);
|
$.scope.LocalGroup.remove(data.name);
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
<div class="card-header text-center">
|
<div class="card-header text-center">
|
||||||
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
|
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
|
||||||
<span class="card-title">Add Group</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>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
+75
-16
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
// Parse the JSON object for a host to something the UI wants
|
// Parse the JSON object for a host to something the UI wants
|
||||||
function hostParseRow(host) {
|
function hostParseRow(host) {
|
||||||
|
host['created_on_text'] = moment(host['created_on'], "x").fromNow();
|
||||||
host['updated_on_text'] = moment(host['updated_on'], "x").fromNow();
|
host['updated_on_text'] = moment(host['updated_on'], "x").fromNow();
|
||||||
host['wildcard_expires_text'] = moment(host['wildcard_expires'], "x").fromNow();
|
host['wildcard_expires_text'] = moment(host['wildcard_expires'], "x").fromNow();
|
||||||
host['targetssl_text'] = host['targetssl'] ? 'https://' : 'http://';
|
host['targetssl_text'] = host['targetssl'] ? 'https://' : 'http://';
|
||||||
@@ -115,10 +116,13 @@
|
|||||||
// attach users to).
|
// attach users to).
|
||||||
let hostFormCurrentHost = null;
|
let hostFormCurrentHost = null;
|
||||||
|
|
||||||
// The auth_mode radios aren't real form fields (no [name]); this keeps the
|
// The auth_mode radios share a name so the browser enforces mutual
|
||||||
// two hidden basicauth_enabled/sso_enabled inputs — the ones actually
|
// exclusivity, but auth_mode itself isn't in Host's _keyMap -- the model
|
||||||
// submitted — in sync so only one can ever be true, and shows/hides the
|
// layer strips unrecognized fields on save (see model-redis's
|
||||||
// matching field group.
|
// processKeys), so it's never actually persisted. This keeps the two
|
||||||
|
// hidden basicauth_enabled/sso_enabled inputs -- the real, submitted
|
||||||
|
// fields -- in sync with whichever radio is selected, and shows/hides
|
||||||
|
// the matching field group.
|
||||||
function hostAuthModeChanged(mode){
|
function hostAuthModeChanged(mode){
|
||||||
$('#basicauth_enabled-hidden').val(mode === 'basic' ? 'true' : 'false');
|
$('#basicauth_enabled-hidden').val(mode === 'basic' ? 'true' : 'false');
|
||||||
$('#sso_enabled-hidden').val(mode === 'sso' ? 'true' : 'false');
|
$('#sso_enabled-hidden').val(mode === 'sso' ? 'true' : 'false');
|
||||||
@@ -188,6 +192,7 @@
|
|||||||
let $f = $(form);
|
let $f = $(form);
|
||||||
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
|
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
|
||||||
$f.find('[name=host]').prop('disabled', false);
|
$f.find('[name=host]').prop('disabled', false);
|
||||||
|
$('#host-rename-help').hide();
|
||||||
if($f.validateClear) $f.validateClear();
|
if($f.validateClear) $f.validateClear();
|
||||||
|
|
||||||
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
|
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
|
||||||
@@ -207,7 +212,7 @@
|
|||||||
hostModal().show();
|
hostModal().show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostEditOpen(host){
|
async function hostEditOpen(host){
|
||||||
hostFormReset();
|
hostFormReset();
|
||||||
let h = $.scope.hosts.getByKey(host);
|
let h = $.scope.hosts.getByKey(host);
|
||||||
let $f = $('#hostForm');
|
let $f = $('#hostForm');
|
||||||
@@ -244,11 +249,40 @@
|
|||||||
hostAuthModeChanged(authMode);
|
hostAuthModeChanged(authMode);
|
||||||
hostRenderBasicAuthUsers(host, h.basicauth_users);
|
hostRenderBasicAuthUsers(host, h.basicauth_users);
|
||||||
|
|
||||||
// The host name is the key; it can't change on edit. Wildcard hosts can
|
// The host name is the Redis record's key -- renaming it is a real
|
||||||
// still toggle their matching mode.
|
// migration (see Host.prototype.update() in models/host.js), scoped
|
||||||
$f.find('[name=host]').prop('disabled', true);
|
// 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){
|
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');
|
$('#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();
|
hostModal().show();
|
||||||
@@ -295,10 +329,12 @@
|
|||||||
|
|
||||||
async function hostMatchWildcard(host){
|
async function hostMatchWildcard(host){
|
||||||
try{
|
try{
|
||||||
let res = await app.api.get(`host/lookup/${host}`);
|
// Not /host/lookup/ -- that resolves an ALREADY-EXISTING host to its
|
||||||
if(res.results && res.results.is_wildcard){
|
// own record, not a sibling wildcard (see the route's comment). This
|
||||||
return res.results;
|
// 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){
|
}catch(error){
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -383,6 +419,7 @@
|
|||||||
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
|
<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="card-title fw-bold">Proxy List</span>
|
||||||
<span class="ms-auto">
|
<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">
|
<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>
|
<i class="fa-solid fa-broom"></i>
|
||||||
Clear cache
|
Clear cache
|
||||||
@@ -420,6 +457,7 @@
|
|||||||
<th>SSL Expire</th>
|
<th>SSL Expire</th>
|
||||||
<th>Host Name</th>
|
<th>Host Name</th>
|
||||||
<th>target</th>
|
<th>target</th>
|
||||||
|
<th class="hidden-xs">Created</th>
|
||||||
<th class="hidden-xs">Updated</th>
|
<th class="hidden-xs">Updated</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -451,6 +489,11 @@
|
|||||||
<td>
|
<td>
|
||||||
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
|
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
|
||||||
</td>
|
</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 }}" >
|
<td class="hidden-xs momentFromNow" data-date="{{ updated_on }}" >
|
||||||
{{ updated_on_text }}
|
{{ updated_on_text }}
|
||||||
</td>
|
</td>
|
||||||
@@ -510,13 +553,14 @@
|
|||||||
<div class="modal-content card border-0">
|
<div class="modal-content card border-0">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header actionMessage m-0" style="display:none"></div>
|
<div class="card-header actionMessage m-0" style="display:none"></div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<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 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 & Wildcard</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 & 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>
|
<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>
|
||||||
@@ -539,6 +583,12 @@
|
|||||||
for one subdomain level, <code>**.example.com</code> for any depth,
|
for one subdomain level, <code>**.example.com</code> for any depth,
|
||||||
or <code>**</code> as a catch-all.
|
or <code>**</code> as a catch-all.
|
||||||
</small>
|
</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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -579,6 +629,7 @@
|
|||||||
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
||||||
Proxy to HTTPS
|
Proxy to HTTPS
|
||||||
</label></div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,6 +668,14 @@
|
|||||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
||||||
Match any subdomain and proxy to this host
|
Match any subdomain and proxy to this host
|
||||||
</label></div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -713,15 +772,15 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="radio"><label>
|
<div class="radio"><label>
|
||||||
<input type="radio" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
|
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
|
||||||
Off (public)
|
Off (public)
|
||||||
</label></div>
|
</label></div>
|
||||||
<div class="radio"><label>
|
<div class="radio"><label>
|
||||||
<input type="radio" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
|
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
|
||||||
Basic authentication
|
Basic authentication
|
||||||
</label></div>
|
</label></div>
|
||||||
<div class="radio"><label>
|
<div class="radio"><label>
|
||||||
<input type="radio" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
|
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</label></div>
|
</label></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,10 +57,10 @@
|
|||||||
|
|
||||||
loadSubjectSuggestions();
|
loadSubjectSuggestions();
|
||||||
|
|
||||||
$.scope.Permission.__setTake(function($el, item, list){
|
$.scope.Permission.__take = function($el, item, list){
|
||||||
$el.addClass('bg-danger');
|
$el.addClass('bg-danger');
|
||||||
$el.fadeOut(600, function(){ $el.remove(); });
|
$el.fadeOut(600, function(){ $el.remove(); });
|
||||||
});
|
};
|
||||||
|
|
||||||
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
|
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
|
||||||
app.subscribe(/^model:Permission:create/, function(data){
|
app.subscribe(/^model:Permission:create/, function(data){
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
<i class="fa-solid fa-user-shield"></i>
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="card-title">Add Permission</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>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
@@ -140,6 +141,7 @@
|
|||||||
<i class="fa-solid fa-list-check"></i>
|
<i class="fa-solid fa-list-check"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="card-title">Permissions</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>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
|||||||
@@ -207,7 +207,9 @@
|
|||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card shadow-lg">
|
<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-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title>Proxy - Theta 42 <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<!-- CSS are placed here -->
|
<!-- CSS are placed here -->
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="#">Dynamic Proxy <%- titleIcon %></a>
|
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@
|
|||||||
for(let user of data.results){
|
for(let user of data.results){
|
||||||
$.scope.users.push(user);
|
$.scope.users.push(user);
|
||||||
}
|
}
|
||||||
$.scope.users.__setPut(function($el, item, list){
|
$.scope.users.__put = function($el, item, list){
|
||||||
$el.addClass('bg-success');
|
$el.addClass('bg-success');
|
||||||
$el.fadeIn(3000, function(){
|
$el.fadeIn(3000, function(){
|
||||||
$el.removeClass('bg-success');
|
$el.removeClass('bg-success');
|
||||||
});
|
});
|
||||||
})
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@
|
|||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
populateUsers(); //populate the table
|
populateUsers(); //populate the table
|
||||||
|
|
||||||
$.scope.users.__setTake(function($el, item, list){
|
$.scope.users.__take = function($el, item, list){
|
||||||
$el.addClass('bg-danger');
|
$el.addClass('bg-danger');
|
||||||
$el.fadeOut(1000, function(){
|
$el.fadeOut(1000, function(){
|
||||||
$el.remove()
|
$el.remove()
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
Add New User
|
Add New User
|
||||||
</span>
|
</span>
|
||||||
<span class="float-end">
|
<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>
|
<i class="fa-solid fa-circle-minus"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
User List
|
User List
|
||||||
</span>
|
</span>
|
||||||
<span class="float-end">
|
<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>
|
<i class="fa-solid fa-circle-minus"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+48
-3
@@ -9,19 +9,29 @@
|
|||||||
# update is just "sync the repo + reload" -- the files under /etc always track
|
# update is just "sync the repo + reload" -- the files under /etc always track
|
||||||
# the repo, so there is nothing to re-copy.
|
# 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
|
# 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
|
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
|
||||||
# the repo (any drift on the box is discarded).
|
# 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
|
set -euo pipefail
|
||||||
# Never block on an interactive git credential prompt in CI.
|
# Never block on an interactive git credential prompt in CI.
|
||||||
export GIT_TERMINAL_PROMPT=0
|
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_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}"
|
BRANCH="${BRANCH:-master}"
|
||||||
NODE_MAJOR=22
|
NODE_MAJOR=22
|
||||||
|
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "This script must be run as root (try: sudo $0)" >&2
|
echo "This script must be run as root (try: sudo $0)" >&2
|
||||||
@@ -34,6 +44,19 @@ link(){
|
|||||||
echo "linked $2 -> $1"
|
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"
|
echo "==> Base packages"
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -134,6 +157,20 @@ else
|
|||||||
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
||||||
fi
|
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"
|
echo "==> Symlink config from the repo"
|
||||||
install -d /etc/openresty/sites-enabled /var/log/nginx
|
install -d /etc/openresty/sites-enabled /var/log/nginx
|
||||||
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
||||||
@@ -162,4 +199,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
listen 4443 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_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_prefer_server_ciphers on;
|
ssl_prefer_server_ciphers on;
|
||||||
|
|||||||
+3
-2
@@ -8,9 +8,10 @@ Type=simple
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/var/www/proxy/nodejs
|
WorkingDirectory=/opt/theta42/proxy/nodejs
|
||||||
Environment="NODE_ENV=production"
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
+10
-4
@@ -6,18 +6,24 @@
|
|||||||
// direct LDAP client for user lookups. This file supplies that wiring.
|
// direct LDAP client for user lookups. This file supplies that wiring.
|
||||||
//
|
//
|
||||||
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
|
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
|
||||||
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env
|
// 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
|
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
|
||||||
// file is authoritative only if the matching app_* env is absent.
|
// 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
|
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
|
||||||
// here override conf/base.js and win over <environment>.js.
|
// 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
|
// 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.
|
// theta-env orchestrator (setup.sh) and ignored by the app.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
name: 'Dynamic Proxy', // shown in the UI
|
||||||
|
logo: '/static/img/theta42.svg', // nav image; point at your own file under public/ to white-label
|
||||||
|
|
||||||
// OpenID Connect — point at your SSO Manager. Issuer + authorization/
|
// OpenID Connect — point at your SSO Manager. Issuer + authorization/
|
||||||
// endSession are browser-facing URLs; token/userinfo can be the internal
|
// endSession are browser-facing URLs; token/userinfo can be the internal
|
||||||
// URL if the SSO is on the same docker network (avoids a TLS hairpin).
|
// URL if the SSO is on the same docker network (avoids a TLS hairpin).
|
||||||
|
|||||||
Reference in New Issue
Block a user