Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 | |||
| 21e295615b | |||
| 7452ccd655 | |||
| 5acea6fcc2 | |||
| fcd97b12aa | |||
| 289a9587d6 | |||
| 6ede072213 | |||
| bdaba513a7 | |||
| d1dd40d60a | |||
| c0e1aa666e | |||
| f8d620f4d3 | |||
| 899c4d91d6 | |||
| 0f268fdcae | |||
| da0ed0e2ad | |||
| f0eadbc2d7 | |||
| a02ca4d3e7 | |||
| 2ff2bf9ea7 | |||
| 355a9d68e5 | |||
| 11f6c4df36 | |||
| a567bf6c51 | |||
| b9bdf36638 | |||
| 426fa111ec | |||
| f0b282b679 | |||
| 4f1fce367e | |||
| 9eb3dfa2e6 | |||
| a40da55993 |
+65
-3
@@ -6,13 +6,66 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.17] - 2026-07-20
|
||||
|
||||
### Fixed
|
||||
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
|
||||
|
||||
## [1.1.16] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
- Public-release packaging: removed `"private": true` from `nodejs/package.json`, corrected the repository URL to `https://github.com/theta42/proxy.git`, and fixed the MIT `LICENSE` copyright line.
|
||||
- Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure.
|
||||
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file.
|
||||
|
||||
### Security
|
||||
- Sanitized rendered docs HTML via `xss` in `routes/docs.js` so malicious markdown cannot inject scripts or other dangerous markup into the in-app docs viewer.
|
||||
- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`.
|
||||
|
||||
### Fixed
|
||||
- The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients.
|
||||
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior.
|
||||
|
||||
## [1.1.15] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
- `ops/install.sh` now installs to `/opt/theta42/proxy` (was `/var/www/proxy`) and seeds `/etc/proxy/secrets.js` from `secrets.js.example` on first run (never overwritten on later runs), instead of requiring a manual `nodejs/conf/secrets.js` edit inside the repo checkout. `ops/proxy.service` sets `CONF_SECRETS=/etc/proxy/secrets.js` to match.
|
||||
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run, instead of updating silently.
|
||||
|
||||
### Fixed
|
||||
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency — it prompted interactively for a timezone with no TTY attached. Set `DEBIAN_FRONTEND=noninteractive`.
|
||||
|
||||
## [1.1.14] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `proxy-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
|
||||
|
||||
## [1.1.13] - 2026-07-17
|
||||
|
||||
### Added
|
||||
- Four new plain-language docs aimed at less technical readers, replacing the system-design-level Architecture/Installation docs as the target of most card help links: **Hosts & HTTPS**, **DNS Providers**, **Users, Groups & Permissions**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed entirely) now has a help link.
|
||||
|
||||
### Fixed
|
||||
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
|
||||
|
||||
## [1.1.12] - 2026-07-17
|
||||
|
||||
### Fixed
|
||||
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
|
||||
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
|
||||
|
||||
## [1.1.11] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
- Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
|
||||
|
||||
## [1.1.10] - 2026-07-17
|
||||
|
||||
### Added
|
||||
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
|
||||
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
||||
|
||||
Bumps to v1.1.10.
|
||||
## [1.1.9] - 2026-07-17
|
||||
|
||||
### Added
|
||||
- The host list now shows who created each host, and when.
|
||||
@@ -23,7 +76,7 @@ Bumps to v1.1.10.
|
||||
- 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()`.
|
||||
|
||||
Bumps to v1.1.9.
|
||||
## [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()`.
|
||||
@@ -31,6 +84,8 @@ Bumps to v1.1.9.
|
||||
|
||||
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.
|
||||
|
||||
@@ -77,7 +132,14 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
||||
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
|
||||
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
|
||||
|
||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.10...HEAD
|
||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.17...HEAD
|
||||
[1.1.17]: https://github.com/theta42/proxy/compare/v1.1.16...v1.1.17
|
||||
[1.1.16]: https://github.com/theta42/proxy/compare/v1.1.15...v1.1.16
|
||||
[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
|
||||
|
||||
+20
-10
@@ -75,11 +75,12 @@ $EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindP
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`docker-entrypoint.sh` symlinks `/config/proxy-secrets.js` → `/app/conf/secrets.js`
|
||||
so `@simpleworkjs/conf` reads it. No `app_*` env is passed — `app_*` env would
|
||||
override the file (env beats secrets.js in `@simpleworkjs/conf`), so the file is
|
||||
kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` / `NODE_PORT` are
|
||||
OpenResty-runtime / process env, not `app_*` config, so they stay in the compose.
|
||||
`docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
|
||||
`@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
|
||||
would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
|
||||
file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
|
||||
`NODE_PORT` are OpenResty-runtime / process env, not `app_*` config, so they
|
||||
stay in the compose.
|
||||
|
||||
> Running the unified `theta-env` stack? Its `setup.sh` generates
|
||||
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
|
||||
@@ -227,16 +228,25 @@ docker compose logs --tail=200 --since=10m proxy # recent context
|
||||
|
||||
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
|
||||
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
|
||||
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
||||
starts `proxy.service`. Re-run it to update.
|
||||
`/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
||||
starts `proxy.service`. Re-run it to update — it prints the version you're
|
||||
updating from and to (or "Already up to date" if there's nothing new).
|
||||
|
||||
```bash
|
||||
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
|
||||
```
|
||||
|
||||
or, if you already have the repo checked out:
|
||||
|
||||
```bash
|
||||
sudo ./ops/install.sh
|
||||
```
|
||||
|
||||
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC +
|
||||
LDAP values (see `nodejs/conf/base.js` for the shape), then
|
||||
`sudo systemctl restart proxy`.
|
||||
Configuration is file-based: on first run the installer seeds
|
||||
`/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
|
||||
in — OIDC + LDAP values, see `nodejs/conf/base.js` for the shape). Edit it,
|
||||
then `sudo systemctl restart proxy`. Later runs never touch an existing
|
||||
secrets file.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ RUN apt-get update \
|
||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||
RUN luarocks install lua-resty-auto-ssl \
|
||||
&& luarocks install luasocket \
|
||||
&& luarocks install lua-resty-balancer \
|
||||
&& luarocks install lua-resty-ipmatcher
|
||||
|
||||
# ── Node app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
Copyright (c) 2026 theta42
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ provider + LDAP directory you already run.
|
||||
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
||||
- Wildcard SSL certificate support with automatic renewal
|
||||
- Dynamic host routing with wildcard domain matching (*, **)
|
||||
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||
- Web-based management interface
|
||||
- RESTful API for automation
|
||||
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
||||
@@ -127,10 +128,15 @@ This installer will:
|
||||
- Install and configure Redis
|
||||
- Set up SSL fallback certificates
|
||||
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
|
||||
- Clone and install the proxy application
|
||||
- Clone/update the proxy application at `/opt/theta42/proxy`
|
||||
- Seed `/etc/proxy/secrets.js` on first run (edit it, then re-run or `systemctl restart proxy`)
|
||||
- Configure systemd service
|
||||
- Start the proxy service
|
||||
|
||||
It's idempotent and safe to re-run — re-running it updates the app in place and
|
||||
prints the version you're updating from and to (e.g. `Updated v1.1.13 ->
|
||||
v1.1.14`), or `Already up to date` if there's nothing new.
|
||||
|
||||
## Logs (Docker)
|
||||
|
||||
The all-in-one image runs OpenResty in the foreground and the Node app in the
|
||||
@@ -224,15 +230,24 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
||||
|
||||
Clone and install:
|
||||
```bash
|
||||
cd /var/www
|
||||
mkdir -p /opt/theta42
|
||||
cd /opt/theta42
|
||||
git clone https://github.com/theta42/proxy.git
|
||||
cd proxy/nodejs
|
||||
npm install
|
||||
```
|
||||
|
||||
Configure secrets:
|
||||
```bash
|
||||
mkdir -p /etc/proxy
|
||||
cp ../secrets.js.example /etc/proxy/secrets.js
|
||||
chmod 600 /etc/proxy/secrets.js
|
||||
$EDITOR /etc/proxy/secrets.js
|
||||
```
|
||||
|
||||
Create systemd service:
|
||||
```bash
|
||||
cp ops/proxy.service /etc/systemd/system/proxy.service
|
||||
cp ../ops/proxy.service /etc/systemd/system/proxy.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxy.service
|
||||
systemctl start proxy.service
|
||||
|
||||
+10
-9
@@ -9,13 +9,14 @@
|
||||
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
|
||||
# dumb-init, PID 1) so it receives SIGTERM from `docker stop`.
|
||||
#
|
||||
# The app reads its config from conf/base.js deep-merged with conf/secrets.js
|
||||
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in
|
||||
# The app reads its config from conf/base.js deep-merged with a secrets file
|
||||
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.2.0, pinned in
|
||||
# nodejs/package-lock.json). No secrets.js is baked into the image. The unified
|
||||
# theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint
|
||||
# symlinks it into /app/conf/secrets.js so the app reads oidc/ldap/auth config
|
||||
# from the file (no app_* env needed). Without the mount, supply the same config
|
||||
# via `app_*` env (compose `environment:` / `env_file:`).
|
||||
# points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
|
||||
# from the mounted file (no app_* env needed, no write access to /app/conf
|
||||
# required). Without the mount, supply the same config via `app_*` env
|
||||
# (compose `environment:` / `env_file:`).
|
||||
#
|
||||
# OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal
|
||||
# home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They
|
||||
@@ -30,14 +31,14 @@ error() { echo "[ERROR] $*" >&2; }
|
||||
|
||||
# ── Optional: mount proxy secrets.js ─────────────────────────────────────────
|
||||
# When /config/proxy-secrets.js is present (unified theta-env stack, or any
|
||||
# deployment that bind-mounts ./config), symlink it into /app/conf/secrets.js so
|
||||
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_* env
|
||||
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
|
||||
# deployment that bind-mounts ./config), point CONF_SECRETS at it so
|
||||
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_*
|
||||
# env should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
|
||||
# (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is
|
||||
# authoritative only if the matching app_* env is absent. When the file is
|
||||
# absent the app falls back to app_* env (compose environment / env_file).
|
||||
if [[ -f /config/proxy-secrets.js ]]; then
|
||||
ln -sf /config/proxy-secrets.js /app/conf/secrets.js
|
||||
export CONF_SECRETS=/config/proxy-secrets.js
|
||||
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
|
||||
fi
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
> Looking for a plainer explanation of hosts, HTTPS, or the local
|
||||
> permission model instead of internals? See
|
||||
> [Hosts & HTTPS](concepts-hosts.html) and
|
||||
> [Users, Groups & Permissions](concepts-access.html).
|
||||
|
||||
## System Overview
|
||||
|
||||
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
|
||||
|
||||
@@ -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,81 @@
|
||||
---
|
||||
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`).
|
||||
|
||||
## Load Balancing
|
||||
|
||||
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
||||
|
||||
## Want more detail?
|
||||
|
||||
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
||||
and the exact install steps. For those, see
|
||||
[Architecture](architecture.html) and [Installation](installation.html).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
+5
-5
@@ -39,11 +39,11 @@ which deep-merges, in order:
|
||||
3. `conf/secrets.js` (gitignored)
|
||||
4. **`app_*` environment variables** — the highest-precedence layer
|
||||
|
||||
The bundled `docker-compose.yml` mount `./config/proxy-secrets.js` at `/config`,
|
||||
and `docker-entrypoint.sh` symlinks it into `/app/conf/secrets.js` so the app
|
||||
reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is passed** —
|
||||
`app_*` env beats `secrets.js`, so the file is authoritative only if the matching
|
||||
`app_*` env is absent. See `secrets.js.example` for the shape.
|
||||
The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
|
||||
and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
|
||||
app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
|
||||
passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
|
||||
the matching `app_*` env is absent. See `secrets.js.example` for the shape.
|
||||
|
||||
Any env var starting with `app_` overrides the merged config; the rest of the
|
||||
name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||
|
||||
@@ -50,6 +50,7 @@ LDAP directory you already run.
|
||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||
it's never ambiguous which one gated a request)
|
||||
|
||||
+20
-3
@@ -8,6 +8,10 @@ description: Installing the proxy — Docker, bare metal, or as part of the unif
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
> Looking for a plainer explanation of hosts, HTTPS, and DNS providers
|
||||
> instead of install steps? See [Hosts & HTTPS](concepts-hosts.html) and
|
||||
> [DNS Providers](concepts-dns.html).
|
||||
|
||||
## Quick Install (Recommended)
|
||||
|
||||
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
|
||||
@@ -142,7 +146,8 @@ openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
|
||||
Clone the repository and copy configuration files:
|
||||
|
||||
```bash
|
||||
cd /var/www
|
||||
mkdir -p /opt/theta42
|
||||
cd /opt/theta42
|
||||
git clone https://github.com/theta42/proxy.git
|
||||
cd proxy
|
||||
|
||||
@@ -157,14 +162,26 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
||||
### Step 7: Install Application
|
||||
|
||||
```bash
|
||||
cd /var/www/proxy/nodejs
|
||||
cd /opt/theta42/proxy/nodejs
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 7b: Configure Secrets
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/proxy
|
||||
cp /opt/theta42/proxy/secrets.js.example /etc/proxy/secrets.js
|
||||
chmod 600 /etc/proxy/secrets.js
|
||||
$EDITOR /etc/proxy/secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
|
||||
```
|
||||
|
||||
`@simpleworkjs/conf` reads this file via the `CONF_SECRETS` env var, which the
|
||||
systemd unit below sets to `/etc/proxy/secrets.js`.
|
||||
|
||||
### Step 8: Configure Systemd Service
|
||||
|
||||
```bash
|
||||
cp /var/www/proxy/ops/proxy.service /etc/systemd/system/proxy.service
|
||||
cp /opt/theta42/proxy/ops/proxy.service /etc/systemd/system/proxy.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxy.service
|
||||
systemctl start proxy.service
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# API Documentation
|
||||
|
||||
> Looking for a plainer explanation of what API tokens are and when you'd
|
||||
> want one, instead of a full endpoint reference? See
|
||||
> [API Tokens](/docs/api-tokens) (in-app) or
|
||||
> [concepts-api-tokens.md](../docs/concepts-api-tokens.md) (repo).
|
||||
|
||||
All API endpoints require authentication unless otherwise noted. Three
|
||||
authentication methods are supported:
|
||||
|
||||
|
||||
+12
-6
@@ -100,15 +100,21 @@ app.use(async function(req, res, next) {
|
||||
|
||||
// Error handler. This is where `next()` will go on error
|
||||
app.use(async function(err, req, res, next) {
|
||||
try{
|
||||
console.error(err.status || res.status, err.name, req.method, req.url);
|
||||
try{
|
||||
const status = err.status || 500;
|
||||
console.error(status, err.name, req.method, req.url);
|
||||
console.error(err.message);
|
||||
console.error(err.stack);
|
||||
if (err.stack) console.error(err.stack);
|
||||
console.error('=========================================');
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.json({name: err.name, message: err.message, keys: err.keys});
|
||||
res.status(status);
|
||||
// Only expose safe, non-internal fields to the client.
|
||||
const body = { name: err.name, message: err.message };
|
||||
res.json(body);
|
||||
}catch(error){
|
||||
console.log('error in the catch all error fn....', error);
|
||||
console.error('error in the catch-all error handler', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ name: 'Error', message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+8
-8
@@ -6,10 +6,10 @@ module.exports = {
|
||||
logo: "/static/img/theta42.svg", // shown in the nav; point at your own file under public/ (or an absolute URL) to white-label
|
||||
userModel: 'redis', // pam, redis, ldap
|
||||
ldap: {
|
||||
url: 'ldap://192.168.1.55:389',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
||||
url: 'ldap://localhost',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||
bindPassword: '__IN SRECREST FILE__',
|
||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
||||
searchBase: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
userNameAttribute: 'uid'
|
||||
},
|
||||
@@ -29,11 +29,11 @@ module.exports = {
|
||||
// redirectUri MUST be registered on the SSO client and match exactly.
|
||||
oidc: {
|
||||
enabled: true,
|
||||
issuer: 'https://sso.theta42.com',
|
||||
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://sso.theta42.com/oauth/token',
|
||||
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo',
|
||||
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout',
|
||||
issuer: 'https://sso.example.com',
|
||||
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://sso.example.com/oauth/token',
|
||||
userinfoEndpoint: 'https://sso.example.com/oauth/userinfo',
|
||||
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
|
||||
clientId: '__SET_ME__',
|
||||
// Where the SSO sends the user back. Must be an absolute URL reachable
|
||||
// by the browser and registered on the SSO client.
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
module.exports = {
|
||||
userModel: 'redis', // pam, redis, ldap
|
||||
ldap: {
|
||||
url: 'ldap://192.168.1.55:389',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
||||
url: 'ldap://localhost',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||
bindPassword: '__IN SRECREST FILE__',
|
||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
||||
searchBase: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
userNameAttribute: 'uid'
|
||||
},
|
||||
|
||||
+28
-13
@@ -28,6 +28,7 @@ class Host extends Table{
|
||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
||||
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
||||
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
||||
|
||||
@@ -518,24 +519,38 @@ class Host extends Table{
|
||||
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.
|
||||
// Find the wildcard that could cover @host, 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 keeps looking
|
||||
// for a sibling/child "*" 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.
|
||||
//
|
||||
// Two tree positions qualify, and we must check BOTH:
|
||||
// 1. Child "*" of @host's own node -- @host is the wildcard's base domain
|
||||
// (e.g. "*.cool.mysite.com" covers host="cool.mysite.com").
|
||||
// 2. Sibling "*" one level up -- @host is a single-label subdomain of the
|
||||
// wildcard (e.g. "*.nl.wgnode.com" covers host="sso.nl.wgnode.com").
|
||||
// Case 2 is the common one and was previously missed: the walk consumed the
|
||||
// leftmost label ("sso") and only inspected that leaf's "*" child, so an
|
||||
// already-existing sibling subdomain could never be attached to its wildcard.
|
||||
static lookUpWildcardParent(host){
|
||||
let place = this.lookUpObj;
|
||||
let parent = undefined;
|
||||
for(let fragment of host.split('.').reverse()){
|
||||
if(!place[fragment]) return undefined;
|
||||
// @host may have no leaf of its own (brand-new subdomain); that case
|
||||
// is already handled by plain lookUp()'s wildcard fallback in the
|
||||
// caller, so just stop -- we've still tracked `parent` for case 2.
|
||||
if(!place[fragment]){ place = undefined; break; }
|
||||
parent = place;
|
||||
place = place[fragment];
|
||||
}
|
||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 1: wildcard is a child of @host's own node.
|
||||
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 2: wildcard is a sibling of @host's leftmost label.
|
||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||
}
|
||||
|
||||
static async lookUpReady(){
|
||||
|
||||
@@ -90,10 +90,19 @@ User.register();
|
||||
var defaultUser = 'proxyadmin2'
|
||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
||||
// auth.localAdminPass in proxy-secrets.js to a generated password so this
|
||||
// bootstrap account isn't left at the well-known default (username ==
|
||||
// password == "proxyadmin2"). Only used on first creation -- once the
|
||||
// account exists this is never read again, so it's safe to leave set.
|
||||
var defaultPass = (conf.auth && conf.auth.localAdminPass) || defaultUser;
|
||||
// bootstrap account isn't left at a well-known default. Only used on first
|
||||
// creation -- once the account exists this is never read again, so it's
|
||||
// safe to leave set. If unset, a random password is generated and printed
|
||||
// once; save it from the log or set auth.localAdminPass explicitly.
|
||||
var defaultPass = (conf.auth && conf.auth.localAdminPass);
|
||||
if (!defaultPass) {
|
||||
defaultPass = crypto.randomBytes(16).toString('hex');
|
||||
console.warn(`====================================================================`);
|
||||
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
|
||||
console.warn(`${defaultPass}`);
|
||||
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
|
||||
console.warn(`====================================================================`);
|
||||
}
|
||||
try{
|
||||
let user = await User.get(defaultUser);
|
||||
}catch(error){
|
||||
@@ -103,7 +112,7 @@ User.register();
|
||||
password: defaultPass,
|
||||
created_by: defaultUser
|
||||
});
|
||||
console.log(defaultUser, 'created', user);
|
||||
console.log(defaultUser, 'created');
|
||||
}catch(error){
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
Generated
+40
-11
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.10",
|
||||
"version": "1.1.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.10",
|
||||
"version": "1.1.17",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.1.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -21,7 +21,7 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.1.0",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^4.0.0",
|
||||
"ldapts": "^8.1.8",
|
||||
"linux-sys-user": "^1.2.0",
|
||||
@@ -32,7 +32,8 @@
|
||||
"p2psub": "^0.2.0",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"tld-extract": "^2.1.0"
|
||||
"tld-extract": "^2.1.0",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
@@ -281,9 +282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
|
||||
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
"integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2"
|
||||
@@ -611,6 +612,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
@@ -722,6 +729,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssfilter": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
|
||||
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1375,9 +1388,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jq-repeat": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
|
||||
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
|
||||
"integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2251,6 +2264,22 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.20.3",
|
||||
"cssfilter": "0.0.10"
|
||||
},
|
||||
"bin": {
|
||||
"xss": "bin/xss"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.10",
|
||||
"private": true,
|
||||
"version": "1.1.17",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
@@ -22,7 +21,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.1.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -32,7 +31,7 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.1.0",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^4.0.0",
|
||||
"ldapts": "^8.1.8",
|
||||
"linux-sys-user": "^1.2.0",
|
||||
@@ -43,12 +42,13 @@
|
||||
"p2psub": "^0.2.0",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"tld-extract": "^2.1.0"
|
||||
"tld-extract": "^2.1.0",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.theta42.com/wmantly/proxy.git"
|
||||
"url": "https://github.com/theta42/proxy.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
|
||||
+49
-3
@@ -5,6 +5,7 @@ const path = require('path');
|
||||
const router = require('express').Router();
|
||||
const {rateLimit} = require('express-rate-limit');
|
||||
const {marked} = require('marked');
|
||||
const xss = require('xss');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
|
||||
@@ -33,6 +34,15 @@ const values = {
|
||||
// An explicit slug -> file allowlist, never a user-suppliable path, so
|
||||
// there's no way to make this read outside the doc set below.
|
||||
const DOCS = {
|
||||
// Plain-language "what is this and why would I use it" guides -- linked
|
||||
// directly from the relevant card in the UI (see the help icon on each
|
||||
// card). Each links onward to the deeper technical doc below for readers
|
||||
// who want the system-design/protocol-level detail.
|
||||
hosts: {title: 'Hosts & HTTPS', file: path.join(__dirname, '../../docs/concepts-hosts.md')},
|
||||
dns: {title: 'DNS Providers', file: path.join(__dirname, '../../docs/concepts-dns.md')},
|
||||
access: {title: 'Users, Groups & Permissions', file: path.join(__dirname, '../../docs/concepts-access.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
||||
@@ -54,6 +64,42 @@ function fixImagePaths(html) {
|
||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||
}
|
||||
|
||||
// docs/*.md files (not the repo-root README/CHANGELOG/api.md) carry Jekyll
|
||||
// front matter for the GitHub Pages build and a "← Back to Home" link back
|
||||
// to that site's index -- both meaningless here (this viewer has its own
|
||||
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
|
||||
// matter isn't regular markdown: it rendered as a garbled heading + stray
|
||||
// <hr> at the top of every page. Strip both before rendering.
|
||||
function stripJekyllCruft(content) {
|
||||
return content
|
||||
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
||||
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
|
||||
}
|
||||
|
||||
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
|
||||
// Pages build, which is what these same .md files also feed) and
|
||||
// "index.html" for the docs home -- neither resolves here, where a doc lives
|
||||
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
|
||||
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
|
||||
// actually exist, so an unrelated "foo.html" link is left alone.
|
||||
// Docs are also linked by their real filename stem (e.g. "concepts-hosts.html"
|
||||
// for docs/concepts-hosts.md) -- the correct, working link on the Jekyll/
|
||||
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
|
||||
// this viewer's own short slugs (DOCS keys, e.g. "hosts"), so also resolve by
|
||||
// filename as a fallback -- one link written in a doc works correctly on
|
||||
// both targets, rather than needing two different link forms.
|
||||
const slugByFilename = Object.fromEntries(
|
||||
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
|
||||
);
|
||||
function fixDocLinks(html) {
|
||||
return html
|
||||
.replace(/href="index\.html"/g, 'href="/docs"')
|
||||
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
|
||||
const slug = DOCS[name] ? name : slugByFilename[name];
|
||||
return slug ? `href="/docs/${slug}"` : match;
|
||||
});
|
||||
}
|
||||
|
||||
router.use(docsLimiter);
|
||||
|
||||
router.get('/', function(req, res) {
|
||||
@@ -73,7 +119,7 @@ router.get('/search', function(req, res) {
|
||||
const results = [];
|
||||
for (const [slug, doc] of Object.entries(DOCS)) {
|
||||
try {
|
||||
const content = fs.readFileSync(doc.file, 'utf8');
|
||||
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)});
|
||||
@@ -89,13 +135,13 @@ router.get('/:slug', function(req, res, next) {
|
||||
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(doc.file, 'utf8');
|
||||
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
|
||||
res.render('docs_page', {
|
||||
...values,
|
||||
docs: docList,
|
||||
currentSlug: req.params.slug,
|
||||
docTitle: doc.title,
|
||||
docHtml: fixImagePaths(marked(content)),
|
||||
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -128,6 +128,29 @@ router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.host
|
||||
}
|
||||
});
|
||||
|
||||
// Is there a wildcard host that could serve as :item's parent (i.e. an
|
||||
// already-issued cert :item could reuse instead of getting its own)? Two
|
||||
// cases, covered by two different lookups: a brand-new subdomain that has
|
||||
// never been created (lookUp()'s normal wildcard fallback finds it, since
|
||||
// the name has no leaf of its own yet), and an ALREADY-EXISTING host or the
|
||||
// wildcard's own base domain (lookUp() would just resolve to that host's
|
||||
// own leaf -- lookUpWildcardParent() checks the sibling "*" slot instead;
|
||||
// see its comment in models/host.js). Used by the host create/edit form to
|
||||
// decide whether to offer "Parent Wildcard" as a challenge type.
|
||||
router.get('/wildcard-parent/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
let match = Model.lookUp(req.params.item);
|
||||
if(!match || !match.is_wildcard){
|
||||
match = Model.lookUpWildcardParent(req.params.item);
|
||||
}
|
||||
return res.json({
|
||||
results: (match && match.is_wildcard) ? match : null,
|
||||
});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// The full lookup tree exposes every host, so restrict it to admins.
|
||||
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
|
||||
@@ -185,6 +185,99 @@ describe('Host wildcard base-domain lookup', () => {
|
||||
await populateTree(Host, ['*.cool.mysite.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
|
||||
});
|
||||
|
||||
// Regression: the common case -- an already-existing single-label subdomain
|
||||
// (its own auto-SSL/HTTP-01 host) sitting beside a wildcard, e.g.
|
||||
// sso.nl.wgnode.com under *.nl.wgnode.com. The wildcard is a SIBLING of the
|
||||
// subdomain's leftmost label, not a child of its node, so the old walk (which
|
||||
// consumed "sso" and only checked that leaf's "*" child) never found it and
|
||||
// the edit form's "Parent Wildcard" option stayed permanently greyed out.
|
||||
test('lookUpWildcardParent finds a sibling wildcard for an existing single-label subdomain', async () => {
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||
const result = Host.lookUpWildcardParent('sso.nl.wgnode.com');
|
||||
assert.ok(result, 'Should find the sibling wildcard');
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
|
||||
// A subdomain with no leaf of its own (never created) is deliberately NOT
|
||||
// this method's job -- the walk stops before reaching the sibling "*" slot.
|
||||
// The route resolves that case via plain lookUp()'s wildcard fallback first
|
||||
// (covered in the route-fallback describe block below).
|
||||
test('lookUpWildcardParent returns undefined for a subdomain with no leaf of its own', async () => {
|
||||
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('api.nl.wgnode.com'), undefined);
|
||||
});
|
||||
|
||||
test('lookUpWildcardParent does not treat a deeper wildcard as covering a shallower host', async () => {
|
||||
// *.deep.nl.wgnode.com must NOT be offered as a parent for sso.nl.wgnode.com
|
||||
// (a single-level wildcard covers only its own direct children).
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.deep.nl.wgnode.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('sso.nl.wgnode.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);
|
||||
});
|
||||
|
||||
// The user's scenario: sso.nl.wgnode.com already exists as its own host, and
|
||||
// a *.nl.wgnode.com wildcard is added afterward. lookUp() resolves to sso's
|
||||
// own (non-wildcard) leaf, so the fallback to lookUpWildcardParent() is what
|
||||
// surfaces the sibling wildcard and lets the edit form offer conversion.
|
||||
test('finds the sibling wildcard for an already-existing single-label subdomain', async () => {
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||
const result = findWildcardParent('sso.nl.wgnode.com');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
|
||||
test('finds the sibling wildcard for a never-created single-label subdomain', async () => {
|
||||
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||
const result = findWildcardParent('api.nl.wgnode.com');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -219,11 +312,17 @@ function createMockHostClassWithWildcardParentFix() {
|
||||
|
||||
static lookUpWildcardParent(host) {
|
||||
let place = this.lookUpObj;
|
||||
let parent = undefined;
|
||||
for(let fragment of host.split('.').reverse()){
|
||||
if(!place[fragment]) return undefined;
|
||||
if(!place[fragment]){ place = undefined; break; }
|
||||
parent = place;
|
||||
place = place[fragment];
|
||||
}
|
||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 1: wildcard is a child of host's own node (base domain).
|
||||
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 2: wildcard is a sibling of host's leftmost label
|
||||
// (single-label subdomain, e.g. sso.nl.wgnode.com -> *.nl.wgnode.com).
|
||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -243,7 +342,11 @@ async function populateTree(Host, hosts) {
|
||||
}
|
||||
|
||||
if(fragments.length === 0){
|
||||
pointer[fragment]['#record'] = {host};
|
||||
// is_wildcard mirrors the real Host model's own field (set
|
||||
// whenever a host is DNS-01 wildcard-issued, i.e. starts with
|
||||
// "*."), needed by tests that check it the same way the real
|
||||
// /wildcard-parent/:item route does.
|
||||
pointer[fragment]['#record'] = {host, is_wildcard: host.startsWith('*.')};
|
||||
|
||||
if(fragment === '*' && !pointer['#record']){
|
||||
pointer['#record'] = pointer[fragment]['#record'];
|
||||
|
||||
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
|
||||
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
|
||||
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
|
||||
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
||||
if('targets' in body) body.targets = parseAllowList(body.targets);
|
||||
|
||||
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
||||
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
||||
|
||||
@@ -27,12 +27,14 @@ class SocketServerJson {
|
||||
this.onClientClose = new CallbackQueue(args.onClientClose);
|
||||
this.onClientError = new CallbackQueue(args.onClientError);
|
||||
|
||||
// Set socket file permissions after listening
|
||||
// 777 is acceptable here for single-use container environments
|
||||
// Wrapped in try-catch as chmod may fail in test/restricted environments
|
||||
// Set socket file permissions after listening. 660 (owner + group read/write)
|
||||
// is the safest default; the Docker image runs both processes as root, and
|
||||
// bare-metal operators should ensure the proxy service and openresty share a
|
||||
// group when running as separate users. Wrapped in try-catch as chmod may
|
||||
// fail in test/restricted environments.
|
||||
this.onListen.push(() => {
|
||||
try {
|
||||
fs.chmodSync(this.socketFile, '777');
|
||||
fs.chmodSync(this.socketFile, '660');
|
||||
} catch(err) {
|
||||
// Chmod may fail in test environments or certain filesystems
|
||||
// Socket will still work with default permissions
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
Add DNS Provider
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<a href="/docs/dns" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
</span>
|
||||
</div>
|
||||
@@ -226,6 +227,7 @@
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="card-title">Dynamic A Records</span>
|
||||
<a href="/docs/dns" class="text-reset ms-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<span class="ms-auto text-muted small">
|
||||
This server's public IP:
|
||||
<span class="badge text-bg-primary fs-6"><i class="fa-solid fa-globe me-1"></i><span id="ddns-current-ip">…</span></span>
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
|
||||
<span class="card-title">Add Group</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
|
||||
+41
-5
@@ -212,7 +212,7 @@
|
||||
hostModal().show();
|
||||
}
|
||||
|
||||
function hostEditOpen(host){
|
||||
async function hostEditOpen(host){
|
||||
hostFormReset();
|
||||
let h = $.scope.hosts.getByKey(host);
|
||||
let $f = $('#hostForm');
|
||||
@@ -232,6 +232,7 @@
|
||||
});
|
||||
|
||||
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
|
||||
$f.find("textarea[name='targets']").val(hostFeatureListToText(h.targets));
|
||||
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
|
||||
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
|
||||
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
|
||||
@@ -258,8 +259,31 @@
|
||||
let hostRenameable = !h.is_wildcard && !h.wildcard_parent && !h.is_cache;
|
||||
$f.find('[name=host]').prop('disabled', !hostRenameable);
|
||||
$('#host-rename-help').toggle(!hostRenameable);
|
||||
|
||||
// Reflect + enable the challenge-type options actually available for
|
||||
// this host. Setting the host field's .val() above does not fire a
|
||||
// 'keyup' event, so without this the "Parent Wildcard" option stayed
|
||||
// permanently greyed out on edit even when a valid parent wildcard
|
||||
// existed -- it only ever got un-greyed by the user re-typing the
|
||||
// hostname (the keyup handler further down).
|
||||
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
|
||||
.addClass('challengeType-container');
|
||||
|
||||
if(h.is_wildcard){
|
||||
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
|
||||
$('#challengeType-DNS-01-wildcard').prop('checked', true);
|
||||
$('#wildcard_matchAny-container').removeClass('challengeType-container');
|
||||
}else{
|
||||
let wildcardParent = await hostMatchWildcard(h.host);
|
||||
if(wildcardParent){
|
||||
$('#challengeType-child-container').removeClass('challengeType-container');
|
||||
$('#challengeType-child-relatedHost').text(wildcardParent.host);
|
||||
}
|
||||
if(h.wildcard_parent){
|
||||
$('#challengeType-wildcardChild').prop('checked', true);
|
||||
}else{
|
||||
$('#challengeType-HTTP-01').prop('checked', true);
|
||||
}
|
||||
}
|
||||
|
||||
hostModal().show();
|
||||
@@ -306,10 +330,12 @@
|
||||
|
||||
async function hostMatchWildcard(host){
|
||||
try{
|
||||
let res = await app.api.get(`host/lookup/${host}`);
|
||||
if(res.results && res.results.is_wildcard){
|
||||
return res.results;
|
||||
}
|
||||
// Not /host/lookup/ -- that resolves an ALREADY-EXISTING host to its
|
||||
// own record, not a sibling wildcard (see the route's comment). This
|
||||
// dedicated endpoint correctly finds a usable wildcard parent whether
|
||||
// @host is brand new or already exists as its own host.
|
||||
let res = await app.api.get(`host/wildcard-parent/${host}`);
|
||||
return res.results || false;
|
||||
}catch(error){
|
||||
return false;
|
||||
}
|
||||
@@ -394,6 +420,7 @@
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
|
||||
<span class="card-title fw-bold">Proxy List</span>
|
||||
<span class="ms-auto">
|
||||
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
|
||||
<i class="fa-solid fa-broom"></i>
|
||||
Clear cache
|
||||
@@ -527,6 +554,7 @@
|
||||
<div class="modal-content card border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
|
||||
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
@@ -605,6 +633,14 @@
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
|
||||
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080 10.0.0.3:8080"></textarea>
|
||||
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS & Wildcard -->
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
</span>
|
||||
<span class="card-title">Add Permission</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
@@ -140,6 +141,7 @@
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</span>
|
||||
<span class="card-title">Permissions</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
@@ -207,7 +207,9 @@
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div>
|
||||
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
|
||||
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
|
||||
|
||||
@@ -62,9 +62,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<a id="cl-help" class="nav-link text-light me-3" href="/docs" title="Help">
|
||||
<i class="fa-solid fa-circle-question"></i>
|
||||
</a>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
@@ -105,21 +102,6 @@
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
// Deep-link the header help icon to whichever doc is most relevant to
|
||||
// the current page. No server-side "current section" local exists
|
||||
// (every res.render() call shares one values object, see
|
||||
// routes/render.js), so this follows the same client-side
|
||||
// path-matching convention already used for the top-nav active-link
|
||||
// highlighting just below. Unmapped pages fall back to the docs
|
||||
// index (already /docs, the anchor's default).
|
||||
var HELP_DOCS_BY_PATH = {
|
||||
'/hosts': 'installation',
|
||||
'/dns': 'installation',
|
||||
'/users': 'architecture',
|
||||
'/permissions': 'architecture',
|
||||
'/groups': 'architecture',
|
||||
};
|
||||
|
||||
$(document).ready(function(){
|
||||
|
||||
// Set the correct link to active in the top nav bar
|
||||
@@ -131,9 +113,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
let helpSlug = HELP_DOCS_BY_PATH[window.location.pathname.toLocaleLowerCase()];
|
||||
if(helpSlug) $('#cl-help').attr('href', '/docs/' + helpSlug);
|
||||
|
||||
// Set the correct login/logout button, and reveal admin-only nav
|
||||
// items for global admins.
|
||||
app.auth.isLoggedIn(function(error, data){
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
Add New User
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
</span>
|
||||
</div>
|
||||
@@ -107,6 +108,7 @@
|
||||
User List
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+49
-3
@@ -9,19 +9,29 @@
|
||||
# update is just "sync the repo + reload" -- the files under /etc always track
|
||||
# the repo, so there is nothing to re-copy.
|
||||
#
|
||||
# Secrets live at $SECRETS_FILE (/etc/proxy/secrets.js by default), outside the
|
||||
# repo checkout so they survive the hard reset below. First run seeds it from
|
||||
# secrets.js.example (placeholders you must fill in); later runs never touch
|
||||
# an existing file.
|
||||
#
|
||||
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
|
||||
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
|
||||
# the repo (any drift on the box is discarded).
|
||||
#
|
||||
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=)
|
||||
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
|
||||
# SECRETS_FILE=)
|
||||
set -euo pipefail
|
||||
# Never block on an interactive git credential prompt in CI.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
|
||||
# dependency on a box that's never configured it).
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}"
|
||||
REPO_DIR="${REPO_DIR:-/var/www/proxy}"
|
||||
REPO_DIR="${REPO_DIR:-/opt/theta42/proxy}"
|
||||
BRANCH="${BRANCH:-master}"
|
||||
NODE_MAJOR=22
|
||||
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This script must be run as root (try: sudo $0)" >&2
|
||||
@@ -34,6 +44,19 @@ link(){
|
||||
echo "linked $2 -> $1"
|
||||
}
|
||||
|
||||
# Read the "version" field out of a package.json without depending on Node
|
||||
# being installed yet (this runs before the Node.js install step below).
|
||||
pkg_version(){
|
||||
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
|
||||
}
|
||||
|
||||
# Installed version before this run touches anything, for the upgrade banner
|
||||
# at the end. Empty on a fresh install (no prior checkout).
|
||||
CURRENT_VERSION=""
|
||||
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
|
||||
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||
fi
|
||||
|
||||
echo "==> Base packages"
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -100,6 +123,7 @@ apt-get install -y nodejs openresty
|
||||
echo "==> Lua modules"
|
||||
luarocks install lua-resty-auto-ssl
|
||||
luarocks install luasocket
|
||||
luarocks install lua-resty-balancer
|
||||
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||
luarocks install lua-resty-ipmatcher
|
||||
@@ -134,6 +158,20 @@ else
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
||||
fi
|
||||
|
||||
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||
|
||||
echo "==> Secrets file at ${SECRETS_FILE}"
|
||||
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
|
||||
if [ ! -f "$SECRETS_FILE" ]; then
|
||||
cp "$REPO_DIR/secrets.js.example" "$SECRETS_FILE"
|
||||
chmod 600 "$SECRETS_FILE"
|
||||
echo " seeded ${SECRETS_FILE} from secrets.js.example -- EDIT IT before the proxy will work:"
|
||||
echo " \$EDITOR ${SECRETS_FILE}"
|
||||
echo " then re-run this script (or: sudo systemctl restart proxy)"
|
||||
else
|
||||
echo " ${SECRETS_FILE} already exists, leaving it untouched"
|
||||
fi
|
||||
|
||||
echo "==> Symlink config from the repo"
|
||||
install -d /etc/openresty/sites-enabled /var/log/nginx
|
||||
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
||||
@@ -162,4 +200,12 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Done. Update later with: sudo BRANCH=${BRANCH} $0"
|
||||
echo "==> Done."
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo " Installed v${NEW_VERSION}."
|
||||
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
|
||||
echo " Already up to date (v${NEW_VERSION})."
|
||||
else
|
||||
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
|
||||
fi
|
||||
echo " Update later with: sudo BRANCH=${BRANCH} $0"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
listen 443 ssl http2;
|
||||
listen 443 ssl;
|
||||
listen 4443 ssl;
|
||||
# The "http2" listen parameter is deprecated since nginx 1.25.1 in favor of
|
||||
# this standalone directive, which applies to every "listen ... ssl" in the
|
||||
# server block (both 443 and 4443 here).
|
||||
http2 on;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
|
||||
|
||||
local json = require "cjson"
|
||||
local redis = require "resty.redis"
|
||||
local round_robin = require "resty.balancer.round_robin"
|
||||
|
||||
if not domain then
|
||||
return nil, 499
|
||||
@@ -95,6 +96,45 @@ function M.get(ngx, domain, targetInfo)
|
||||
return nil, 406
|
||||
end
|
||||
|
||||
-- Load balancing
|
||||
local target_list = {}
|
||||
table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"]))
|
||||
|
||||
if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then
|
||||
local decodeOk, decodedTargets = pcall(json.decode, res["targets"])
|
||||
if decodeOk and type(decodedTargets) == "table" then
|
||||
for _, t in ipairs(decodedTargets) do
|
||||
table.insert(target_list, t)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #target_list > 1 then
|
||||
if not M.host_balancers then M.host_balancers = {} end
|
||||
local cache_key = domain .. "_" .. (res["updated_on"] or "0")
|
||||
|
||||
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
|
||||
local b = round_robin:new()
|
||||
local nodes = {}
|
||||
for _, t in ipairs(target_list) do
|
||||
nodes[t] = 1
|
||||
end
|
||||
b:reinit(nodes)
|
||||
M.host_balancers[domain] = { b = b, key = cache_key }
|
||||
end
|
||||
|
||||
local peer = M.host_balancers[domain].b:find()
|
||||
if peer then
|
||||
local colon = peer:find(":")
|
||||
if colon then
|
||||
res["ip"] = peer:sub(1, colon - 1)
|
||||
res["targetPort"] = peer:sub(colon + 1)
|
||||
else
|
||||
res["ip"] = peer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ngx.ctx.targetInfo = res
|
||||
-- Remember which host this target was resolved for, so the reuse guard at
|
||||
-- the top can tell a genuine cache hit from a coalesced request for a
|
||||
|
||||
+3
-2
@@ -8,9 +8,10 @@ Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=root
|
||||
WorkingDirectory=/var/www/proxy/nodejs
|
||||
WorkingDirectory=/opt/theta42/proxy/nodejs
|
||||
Environment="NODE_ENV=production"
|
||||
ExecStart=/usr/bin/env node /var/www/proxy/nodejs/bin/www
|
||||
Environment="CONF_SECRETS=/etc/proxy/secrets.js"
|
||||
ExecStart=/usr/bin/env node /opt/theta42/proxy/nodejs/bin/www
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+7
-4
@@ -6,13 +6,16 @@
|
||||
// direct LDAP client for user lookups. This file supplies that wiring.
|
||||
//
|
||||
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
|
||||
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env
|
||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
|
||||
// CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
|
||||
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
|
||||
// file is authoritative only if the matching app_* env is absent.
|
||||
//
|
||||
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. Values
|
||||
// here override conf/base.js and win over <environment>.js.
|
||||
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
|
||||
// run (with placeholders for the values it can't guess) and points the
|
||||
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
|
||||
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
|
||||
// over <environment>.js.
|
||||
//
|
||||
// Only the keys the app reads are listed below. The `stack` key is read by the
|
||||
// theta-env orchestrator (setup.sh) and ignored by the app.
|
||||
|
||||
Reference in New Issue
Block a user