Compare commits

..

24 Commits

Author SHA1 Message Date
wmantly 9eb3dfa2e6 Merge pull request #161 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 20:02:08 -04:00
wmantly a40da55993 Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics. Removed it and added a small help icon directly to each card
that has real corresponding doc content, linking straight to that doc:
Proxy List + Add/Edit host modal (hosts.ejs), Add DNS Provider + Dynamic
A Records (dns.ejs), Add New User + User List (users.ejs), Add
Permission + Permissions (permissions.ejs), Add Group (groups.ejs).

Bumps to v1.1.11.

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

Bumps to v1.1.10.

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

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:03:26 -04:00
wmantly 9a83fb8252 Merge pull request #158 from theta42/wildcard-cert-fixes
Fix wildcard-cert gaps: attach existing host, and register wildcard's own base domain
2026-07-17 18:47:28 -04:00
wmantly 17b903e228 Fix two wildcard-cert gaps: attaching an existing host, and the wildcard's own base domain
- Host.prototype.update() had no challengeType handling (only create() did),
  so selecting "Parent Wildcard" on an existing host's edit form silently
  did nothing. Added the same wildcard-parent lookup to update(), using a
  new Host.lookUpWildcardParent() -- the existing lookUp() can't be reused
  here since an already-created host resolves to its own leaf rather than
  falling through to a sibling wildcard.

- A wildcard's issued cert covers both the base domain and *.base domain
  (altNames), but the lookup tree stores the wildcard one level below its
  base -- looking up the bare base domain landed on an empty parent node
  and found nothing. buildLookUpObj() now also stamps that parent node,
  order-independent (a real host explicitly created at that exact name
  always still wins).

Verified both fixes against a real Redis-backed Host model (not just the
mocked lookup-tree tests) -- see PR description.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 18:46:09 -04:00
wmantly 11f44176c0 Merge pull request #157 from theta42/bump-1.1.7
Bump version to 1.1.7
2026-07-16 20:23:17 -04:00
wmantly b4d971b508 Bump version to 1.1.7; update CHANGELOG 2026-07-16 20:22:02 -04:00
wmantly 28f1c53d06 Merge pull request #156 from theta42/redesign-docs-site
Redesign docs site: match the app's own look, add SEO, mobile-ready
2026-07-16 20:06:38 -04:00
wmantly 8c3a263937 Redesign docs site: match the app's own look, add SEO, mobile-ready
Same treatment as the sso-manager-node companion PR: replaced the
generic jekyll-theme-cayman theme with a custom layout mirroring the
actual app UI -- dark fixed navbar with the theta42 logo, Bootstrap 5
+ Font Awesome (same stack the app uses), content in a card, dark
footer matching bottom.ejs. Keeps this repo's own favicon.svg
(confirmed genuinely distinct SVG artwork from the shared theta42
logo, not a duplicate) as the browser-tab icon.

- New cross-page nav (Home/Installation/Architecture/API/Docker/
  Contributing/Changelog).
- SEO: jekyll-seo-tag + jekyll-sitemap, per-page meta description,
  OG/Twitter card tags, canonical URLs, JSON-LD, sitemap.xml,
  robots.txt.
- Mobile: Bootstrap's responsive grid + collapsible navbar; the
  screenshot pairs in index.md stack to full-width below 576px.

Verified with a real Jekyll build (jekyll/jekyll Docker image) +
Playwright: desktop and mobile (375px) screenshots, mobile nav
toggle, active-link highlighting, zero console/page errors, and
confirmed real SEO output + the correct (non-shared) favicon via curl
against the served site.
2026-07-16 20:05:32 -04:00
wmantly e249b4e168 Merge pull request #155 from theta42/bump-1.1.6
Bump version to 1.1.6
2026-07-16 19:04:34 -04:00
wmantly 34b1413c96 Bump version to 1.1.6; update CHANGELOG 2026-07-16 19:02:59 -04:00
wmantly eded87b6f9 Merge pull request #154 from theta42/fix-host-auth-mode-radios
hosts.ejs: fix Authentication tab radios not enforcing mutual exclusivity
2026-07-16 19:01:26 -04:00
wmantly a57f3f03f6 hosts.ejs: fix Authentication tab radios not enforcing mutual exclusivity
The three auth_mode radios (Off / Basic / SSO) had no shared [name]
attribute, so per the HTML spec each was its own independent group --
clicking one didn't uncheck the others, letting multiple options
appear selected at once despite the page's own text saying "basic
auth and SSO can't both be enabled."

Added name="auth_mode" to restore native browser radio-group
behavior. The original comment claimed the radios were deliberately
kept nameless to avoid polluting the submitted form data (formAJAX
serializes every [name] field in the form), but that reasoning
doesn't hold: model-redis's processKeys() rebuilds the saved object
strictly from the Host model's own _keyMap, so an unrecognized
auth_mode field is silently stripped before anything is ever
persisted -- confirmed directly with model-redis's own
object_validate.js. Updated the stale comment accordingly.
2026-07-16 18:59:50 -04:00
wmantly 5b08eecca9 Merge pull request #151 from theta42/bump-1.1.5
Bump version to 1.1.5
2026-07-16 18:35:41 -04:00
wmantly 7c0cb5eabd Bump version to 1.1.5; update CHANGELOG 2026-07-16 18:34:03 -04:00
wmantly 3f0d6fb438 Merge pull request #150 from theta42/jq-repeat-2.1.0
Update jq-repeat to 2.1.0; fix removed __setPut/__setTake API
2026-07-16 18:27:28 -04:00
wmantly 6cd3a5bc58 Update jq-repeat to 2.1.0; fix removed __setPut/__setTake API
jq-repeat 2.1.0 (release notes: https://github.com/wmantly/jq-repeat/releases/tag/v2.1.0)
brings real fixes (throttled-update race conditions, sorted-list
reverse() leaking elements, nested-scope isolation) and a few
behavior changes. Audited every usage in this repo against the
changelog before upgrading:

- push()/unshift() now return the new array length -- every call
  site in this repo is a bare statement, none consume the return
  value. No risk.
- update() is now trailing-edge throttled (~50ms) even on the first
  call, not just rapid subsequent ones -- no code in this repo reads
  DOM/item state immediately after calling update(), so no risk here
  (unlike sso-manager-node's companion PR, which needed a fix).
- jr-order-reverse and nested jq-repeat templates: not used anywhere
  in this repo.

Real breakage found and fixed: users.ejs/groups.ejs/permissions.ejs
called $.scope.X.__setPut(fn)/__setTake(fn) as setter METHODS -- that
API is gone in 2.1.0. Insert/remove hooks are now set via direct
property assignment ($.scope.X.__put = fn), per the current README.
Verified live (real dev server + Playwright): before the fix, all
three pages threw "__setTake is not a function" and the
insert/remove row animations were broken; after, zero errors and the
hooks fire correctly.
2026-07-16 18:26:02 -04:00
wmantly 88cf5cf281 Merge pull request #149 from theta42/bump-1.1.4
Bump version to 1.1.4
2026-07-16 17:45:22 -04:00
wmantly 526545ee68 Bump version to 1.1.4; update CHANGELOG 2026-07-16 17:44:00 -04:00
wmantly ecf064b9ae Merge pull request #148 from theta42/white-label
White-label: title/logo now driven by conf
2026-07-16 17:36:55 -04:00
wmantly fd71485960 White-label: title/logo now driven by conf (closes #45)
<title>, the navbar brand text, and the logo were all hardcoded
"Proxy - Theta 42"/"Dynamic Proxy". New conf.name/conf.logo keys
(defaults matching current text/asset) thread through the existing
values object pattern in routes/render.js and routes/docs.js, and
top.ejs now renders <%- name %>/<%- logo %> for the title and a new
navbar logo image.

Footer (copyright, theta42.com link, GitHub/license links) and the
existing favicon.svg are left as-is -- open-source attribution and a
distinct, already-working icon asset, not deployment branding.
2026-07-16 17:35:49 -04:00
29 changed files with 743 additions and 38 deletions
+57 -1
View File
@@ -6,6 +6,54 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [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.
Bumps to v1.1.11.
### 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.
### 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()`.
Bumps to v1.1.9.
### 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.
### 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 +82,15 @@ 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.11...HEAD
[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
+41 -3
View File
@@ -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
+82
View File
@@ -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 }} &middot; {% 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>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<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
View File
@@ -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
View File
@@ -1,6 +1,7 @@
--- ---
layout: default layout: default
title: Architecture title: Architecture
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
--- ---
# Architecture # Architecture
+116
View File
@@ -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;
}
+17
View File
@@ -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

+51
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
--- ---
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
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/proxy/sitemap.xml
+2
View File
@@ -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',
+9 -1
View File
@@ -14,6 +14,14 @@ async function getCert(host){
} }
} }
async function setCert(host, cert){
try{
return await client.SET(`${host}:latest`, JSON.stringify(cert));
}catch(error){
return {}
}
}
async function deleteCert(host){ 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
View File
@@ -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.
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.3", "version": "1.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.3", "version": "1.1.11",
"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",
@@ -1375,9 +1375,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
"integrity": "sha512-ATI25tKQG3uHW8f8XPqBe85JsH4PNGHA/YLy1KgMVeYDoUSf9cqGNBum+4A+Pg1WKh9PA6bYyWfYNsgktwIbSg==", "integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.3", "version": "1.1.11",
"private": true, "private": true,
"author": [ "author": [
{ {
@@ -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.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",
+26
View File
@@ -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,
}; };
@@ -58,6 +60,30 @@ 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 = 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'});
+2
View File
@@ -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,
} }
+119
View File
@@ -136,6 +136,125 @@ 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);
});
});
/**
* 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){
pointer[fragment]['#record'] = {host};
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
+2
View File
@@ -125,6 +125,7 @@
Add DNS Provider Add DNS Provider
</span> </span>
<span class="float-end"> <span class="float-end">
<a href="/docs/installation" 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/installation" 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>
+42 -1
View File
@@ -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') %>
+3 -2
View File
@@ -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/architecture" 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">
+45 -11
View File
@@ -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.
@@ -244,9 +249,15 @@
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);
if(h.is_wildcard){ if(h.is_wildcard){
$('#wildcard_matchAny-container').removeClass('challengeType-container'); $('#wildcard_matchAny-container').removeClass('challengeType-container');
} }
@@ -383,6 +394,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/installation" 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 +432,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 +464,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 +528,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/installation" 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 &amp; 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 &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li> <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 +558,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 +604,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 +643,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 +747,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>
+4 -2
View File
@@ -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/architecture" 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/architecture" 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>
+2 -2
View File
@@ -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>
+6 -4
View File
@@ -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/architecture" 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/architecture" 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>
+3
View File
@@ -18,6 +18,9 @@
// 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).