From 7d9c63b049aa15997f246abd3966e2e1d472f169 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 16 Jul 2026 15:26:10 -0400 Subject: [PATCH 1/2] Air-gap fixes + in-app /docs (README/DEPLOYMENT/api.md/docs/*) Air-gap: - DynamicRecord.refreshAll() called getPublicIp() (api.ipify.org, icanhazip.com, ifconfig.me) every 4h on a timer regardless of whether any dynamic records were configured -- the one background call in the repo not actually gated by feature use. Now skips the lookup entirely when there's nothing to refresh. - Removed the stray, unauthenticated GET /test page (a leftover jq-repeat demo) that loaded jQuery + Mustache from external CDNs. - Removed a dead IE<9-only html5shim script tag pointing at a domain that no longer resolves. Docs: - New GET /docs (index) and /docs/:slug routes render this project's own README, DEPLOYMENT, api.md, and docs/*.md server-side via marked (new dependency) -- so the documentation is readable from the running app with no route to GitHub Pages, where it otherwise only lives. Public, no auth, same tier as the health endpoint. - .dockerignore/Dockerfile updated: docs/, DEPLOYMENT.md, and nodejs/api.md were previously excluded from the image entirely ("served via GitHub Pages, not from the image") -- now copied in alongside README.md/tos.md-style, since they're needed at runtime. --- .dockerignore | 7 +++- Dockerfile | 9 ++++ nodejs/app.js | 5 +++ nodejs/models/dynamic_record.js | 12 +++++- nodejs/package-lock.json | 13 ++++++ nodejs/package.json | 1 + nodejs/routes/docs.js | 64 ++++++++++++++++++++++++++++ nodejs/routes/render.js | 3 -- nodejs/views/bottom.ejs | 3 ++ nodejs/views/docs_index.ejs | 26 ++++++++++++ nodejs/views/docs_page.ejs | 31 ++++++++++++++ nodejs/views/test.ejs | 74 --------------------------------- nodejs/views/top.ejs | 6 --- 13 files changed, 167 insertions(+), 87 deletions(-) create mode 100644 nodejs/routes/docs.js create mode 100644 nodejs/views/docs_index.ejs create mode 100644 nodejs/views/docs_page.ejs delete mode 100644 nodejs/views/test.ejs diff --git a/.dockerignore b/.dockerignore index 8b934b9..30a7dfb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,8 +14,6 @@ ops/cookbooks/ ops/roles/ ops/proxy.service -# Docs site (served via GitHub Pages, not from the image). -docs/ .github/ # Git + editor + secrets. @@ -24,8 +22,13 @@ docs/ # nodejs/utils/build_info.js), then it's discarded before the final stage. # It never ends up in the final image. .gitignore +# docs/ and the doc files below ARE needed in the image now — served in-app +# at /docs (routes/docs.js) so they're readable without internet access. *.md !README.md +!DEPLOYMENT.md +!nodejs/api.md +!docs/**/*.md secrets.js secrets.json *.env diff --git a/Dockerfile b/Dockerfile index 942bf0d..49ad531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,6 +106,15 @@ COPY nodejs/services ./services COPY nodejs/utils ./utils COPY nodejs/views ./views COPY nodejs/public ./public +COPY nodejs/api.md ./api.md + +# Documentation, served in-app at /docs (routes/docs.js) so it's readable +# without internet access. README.md/DEPLOYMENT.md land one level above the +# flattened /app (mirrors sso-manager-node's tos.md -> /tos.md convention); +# docs/ mirrors the repo's own top-level docs/ folder. +COPY README.md /README.md +COPY DEPLOYMENT.md /DEPLOYMENT.md +COPY docs /docs # Baked commit hash from the gitinfo stage (see build_info.js). COPY --from=gitinfo /commit.txt ./.build_commit diff --git a/nodejs/app.js b/nodejs/app.js index 9bb69a8..2040bd9 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -77,6 +77,11 @@ app.use('/__proxy_auth', require('./routes/host_auth')); // Routes for front end content. app.use('/', require('./routes/render')); +// Local, in-app copy of the project's documentation (README, DEPLOYMENT, +// api.md, docs/*) -- public, no auth, so it's readable even by a locked-out +// admin or an air-gapped operator with no route to GitHub Pages. +app.use('/docs', require('./routes/docs')); + // Routes for API app.use('/api', require('./routes/api')); diff --git a/nodejs/models/dynamic_record.js b/nodejs/models/dynamic_record.js index c2c7762..385a168 100644 --- a/nodejs/models/dynamic_record.js +++ b/nodejs/models/dynamic_record.js @@ -76,8 +76,17 @@ class DynamicRecord extends Table{ } } - // Resolve the public IP once, then reconcile every record to it. + // Resolve the public IP once, then reconcile every record to it. Checked + // BEFORE the public-IP lookup: on a stock install with zero dynamic + // records configured, this runs on a timer regardless (services/dynamic_dns.js) + // -- without this guard it would still reach out to the public-IP + // resolvers (utils/public_ip.js) every cycle for nothing, which is + // exactly the kind of always-on external call an air-gapped deployment + // can't have. static async refreshAll(){ + let records = await this.listDetail(); + if(!records.length) return {count: 0}; + let ip; try{ ip = await getPublicIp(); @@ -86,7 +95,6 @@ class DynamicRecord extends Table{ return {error: error.message}; } - let records = await this.listDetail(); for(let record of records){ await record.apply(ip); } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ab4543f..d045479 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -25,6 +25,7 @@ "jquery": "^4.0.0", "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", + "marked": "^9.1.6", "model-redis": "^1.5.0", "moment": "^2.30.1", "mustache": "^4.2.0", @@ -1410,6 +1411,18 @@ "integrity": "sha512-TyPFnk3kp9kplKPjWtYYbezYzj+Xe47bGu3YZs2Jg3xxLUw/ny0AHL252jpR1c8q32vIQxWK8qLpFZ+qHHC0MQ==", "license": "MIT" }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 51e74d6..e1ec60c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -36,6 +36,7 @@ "jquery": "^4.0.0", "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", + "marked": "^9.1.6", "model-redis": "^1.5.0", "moment": "^2.30.1", "mustache": "^4.2.0", diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js new file mode 100644 index 0000000..57c0eb0 --- /dev/null +++ b/nodejs/routes/docs.js @@ -0,0 +1,64 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const router = require('express').Router(); +const {marked} = require('marked'); +const conf = require('@simpleworkjs/conf'); +const buildInfo = require('../utils/build_info'); + +const values = { + title: conf.environment !== 'production' ? `dev` : '', + titleIcon: conf.environment !== 'production' ? `` : '', + ...buildInfo, +}; + +// Full local copy of the project's documentation, rendered server-side -- +// so an operator running air-gapped (no route to GitHub Pages, where this +// content otherwise only lives) can still read it from the running app. +// 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 = { + overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, + deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')}, + api: {title: 'API Reference', file: path.join(__dirname, '../api.md')}, + installation: {title: 'Installation', file: path.join(__dirname, '../../docs/installation.md')}, + architecture: {title: 'Architecture', file: path.join(__dirname, '../../docs/architecture.md')}, + docker: {title: 'Docker', file: path.join(__dirname, '../../docs/docker.md')}, + contributing: {title: 'Contributing', file: path.join(__dirname, '../../docs/contributing.md')}, +}; + +const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})); + +// README.md links its screenshots as repo-relative "docs/images/...", which +// only resolves correctly on GitHub. Serve that same folder here and rewrite +// the rendered markup to point at it absolutely, so the images work when +// read from /docs/overview too. +router.use('/images', require('express').static(path.join(__dirname, '../../docs/images'))); +function fixImagePaths(html) { + return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); +} + +router.get('/', function(req, res) { + res.render('docs_index', {...values, docs: docList}); +}); + +router.get('/:slug', function(req, res, next) { + const doc = DOCS[req.params.slug]; + if (!doc) return next({status: 404, message: 'Doc not found'}); + + try { + const content = fs.readFileSync(doc.file, 'utf8'); + res.render('docs_page', { + ...values, + docs: docList, + currentSlug: req.params.slug, + docTitle: doc.title, + docHtml: fixImagePaths(marked(content)), + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 17235ba..6f592fc 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -80,7 +80,4 @@ router.get('/login/*splat', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); }); -router.get('/test', async function(req, res, next) { - res.render('test', {...values, redirect: req.query.redirect}); -}); module.exports = router; diff --git a/nodejs/views/bottom.ejs b/nodejs/views/bottom.ejs index 2ff65f0..905359d 100755 --- a/nodejs/views/bottom.ejs +++ b/nodejs/views/bottom.ejs @@ -10,6 +10,9 @@ MIT License + + Docs + GitHub diff --git a/nodejs/views/docs_index.ejs b/nodejs/views/docs_index.ejs new file mode 100644 index 0000000..0dca9cf --- /dev/null +++ b/nodejs/views/docs_index.ejs @@ -0,0 +1,26 @@ +<%- include('top') %> + +
+
+
+
+ Documentation +
+
+

+ A local copy of this project's documentation, readable from the + running app -- no internet access required. +

+ +
+
+
+
+ +<%- include('bottom') %> diff --git a/nodejs/views/docs_page.ejs b/nodejs/views/docs_page.ejs new file mode 100644 index 0000000..2cc9d64 --- /dev/null +++ b/nodejs/views/docs_page.ejs @@ -0,0 +1,31 @@ +<%- include('top') %> + +
+
+
+
+ Documentation +
+
+ <% docs.forEach(function(doc){ %> + + <%= doc.title %> + + <% }) %> +
+
+
+
+
+
+ <%= docTitle %> +
+
+ <%- docHtml %> +
+
+
+
+ +<%- include('bottom') %> diff --git a/nodejs/views/test.ejs b/nodejs/views/test.ejs deleted file mode 100644 index 6e0fc1c..0000000 --- a/nodejs/views/test.ejs +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - Document - - - - - - - \ No newline at end of file diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 68599d8..06cdcfd 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -24,12 +24,6 @@ - - - - From c71b23ef82b84c0e3953ea2b3090de6a37c3be8b Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 16 Jul 2026 15:29:06 -0400 Subject: [PATCH 2/2] docs.js: rate-limit the doc routes (CodeQL: missing rate limiting) Public route reading from disk on every request with no throttling -- add a per-IP limiter matching the routes/auth.js/routes/host.js convention already used elsewhere in this repo. --- nodejs/routes/docs.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 57c0eb0..e99fa6e 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -3,10 +3,22 @@ const fs = require('fs'); const path = require('path'); const router = require('express').Router(); +const {rateLimit} = require('express-rate-limit'); const {marked} = require('marked'); const conf = require('@simpleworkjs/conf'); const buildInfo = require('../utils/build_info'); +// Public, unauthenticated, and reads from disk on every request -- throttle +// per IP so it can't be used to hammer the filesystem (mirrors the pattern +// in routes/auth.js/routes/host.js), generous since this is just docs. +const docsLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: {name: 'TooManyRequests', message: 'Too many requests, please try again later.'}, +}); + const values = { title: conf.environment !== 'production' ? `dev` : '', titleIcon: conf.environment !== 'production' ? `` : '', @@ -39,6 +51,8 @@ function fixImagePaths(html) { return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); } +router.use(docsLimiter); + router.get('/', function(req, res) { res.render('docs_index', {...values, docs: docList}); });