Skip to content
Open
22 changes: 16 additions & 6 deletions HousePanel.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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)
{
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,28 @@ <h3>Hub Push Node.js Installation</h3>
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.
<br><br>

<h3>Push Authentication</h3>
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 &mdash;
the SmartApp will then send it as an <code>Authorization: Bearer &lt;token&gt;</code>
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 <code>GET /</code> does not require
this token.
<br><br>

You do <b>not</b> 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
<code>sudo systemctl restart housepanel-push</code>.
<br>

<h3>Set Server Permissions</h3>
Expand Down
163 changes: 124 additions & 39 deletions housepanel-push/housepanel-push.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [ ];
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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
// <token> 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 = "<p>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. <br>" +
"Currently connected to " + clients.length + " clients.</p>";
str = str + "<br><hr><br>";

for (var i=0; i < clients.length; i++) {
str = str + "Client #" + i + " host= " + clients[i].socket.remoteAddress.substring(7) + " <br>";
// str = str + "Client #" + i + " host= " + clients[i].origin + " <br>";
}
res.send(str);
console.log((new Date()) + "GET request. Currently connected to " + clients.length + " clients. " );
});
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
};
Loading