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:
co-authored by
Claude Fable 5.1
parent
e7dc235a8a
commit
50caf02023
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user