Merge pull request #144 from theta42/airgap-and-docs

Air-gap fixes + in-app /docs
This commit is contained in:
2026-07-16 15:30:21 -04:00
committed by GitHub
13 changed files with 181 additions and 87 deletions
+5 -2
View File
@@ -14,8 +14,6 @@ ops/cookbooks/
ops/roles/ ops/roles/
ops/proxy.service ops/proxy.service
# Docs site (served via GitHub Pages, not from the image).
docs/
.github/ .github/
# Git + editor + secrets. # Git + editor + secrets.
@@ -24,8 +22,13 @@ docs/
# nodejs/utils/build_info.js), then it's discarded before the final stage. # nodejs/utils/build_info.js), then it's discarded before the final stage.
# It never ends up in the final image. # It never ends up in the final image.
.gitignore .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 *.md
!README.md !README.md
!DEPLOYMENT.md
!nodejs/api.md
!docs/**/*.md
secrets.js secrets.js
secrets.json secrets.json
*.env *.env
+9
View File
@@ -106,6 +106,15 @@ COPY nodejs/services ./services
COPY nodejs/utils ./utils COPY nodejs/utils ./utils
COPY nodejs/views ./views COPY nodejs/views ./views
COPY nodejs/public ./public 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). # Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit COPY --from=gitinfo /commit.txt ./.build_commit
+5
View File
@@ -77,6 +77,11 @@ app.use('/__proxy_auth', require('./routes/host_auth'));
// Routes for front end content. // Routes for front end content.
app.use('/', require('./routes/render')); 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 // Routes for API
app.use('/api', require('./routes/api')); app.use('/api', require('./routes/api'));
+10 -2
View File
@@ -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(){ static async refreshAll(){
let records = await this.listDetail();
if(!records.length) return {count: 0};
let ip; let ip;
try{ try{
ip = await getPublicIp(); ip = await getPublicIp();
@@ -86,7 +95,6 @@ class DynamicRecord extends Table{
return {error: error.message}; return {error: error.message};
} }
let records = await this.listDetail();
for(let record of records){ for(let record of records){
await record.apply(ip); await record.apply(ip);
} }
+13
View File
@@ -25,6 +25,7 @@
"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",
"marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.5.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
@@ -1410,6 +1411,18 @@
"integrity": "sha512-TyPFnk3kp9kplKPjWtYYbezYzj+Xe47bGu3YZs2Jg3xxLUw/ny0AHL252jpR1c8q32vIQxWK8qLpFZ+qHHC0MQ==", "integrity": "sha512-TyPFnk3kp9kplKPjWtYYbezYzj+Xe47bGu3YZs2Jg3xxLUw/ny0AHL252jpR1c8q32vIQxWK8qLpFZ+qHHC0MQ==",
"license": "MIT" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -36,6 +36,7 @@
"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",
"marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.5.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
+78
View File
@@ -0,0 +1,78 @@
'use strict';
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' ? `<i class="fa-brands fa-dev"></i>` : '',
...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.use(docsLimiter);
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;
-3
View File
@@ -80,7 +80,4 @@ router.get('/login/*splat', async function(req, res, next) {
res.render('login', {...values, redirect: req.query.redirect}); 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; module.exports = router;
+3
View File
@@ -10,6 +10,9 @@
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a> <a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
</span> </span>
<span class="d-flex align-items-center gap-3"> <span class="d-flex align-items-center gap-3">
<a href="/docs" class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none"> <a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub <i class="fa-brands fa-github"></i> GitHub
</a> </a>
+26
View File
@@ -0,0 +1,26 @@
<%- include('top') %>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-book"></i> Documentation
</div>
<div class="card-body">
<p class="text-muted">
A local copy of this project's documentation, readable from the
running app -- no internet access required.
</p>
<ul class="list-group">
<% docs.forEach(function(doc){ %>
<li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
</li>
<% }) %>
</ul>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+31
View File
@@ -0,0 +1,31 @@
<%- include('top') %>
<div class="row">
<div class="col-md-3 d-none d-md-block">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-book"></i> Documentation
</div>
<div class="list-group list-group-flush">
<% docs.forEach(function(doc){ %>
<a href="/docs/<%= doc.slug %>"
class="list-group-item list-group-item-action<%= doc.slug === currentSlug ? ' active' : '' %>">
<%= doc.title %>
</a>
<% }) %>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-file-lines"></i> <%= docTitle %>
</div>
<div class="card-body markdown-body">
<%- docHtml %>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
-74
View File
@@ -1,74 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<!-- need to load jq-query cdn or file. -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<!-- need to load mustache cdn or file. -->
<script src="
https://cdn.jsdelivr.net/npm/mustache@4.2.0/mustache.min.js
"></script>
<!-- need to load jq-repeat cdn or file -->
<script type="text/javascript" src='/static/lib/js/jq-repeat_new.js'></script>
<script>
//on document ready. the logic would execute.
$(document).ready(function () {
$.scope.toDo.__setPut(function($el, item, list){
$el.slideDown('slow');
})
// $.scope.toDo.__setUpdate(function($el, $render, item, list){
// $el.fadeOut(2000, function() {
// $(this).html($render.html()).fadeIn(2000);
// });
// });
// $.scope.toDo.__setUpdate(function($el, $render, item, list){
// $el.animate({'opacity': 0}, 400, function(){
// $(this).html($render.html()).animate({'opacity': 1}, 400);
// });
// });
$.scope.toDo.__setUpdate(function($el, $render, item, list){
$el.slideUp(function(){
$(this).replaceWith($render)
$render.slideDown()
})
});
$.scope.toDo.push({ item: "Get milk", done: "Yes" }); // 0
$.scope.toDo.push({ item: "Do laundry", done: "No" }); // 1
//should take array id or array key
// - **howMany** _Type: Number_
// Number of repeat objects that will be removed. If there are non to be removed, it is not required to use this argument.
// - **update** _Type: Array_
// This is the array of repeat objects to add. If there are none to this is not required.
//remove works
// $.scope.toDo.splice("Get milk" , "1")
//update
$.scope.toDo.splice(-1,0, { item: "Get Bread", done: "Yes" })
});
</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul>
<li jq-repeat="toDo" jq-repeat-index="item" style="display:none;"><span class="item">{{ item }}</span>: {{ done }}</li>
</ul>
</body>
</html>
-6
View File
@@ -24,12 +24,6 @@
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head> </head>
<body> <body>