diff --git a/HousePanel.groovy b/HousePanel.groovy
index c980fb5..499921c 100644
--- a/HousePanel.groovy
+++ b/HousePanel.groovy
@@ -107,6 +107,7 @@ preferences {
paragraph "Specify these parameters to enable direct and instant hub pushes when things change in your home."
input "webSocketHost", "text", title: "Host IP", defaultValue: "192.168.11.20", required: false
input "webSocketPort", "text", title: "Port", defaultValue: "19234", required: false
+ input "pushToken", "text", title: "Push Token (copy from HousePanel Options page)", required: false
}
section("Lights and Switches") {
input "myswitches", "capability.switch", multiple: true, required: false, title: "Switches"
@@ -197,6 +198,7 @@ def initialize() {
state.usepistons = settings?.usepistons ?: false
state.directIP = settings?.webSocketHost ?: ""
state.directPort = settings?.webSocketPort ?: "19234"
+ state.pushToken = settings?.pushToken ?: ""
state.tz = settings?.timezone ?: "America/Detroit"
state.prefix = settings?.hubprefix ?: getPrefix()
state.dateFormat = settings?.dateformat ?: "M/dd h:mm"
@@ -206,7 +208,10 @@ def initialize() {
webCoRE_init()
}
state.loggingLevelIDE = settings.configLogLevel?.toInteger() ?: 3
- logger("Installed ${hubtype} hub with settings: ${settings} ", "debug")
+ logger("Installed ${hubtype} hub. " +
+ "webSocket: ${settings?.webSocketHost}:${settings?.webSocketPort}, " +
+ "cloudCalls: ${settings?.cloudcalls}, " +
+ "timezone: ${settings?.timezone}", "debug")
if (state.directIP)
{
@@ -2383,14 +2388,19 @@ def postHub(msgtype, name, id, attr, value) {
// Send Using the Direct Mechanism
logger("Sending ${msgtype} to Websocket at ${state.directIP}:${state.directPort}", "info")
- // set a hub action - include the access token so we know which hub this is
+ // set a hub action - include the push token so housepanel-push can
+ // authenticate this request as coming from an authorized hub
+ def pushHeaders = [
+ HOST: "${state.directIP}:${state.directPort}",
+ 'Content-Type': 'application/json'
+ ]
+ if ( state?.pushToken ) {
+ pushHeaders['Authorization'] = "Bearer ${state.pushToken}"
+ }
def params = [
method: "POST",
path: "/",
- headers: [
- HOST: "${state.directIP}:${state.directPort}",
- 'Content-Type': 'application/json'
- ],
+ headers: pushHeaders,
body: [
msgtype: msgtype,
change_name: name,
diff --git a/docs/index.html b/docs/index.html
index a4eb8bf..e98991b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -588,6 +588,28 @@
Hub Push Node.js Installation
completely robust so it might fail, in which case the details above should help.
Over time I will continue improving the install.sh script to make it more robust
and foolproof.
+
+
+
Push Authentication
+housepanel-push requires every state-changing hub POST to include a shared secret,
+so a remote attacker cannot forge push traffic. Open the HousePanel Options page in
+your browser once after upgrading; a Push Token field will be generated automatically
+and shown there (click it to select and copy). Paste that value into the new
+"Push Token" setting in your SmartThings/Hubitat HousePanel SmartApp and save it —
+the SmartApp will then send it as an Authorization: Bearer <token>
+header on every push. Until you do this, hub pushes will be rejected with 401
+Unauthorized (or 503 if the Options page has never been opened on this install, since
+no token has been generated yet). The status page at GET / does not require
+this token.
+
+
+You do not need to restart housepanel-push after the token is generated. The
+service re-reads the token from hmoptions.cfg whenever that file changes, so an
+already-running service starts accepting authenticated pushes on the next one, and
+picks up a changed token the same way. If pushes still fail after you have copied the
+token into the SmartApp, confirm the token in the SmartApp matches the Options page
+exactly, then as a fallback reload the service with
+sudo systemctl restart housepanel-push.
Set Server Permissions
diff --git a/housepanel-push/housepanel-push.js b/housepanel-push/housepanel-push.js
index bf1ff54..67f0012 100644
--- a/housepanel-push/housepanel-push.js
+++ b/housepanel-push/housepanel-push.js
@@ -2,9 +2,18 @@
process.title = 'housepanel-push';
// websocket and http servers
-var webSocketServer = require('websocket').server;
+// the websocket module is optional at load time so this file can be required
+// (by the smoke tests) before npm install has been run; the try block below
+// already degrades gracefully when the server cannot be created
+var webSocketServer = null;
+try {
+ webSocketServer = require('websocket').server;
+} catch (e) {
+ webSocketServer = null;
+}
var http = require('http');
var fs = require('fs');
+var crypto = require('crypto');
// list of currently connected clients (users)
var clients = [ ];
@@ -16,6 +25,12 @@ var elements = [ ];
var config;
var hubs;
+// push token cached from the main options file, with the file and mtime it
+// was read from so we can pick up changes without restarting the service
+var pushToken = null;
+var pushTokenFname = null;
+var pushTokenMtime = null;
+
// server variables
var server;
var app;
@@ -46,34 +61,34 @@ try {
app = null;
}
+// the places HousePanel may have installed hmoptions.cfg, in priority order
+var optionsCandidates = [
+ "hmoptions.cfg",
+ "../hmoptions.cfg",
+ "/var/www/html/housepanel/hmoptions.cfg",
+ "/var/www/html/smartthings/hmoptions.cfg"
+];
+
+// return the path to the options file, or null if none of them exist
+function locateOptionsFile() {
+ for ( var i=0; i < optionsCandidates.length; i++ ) {
+ try {
+ fs.statSync(optionsCandidates[i]);
+ return optionsCandidates[i];
+ } catch (err) {
+ // try the next candidate
+ }
+ }
+ return null;
+}
+
function updateElements() {
elements = [ ];
hubs = null;
// read options file here since it could have changed
-
- fname = "hmoptions.cfg";
- try {
- fs.statSync(fname);
- } catch (err) {
- try {
- fname = "../hmoptions.cfg";
- fs.statSync(fname);
- } catch (err2) {
- try {
- fname = "/var/www/html/housepanel/hmoptions.cfg";
- fs.statSync(fname);
- } catch (err3) {
- try {
- fname = "/var/www/html/smartthings/hmoptions.cfg";
- fs.statSync(fname);
- } catch (err4) {
- fname = null;
- }
- }
- }
- }
-
+ fname = locateOptionsFile();
+
if ( fname === null ) {
console.log('housepanel-push installed but hmoptions file not found. Will be activated when HousePanel is used and the first hub is authorized.');
return;
@@ -158,7 +173,7 @@ function updateElements() {
});
applistening = true;
} else {
- console.log((new Date()) + "Node.js application port not valid. port= ", config.port);
+ console.log((new Date()) + "Node.js application port not valid. port= ", config ? config.port : 'none');
}
if ( !serverlistening && server && config && config.webSocketServerPort ) {
@@ -167,23 +182,77 @@ function updateElements() {
});
serverlistening = true;
} else {
- console.log("webSocket port not valid. webSocketServerPort= ", config.webSocketServerPort);
+ console.log("webSocket port not valid. webSocketServerPort= ", config ? config.webSocketServerPort : 'none');
+ }
+}
+
+// read the push token straight from hmoptions.cfg, re-reading only when the
+// file has changed. updateElements() is not a usable source here: it only runs
+// at startup, on a websocket message, or on the "initialize" POST -- and that
+// POST is itself behind this auth check. Without an independent read, a service
+// that started before the Options page generated a token would reject every
+// push until it was restarted. Deliberately does not touch config/hubs/elements
+// or make hub requests, so an unauthenticated caller cannot trigger any work.
+function getPushToken() {
+ var tokenFile = locateOptionsFile();
+ if ( tokenFile === null ) {
+ pushToken = null;
+ pushTokenMtime = null;
+ return null;
+ }
+
+ try {
+ var mtime = fs.statSync(tokenFile).mtimeMs;
+ if ( tokenFile !== pushTokenFname || mtime !== pushTokenMtime ) {
+ var options = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
+ pushToken = (options && options.config && options.config.pushToken) || null;
+ pushTokenFname = tokenFile;
+ pushTokenMtime = mtime;
+ }
+ } catch (e) {
+ pushToken = null;
+ pushTokenMtime = null;
}
+ return pushToken;
}
-// a callback function to give status info if they point a browser here
+// require a shared secret (configured as config.pushToken in hmoptions.cfg,
+// generated and displayed by the HousePanel Options page) on state-changing
+// requests so remote attackers cannot inject fake hub push traffic. Fails
+// closed if no token has been configured yet. Only Authorization: Bearer
+// is accepted -- no header/query/body alternatives, since those can
+// leak into access logs.
+function checkPushAuth(req, res) {
+ var token = getPushToken();
+ if ( !token ) {
+ console.log((new Date()) + " housepanel-push: pushToken not configured in hmoptions.cfg; rejecting unauthenticated request.");
+ res.status(503).json('housepanel-push is not configured with a pushToken; request rejected');
+ return false;
+ }
+ var auth = req.get('Authorization') || '';
+ var match = auth.match(/^Bearer\s+(.+)$/i);
+ var provided = match ? match[1] : null;
+ var providedBuf = Buffer.from(provided || '');
+ var tokenBuf = Buffer.from(token);
+ var authorized = !!provided &&
+ providedBuf.length === tokenBuf.length &&
+ crypto.timingSafeEqual(providedBuf, tokenBuf);
+ if ( !authorized ) {
+ console.log((new Date()) + " housepanel-push: rejected unauthorized request from " + req.ip);
+ res.status(401).json('unauthorized');
+ return false;
+ }
+ return true;
+}
+
+// a callback function to give status info if they point a browser here.
+// this is a public status page (no credentials required) so it only
+// reports a client count, never per-client host/IP details.
if ( app ) {
app.get("/", function (req, res) {
-
var str = "
This is housepanel-push used to forward state from hubs to HousePanel dashboards. " +
"To use this you must install housepanel-push as a service on some server. " +
"Currently connected to " + clients.length + " clients.
";
- str = str + " ";
-
- for (var i=0; i < clients.length; i++) {
- str = str + "Client #" + i + " host= " + clients[i].socket.remoteAddress.substring(7) + " ";
- // str = str + "Client #" + i + " host= " + clients[i].origin + " ";
- }
res.send(str);
console.log((new Date()) + "GET request. Currently connected to " + clients.length + " clients. " );
});
@@ -192,6 +261,7 @@ if ( app ) {
// handler for messages posted from the hub
if ( app ) {
app.post("/", function (req, res) {
+ if ( !checkPushAuth(req, res) ) { return; }
// handle two types of messages posted from hub
// the first initialize type tells Node.js to update elements
@@ -208,17 +278,20 @@ if ( app ) {
for (var num= 0; num< elements.length; num++) {
var entry = elements[num];
+ var changeAttr = req.body['change_attribute'];
if ( entry.id == req.body['change_device'].toString() &&
- req.body['change_attribute']!='trackData' &&
+ changeAttr!='trackData' &&
+ typeof changeAttr === 'string' &&
+ Object.prototype.hasOwnProperty.call(entry.value || {}, changeAttr) &&
entry.value && typeof entry.value === 'object' &&
- entry['value'][req.body['change_attribute']] != req.body['change_value'] )
+ Reflect.get(entry.value, changeAttr) != req.body['change_value'] )
{
cnt = cnt + 1;
// console.log(entry['value']);
- entry['value'][req.body['change_attribute']] = req.body['change_value'];
+ Reflect.set(entry.value, changeAttr, req.body['change_value']);
if ( entry['value']['trackData'] ) { delete entry['value']['trackData']; }
console.log((new Date()) + 'updating tile #',entry['id'],' from trigger:',
- req.body['change_attribute'],' to ', clients.length,' hosts. value= ', JSON.stringify(entry['value']) );
+ changeAttr,' to ', clients.length,' hosts. value= ', JSON.stringify(entry['value']) );
// send the updated element to all clients
// this is processed by the webSockets client in housepanel.js
@@ -299,4 +372,16 @@ if ( wsServer ) {
// start with an initial list of all elements
// this is updated when any hub is reinstalled
-updateElements();
+// only when run as a service; requiring this file (e.g. from the smoke tests)
+// must not bind ports or start reading hubs
+if ( require.main === module ) {
+ updateElements();
+}
+
+module.exports = {
+ checkPushAuth: checkPushAuth,
+ getPushToken: getPushToken,
+ locateOptionsFile: locateOptionsFile,
+ updateElements: updateElements,
+ app: app
+};
diff --git a/housepanel-push/housepanel-push.smoke.js b/housepanel-push/housepanel-push.smoke.js
index 9ea29a9..6029299 100644
--- a/housepanel-push/housepanel-push.smoke.js
+++ b/housepanel-push/housepanel-push.smoke.js
@@ -8,6 +8,9 @@
// 4. healthy path still parses, indexes hubs correctly, and pushes items
const assert = require("assert");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
// Mirrors the callback body now in housepanel-push.js updateElements()
function makeCallback(hubs, elements, logs) {
@@ -212,4 +215,206 @@ const hubs = [
assert.strictEqual(threw, false, "update handler must not throw on non-object value");
}
-console.log("ALL BEHAVIORAL ASSERTIONS PASSED");
+// ---------------------------------------------------------------------------
+// Push authentication tests. These call the REAL checkPushAuth/getPushToken
+// exported by housepanel-push.js -- not a copy of the logic -- so that a
+// regression in production auth fails this suite.
+// ---------------------------------------------------------------------------
+
+// Point the options-file lookup at a fixture we control. locateOptionsFile()
+// tries "hmoptions.cfg" relative to cwd first, so chdir into a temp dir that
+// holds our fixture. Must happen before requiring the module.
+const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "hp-push-auth-"));
+const cfgPath = path.join(tmpdir, "hmoptions.cfg");
+const origCwd = process.cwd();
+
+// getPushToken() re-reads only when mtime changes, so step the mtime back on
+// every write; otherwise same-millisecond rewrites could be missed.
+let mtimeStep = 0;
+function writeCfg(configObj) {
+ fs.writeFileSync(cfgPath, JSON.stringify({ config: configObj }));
+ mtimeStep += 2;
+ const stamp = new Date(Date.now() - (mtimeStep * 1000));
+ fs.utimesSync(cfgPath, stamp, stamp);
+}
+
+// start with a config that has NO pushToken, as every pre-upgrade install does
+writeCfg({ port: "19234", webSocketServerPort: "1337" });
+process.chdir(tmpdir);
+
+const push = require(path.join(__dirname, "housepanel-push.js"));
+
+// invoke the real checkPushAuth with minimal req/res doubles and report the
+// status it sent (or 200 when it authorized and sent nothing)
+function callAuth(authHeader) {
+ let sentStatus = null;
+ const req = {
+ get: function (name) {
+ return (String(name).toLowerCase() === "authorization") ? authHeader : undefined;
+ },
+ ip: "10.0.0.5"
+ };
+ const res = {
+ status: function (code) { sentStatus = code; return res; },
+ json: function () { return res; }
+ };
+ const allowed = push.checkPushAuth(req, res);
+ return { allowed: allowed, status: allowed ? 200 : sentStatus };
+}
+
+const REAL_TOKEN = "s3cr3t-push-token";
+
+// 7. no pushToken configured -> 503 regardless of what is sent
+{
+ assert.strictEqual(callAuth("Bearer anything").status, 503, "unconfigured token must 503");
+ assert.strictEqual(callAuth(undefined).status, 503, "unconfigured token must 503 with no header");
+ assert.strictEqual(push.getPushToken(), null, "no token should be found in a cfg without one");
+}
+
+// 7b. REGRESSION: the token is written while this process is already running.
+// The Options page writes hmoptions.cfg after housepanel-push has started, and
+// the "initialize" POST that used to refresh config is itself behind auth, so
+// this must start working with no restart.
+{
+ writeCfg({ port: "19234", pushToken: REAL_TOKEN });
+ const res = callAuth("Bearer " + REAL_TOKEN);
+ assert.strictEqual(res.allowed, true, "token written after startup must be picked up without a restart");
+ assert.strictEqual(res.status, 200, "valid token must not set an error status");
+ assert.strictEqual(push.getPushToken(), REAL_TOKEN, "getPushToken must return the freshly written token");
+}
+
+// 7c. configured token, no Authorization header -> 401
+{
+ const res = callAuth(undefined);
+ assert.strictEqual(res.allowed, false, "missing Authorization must be rejected");
+ assert.strictEqual(res.status, 401, "missing Authorization must 401");
+}
+
+// 7d. configured token, malformed Authorization -> 401
+{
+ assert.strictEqual(callAuth("Token " + REAL_TOKEN).status, 401, "non-Bearer scheme must 401");
+ assert.strictEqual(callAuth(REAL_TOKEN).status, 401, "raw token with no scheme must 401");
+ assert.strictEqual(callAuth("Bearer").status, 401, "Bearer with no token must 401");
+ assert.strictEqual(callAuth("Bearer ").status, 401, "Bearer with empty token must 401");
+}
+
+// 7e. configured token, wrong bearer token -> 401
+{
+ assert.strictEqual(callAuth("Bearer wrong-token").status, 401, "wrong bearer token must 401");
+ assert.strictEqual(callAuth("Bearer " + REAL_TOKEN + "x").status, 401, "token with extra suffix must 401");
+ assert.strictEqual(callAuth("Bearer " + REAL_TOKEN.slice(0, -1)).status, 401, "truncated token must 401");
+}
+
+// 7f. correct bearer token, case-insensitive scheme -> authorized
+{
+ assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).allowed, true, "correct bearer token must authorize");
+ assert.strictEqual(callAuth("bearer " + REAL_TOKEN).allowed, true, "scheme match must be case-insensitive");
+}
+
+// 7g. a rotated token in the cfg is picked up, and the old one stops working
+{
+ const NEW_TOKEN = "rotated-push-token-value";
+ writeCfg({ port: "19234", pushToken: NEW_TOKEN });
+ assert.strictEqual(callAuth("Bearer " + NEW_TOKEN).allowed, true, "rotated token must authorize");
+ assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).status, 401, "superseded token must 401");
+}
+
+// 7h. a corrupt cfg must fail closed rather than throw
+{
+ fs.writeFileSync(cfgPath, "not json");
+ mtimeStep += 2;
+ const stamp = new Date(Date.now() - (mtimeStep * 1000));
+ fs.utimesSync(cfgPath, stamp, stamp);
+ assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).status, 503, "unparseable cfg must fail closed with 503");
+}
+
+// ---------------------------------------------------------------------------
+// 8. HTTP-level coverage of the real Express routes. Only runs when the
+// dependencies are installed (npm install); the assertions above already
+// exercise production checkPushAuth without them, so skipping here is not a
+// silent gap in auth coverage.
+// ---------------------------------------------------------------------------
+function httpTests() {
+ if ( !push.app ) {
+ console.log("SKIP HTTP-level route tests: express/body-parser not installed (run npm install for this coverage). " +
+ "Unit-level assertions above already ran against production checkPushAuth.");
+ return Promise.resolve();
+ }
+
+ const httpmod = require("http");
+ // no port/webSocketServerPort here on purpose: the "initialize" case below
+ // reaches updateElements(), which would otherwise bind the configured
+ // ports for real and leave this test process hanging on an open handle
+ writeCfg({ pushToken: REAL_TOKEN });
+
+ return new Promise(function (resolve, reject) {
+ const listener = push.app.listen(0, "127.0.0.1", function () {
+ const port = listener.address().port;
+
+ function send(method, headers, body) {
+ return new Promise(function (done, fail) {
+ const req = httpmod.request({
+ host: "127.0.0.1", port: port, path: "/", method: method, headers: headers || {}
+ }, function (res) {
+ let data = "";
+ res.on("data", function (chunk) { data += chunk; });
+ res.on("end", function () { done({ status: res.statusCode, body: data }); });
+ });
+ req.on("error", fail);
+ if ( body ) { req.write(body); }
+ req.end();
+ });
+ }
+
+ const asJson = { "Content-Type": "application/json" };
+ const payload = JSON.stringify({ msgtype: "update", change_device: "1", change_attribute: "switch", change_value: "on" });
+
+ Promise.resolve()
+ .then(function () {
+ return send("POST", asJson, payload).then(function (res) {
+ assert.strictEqual(res.status, 401, "POST with no Authorization must 401");
+ });
+ })
+ .then(function () {
+ return send("POST", Object.assign({ Authorization: "Bearer wrong" }, asJson), payload).then(function (res) {
+ assert.strictEqual(res.status, 401, "POST with wrong bearer token must 401");
+ });
+ })
+ .then(function () {
+ return send("POST", Object.assign({ Authorization: "Bearer " + REAL_TOKEN }, asJson), payload).then(function (res) {
+ assert.strictEqual(res.status, 200, "POST with valid bearer token must be accepted");
+ });
+ })
+ .then(function () {
+ // a legitimate hub "initialize" post, the message that reauthorizes hubs
+ const init = JSON.stringify({ msgtype: "initialize" });
+ return send("POST", Object.assign({ Authorization: "Bearer " + REAL_TOKEN }, asJson), init).then(function (res) {
+ assert.strictEqual(res.status, 200, "authenticated hub initialize must succeed");
+ });
+ })
+ .then(function () {
+ return send("GET", {}).then(function (res) {
+ assert.strictEqual(res.status, 200, "GET status page must not require auth");
+ assert.ok(res.body.indexOf("housepanel-push") >= 0, "GET must return the status page");
+ assert.ok(res.body.indexOf("Client #") < 0, "GET must not disclose per-client host details");
+ });
+ })
+ .then(function () { listener.close(function () { resolve(); }); })
+ .catch(function (err) { listener.close(function () { reject(err); }); });
+ });
+ listener.on("error", reject);
+ });
+}
+
+httpTests()
+ .then(function () {
+ process.chdir(origCwd);
+ fs.rmSync(tmpdir, { recursive: true, force: true });
+ console.log("ALL BEHAVIORAL ASSERTIONS PASSED");
+ })
+ .catch(function (err) {
+ process.chdir(origCwd);
+ fs.rmSync(tmpdir, { recursive: true, force: true });
+ console.error(err);
+ process.exit(1);
+ });
diff --git a/housepanel.php b/housepanel.php
index 10a775a..ad67a34 100644
--- a/housepanel.php
+++ b/housepanel.php
@@ -712,7 +712,7 @@ function getEndpoint($access_token, $stweb, $clientId, $hubType) {
// this used to create input blocks for auth page
// it was modified for use now on the options page
-function tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer) {
+function tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer, $pushToken = "") {
$tc= "";
$tc.= "