pco-plan: learn the shell's port from requests instead of config/proddash.json

Main now keeps a per-machine data directory whose proddash.json can override
the port, so reading the repo's config file was no longer a safe way to find
the ProPresenter module's loopback feed. Guess the port the way the shell
does (PORT, data-dir proddash.json, repo proddash.json, 24500), then confirm
it from the Host header of the first tile request; a learned port that
refuses the connection (a reverse proxy) is blacklisted and the guess kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-09 15:45:55 -04:00
co-authored by Claude Fable 5.1
parent e7dc235a8a
commit 50caf02023
+67 -9
View File
@@ -26,7 +26,9 @@
* second time, this module reads the propresenter-now-next module's own SSE * second time, this module reads the propresenter-now-next module's own SSE
* feed over loopback (/api/modules/propresenter-now-next/stream) and matches * feed over loopback (/api/modules/propresenter-now-next/stream) and matches
* the live presentation name to a plan item. Item start/end timestamps are * the live presentation name to a plan item. Item start/end timestamps are
* tracked here, once, so every tile agrees on the runtime. * tracked here, once, so every tile agrees on the runtime. The shell's port
* is guessed the way the shell decides it and then confirmed from the Host
* header of the first tile request.
* *
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
* tools/pco-mock.js for development. * tools/pco-mock.js for development.
@@ -34,6 +36,7 @@
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
@@ -91,17 +94,42 @@ function clearedLive() {
}; };
} }
/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */ /**
function shellPort() { * First guess at the shell's port, so the loopback Now/Next feed lands on this
* ProdDash before any browser has talked to us. Mirrors how the shell decides:
* PORT env, then this machine's proddash.json in the data directory, then the
* checked-in config/proddash.json, then 24500. The guess is corrected from the
* Host header of the first request that reaches a route (see learnPort).
*/
function guessShellPort() {
const env = Number(process.env.PORT); const env = Number(process.env.PORT);
if (env) return env; if (env) return env;
try { const home = os.homedir();
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8')); const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
if (Number(cfg.port)) return Number(cfg.port); const root = path.join(__dirname, '..', '..');
} catch { /* fall through */ } const dataDir = fromEnv
? path.resolve(root, fromEnv)
: process.platform === 'win32'
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
: process.platform === 'darwin'
? path.join(home, 'Library', 'Application Support', 'ProdDash')
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
try {
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
if (port) return port;
} catch { /* try the next one */ }
}
return 24500; return 24500;
} }
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
function portFromHost(hostHeader) {
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
if (m) return Number(m[1]) || 0;
return hostHeader ? 80 : 0;
}
function localYmd(d) { function localYmd(d) {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -500,6 +528,23 @@ module.exports = {
let nnRequest = null; let nnRequest = null;
let nnRetryTimer = null; let nnRetryTimer = null;
let lastLiveName = null; let lastLiveName = null;
const guessedPort = guessShellPort();
let nnPort = guessedPort;
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
const badPorts = new Set();
/** A browser reached us on this Host — normally the port the shell really serves on. */
function learnPort(hostHeader) {
const port = portFromHost(hostHeader);
if (!port || port === nnPort || badPorts.has(port)) return;
log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`);
nnPort = port;
if (!follow || stopped) return;
clearTimeout(nnRetryTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
nnRequest = null;
connectNowNext();
}
function resetLiveTracking() { function resetLiveTracking() {
const keep = state.live; const keep = state.live;
@@ -593,7 +638,7 @@ module.exports = {
function connectNowNext() { function connectNowNext() {
if (stopped || !follow) return; if (stopped || !follow) return;
const port = shellPort(); const port = nnPort;
const req = http.get( const req = http.get(
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
(res) => { (res) => {
@@ -628,7 +673,17 @@ module.exports = {
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS)); res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
} }
); );
req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS)); req.on('error', (err) => {
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
if (port !== guessedPort) {
// The Host header lied (reverse proxy, port forward) — that port is
// not this ProdDash. Go back to the guess and never trust it again.
badPorts.add(port);
nnPort = guessedPort;
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
}
nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS);
});
req.setTimeout(45000, () => req.destroy(new Error('feed went silent'))); req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
nnRequest = req; nnRequest = req;
} }
@@ -665,6 +720,7 @@ module.exports = {
streams, streams,
pco, pco,
getState: () => state, getState: () => state,
learnPort,
refresh() { refresh() {
clearTimeout(pollTimer); clearTimeout(pollTimer);
return poll(); return poll();
@@ -707,10 +763,12 @@ module.exports = {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
current?.learnPort(req.headers.host);
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() }); json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
}, },
'GET /stream': (req, res) => { 'GET /stream': (req, res) => {
current?.learnPort(req.headers.host);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8', 'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform', 'Cache-Control': 'no-cache, no-transform',