feat(mdns): announce this site's local presence for agent local-discovery

The announcer half of AGENT_LOCAL_DISCOVERY_SPEC.md / MULTI_SITE_SPEC.md
Appendix B -- advertises which public hostnames this site fronts (and at
what local IP) via mDNS, so a theta-agent on the same LAN segment with
prefer_local_directory enabled can skip the relay/WAN path.

Opt-in via THETA_LOCAL_DISCOVERY_HOSTS (comma-separated); no-op if unset,
so this changes nothing for an install that doesn't configure it. Only
ever advertises which hostnames map to which local IP -- no identity/
trust information -- consistent with the hard rule on the listening side
(theta-agent) that local-discovery may change DNS resolution but must
never touch certificate validation.

Verified end-to-end against the real theta-agent Go binary: announce,
discover, apply, and clean revert on disappearance all confirmed working
over real multicast between two containers.
This commit is contained in:
2026-08-10 18:03:58 -04:00
parent 99276f4ee5
commit fe71ec1bcc
4 changed files with 113 additions and 0 deletions
+2
View File
@@ -19,6 +19,8 @@ require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(async (
require('../models');
const { bootstrapWireguard } = require('../services/wg_bootstrap');
await bootstrapWireguard();
const { startMdnsAnnounce } = require('../services/mdns_announce');
await startMdnsAnnounce();
const app = require('../app');
const middleware = require('../middleware/auth');
+54
View File
@@ -19,6 +19,7 @@
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bonjour-service": "^1.4.4",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10",
@@ -63,6 +64,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -500,6 +507,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bonjour-service": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz",
"integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -892,6 +909,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"license": "MIT",
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/dottie": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
@@ -1170,6 +1199,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1788,6 +1823,19 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"license": "MIT",
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
},
"bin": {
"multicast-dns": "cli.js"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@@ -2898,6 +2946,12 @@
"node": ">=6"
}
},
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bonjour-service": "^1.4.4",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10",
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// mDNS local-discovery announcer (MULTI_SITE_SPEC.md Appendix B). Advertises
// this site's local presence so an agent on the same LAN segment (with
// prefer_local_directory enabled -- see theta-agent's local_discovery.go)
// can skip the relay/WAN path and talk to the local instance directly.
//
// What gets announced is deliberately just "which public hostnames does
// this site front, and at what local IP" -- nothing about identity or
// trust. The listening side never weakens certificate validation based on
// this; it only ever changes DNS resolution (see the hard rule documented
// in theta-agent's local_discovery.go).
const SERVICE_TYPE = 'theta-suite'; // -> _theta-suite._tcp, matches theta-agent's mdnsServiceName
function announcedHosts() {
return (process.env.THETA_LOCAL_DISCOVERY_HOSTS || '')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
}
let bonjourInstance = null;
let publishedService = null;
async function startMdnsAnnounce() {
const hosts = announcedHosts();
if (hosts.length === 0) {
console.log('[mdns-announce] THETA_LOCAL_DISCOVERY_HOSTS not set -- nothing to announce, skipping');
return;
}
const { Bonjour } = require('bonjour-service');
bonjourInstance = new Bonjour(undefined, (err) => {
console.error('[mdns-announce] bonjour-service error:', err.message);
});
publishedService = bonjourInstance.publish({
name: `theta-suite-${process.env.SITE_SLUG || 'site'}`,
type: SERVICE_TYPE,
port: Number(process.env.PORT) || 80,
txt: { hosts: hosts.join(','), site: process.env.SITE_SLUG || '' }
});
console.log(`[mdns-announce] announcing on the local network: ${hosts.join(', ')}`);
}
function stopMdnsAnnounce() {
if (bonjourInstance) {
bonjourInstance.destroy();
bonjourInstance = null;
publishedService = null;
}
}
module.exports = { startMdnsAnnounce, stopMdnsAnnounce };