@iobroker/socket-classes
Version:
ioBroker server-side web sockets
743 lines • 32.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SocketCommon = void 0;
const socketCommands_1 = require("./socketCommands");
// this class can manage 3 types of Socket.io
// - socket.io v2
// - socket.io v4
// - iobroker ws socket
class SocketCommon {
static COMMAND_RE_AUTHENTICATE = 'reauthenticate';
server = null;
serverMode = false;
settings;
adapter;
infoTimeout = null;
store = null; // will be set in __initAuthentication
commands = null;
noDisconnect;
eventHandlers = {};
wsRoutes = {};
// Structure for socket.io 4
allNamespaces;
context;
initialized = false;
constructor(settings, adapter) {
this.settings = settings || {};
this.adapter = adapter;
this.noDisconnect = this.__getIsNoDisconnect();
this.settings.defaultUser ||= 'system.user.admin';
if (!this.settings.defaultUser.match(/^system\.user\./)) {
this.settings.defaultUser = `system.user.${this.settings.defaultUser}`;
}
this.settings.ttl = parseInt(this.settings.ttl, 10) || 3600;
this.context = {
language: this.settings.language,
ratings: null,
ratingTimeout: null,
};
}
__getIsNoDisconnect() {
throw new Error('"__getIsNoDisconnect" must be implemented in SocketCommon!');
}
__initAuthentication(_authOptions) {
throw new Error('"__initAuthentication" must be implemented in SocketCommon!');
}
/** Get user from pure WS socket (used in `iobroker.admin` and `iobroker.ws`) */
__getUserFromSocket(socket, callback) {
// Authentication by bearer token
let accessToken;
if (socket.conn.request.query?.token) {
accessToken = socket.conn.request.query.token;
}
if (!accessToken && socket.conn.request.headers?.authorization?.startsWith('Bearer ')) {
accessToken = socket.conn.request.headers.authorization.split(' ')[1];
}
if (!accessToken && socket.conn.request.headers?.cookie) {
const cookies = socket.conn.request.headers.cookie.split(';');
accessToken = cookies.find(cookie => cookie.trim().split('=')[0] === 'access_token');
if (accessToken) {
accessToken = accessToken.split('=')[1];
}
}
if (accessToken) {
socket._secure = !!this.settings.auth;
void this.adapter.getSession(`a:${accessToken}`, (obj) => {
if (!obj?.user) {
if (socket._acl) {
socket._acl.user = '';
}
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
callback('Cannot detect user');
}
else {
callback(null, obj.user ? `system.user.${obj.user}` : '', obj.aExp);
}
});
return;
}
else if (socket.conn.request.sessionID) {
socket._secure = !!this.settings.auth;
socket._sessionID = socket.conn.request.sessionID;
// Get user for session
void this.adapter.getSession(socket.conn.request.sessionID, obj => {
if (!obj?.passport) {
if (socket._acl) {
socket._acl.user = '';
}
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
callback('Cannot detect user');
}
else {
callback(null, obj.passport.user ? `system.user.${obj.passport.user}` : '', obj.cookie.expires ? new Date(obj.cookie.expires).getTime() : 0);
}
});
return;
}
if (!this.settings.noBasicAuth) {
let user;
let pass;
if (socket.conn.request.headers?.authorization?.startsWith('Basic ')) {
const auth = Buffer.from(socket.conn.request.headers.authorization.split(' ')[1], 'base64').toString('utf8');
const parts = auth.split(':');
user = parts.shift();
pass = parts.join(':');
}
else {
user = socket.query.user;
pass = socket.query.pass;
}
if (user && typeof user === 'string' && pass && typeof pass === 'string') {
// Authentication by user/password
void this.adapter.checkPassword(user, pass, res => {
if (res) {
this.adapter.log.debug(`Logged in: ${user}`);
if (typeof callback === 'function') {
callback(null, user, 0);
}
else {
this.adapter.log.warn('[_getUserFromSocket] Invalid callback');
}
}
else {
this.adapter.log.warn(`Invalid password or user name: ${user}, ${pass[0]}***(${pass.length})`);
if (typeof callback === 'function') {
callback('unknown user');
}
else {
this.adapter.log.warn('[_getUserFromSocket] Invalid callback');
}
}
});
}
else {
callback('Cannot detect user');
}
}
else {
callback('Cannot detect user');
}
}
/** Get client address from socket */
__getClientAddress(socket) {
let address;
if (socket.connection) {
address = socket.connection.remoteAddress;
}
else {
address = socket.ws._socket.remoteAddress;
}
// @ts-expect-error socket.io
if (!address && socket.handshake) {
// @ts-expect-error socket.io
address = socket.handshake.address;
}
// @ts-expect-error socket.io
if (!address && socket.conn.request?.connection) {
// @ts-expect-error socket.io
address = socket.conn.request.connection.remoteAddress;
}
if (address && typeof address !== 'object') {
return {
address,
family: address.includes(':') ? 'IPv6' : 'IPv4',
port: 0,
};
}
return address;
}
// update session ID, but not ofter than 60 seconds
__updateSession(socket) {
const now = Date.now();
if (socket._sessionExpiresAt) {
// If less than 10 seconds, then recheck the socket
if (socket._sessionExpiresAt < Date.now() - 10_000) {
let accessToken = socket.conn.request.headers?.cookie
?.split(';')
.find(c => c.trim().startsWith('access_token='));
if (accessToken) {
accessToken = accessToken.split('=')[1];
}
if (!accessToken) {
// Try to find in a query
accessToken = socket.conn.request.query?.token;
if (!accessToken && socket.conn.request.headers?.authorization?.startsWith('Bearer ')) {
// Try to find in Authentication header
accessToken = socket.conn.request.headers.authorization.split(' ')[1];
}
}
if (accessToken) {
void this.store?.get(`a:${accessToken}`, (err, token) => {
const tokenData = token;
if (err) {
this.adapter.log.error(`Cannot get token: ${err}`);
}
else if (!tokenData?.user) {
this.adapter.log.silly('No session found');
}
else {
socket._sessionExpiresAt = tokenData.aExp;
}
});
}
}
if (socket._sessionExpiresAt < now) {
this.adapter.log.warn('REAUTHENTICATE!');
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
return false;
}
// Check socket expiration time
return true;
}
// Legacy authentication method
const sessionId = socket._sessionID;
if (sessionId) {
if (socket._lastActivity && now - socket._lastActivity > (this.settings.ttl || 3600) * 1000) {
this.adapter.log.warn('REAUTHENTICATE!');
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
return false;
}
socket._lastActivity = now;
socket._sessionTimer ||= setTimeout(() => {
socket._sessionTimer = undefined;
void this.adapter.getSession(socket._sessionID, obj => {
if (obj) {
void this.adapter.setSession(socket._sessionID, this.settings.ttl || 3600, obj);
}
else {
this.adapter.log.warn('REAUTHENTICATE!');
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
}
});
}, 60_000);
}
return true;
}
__getSessionID(_socket) {
throw new Error('"__getSessionID" must be implemented in SocketCommon!');
}
/** Install handler on connecting and disconnecting events */
addEventHandler(eventName, handler) {
this.eventHandlers[eventName] = handler;
}
/**
* Add a new route for the websocket
*
* @param path the path to listen for like "/cameras.0/*"
* @param handler Web socket custom handler
*/
addWsRoute(path, handler) {
this.wsRoutes[path] = handler;
}
start(server, socketClass, authOptions, socketOptions) {
this.serverMode = !!socketClass;
this.commands ||= new socketCommands_1.SocketCommands(this.adapter, socket => this.__updateSession(socket), this.context);
if (!server) {
throw new Error('Server cannot be empty');
}
// it can be used as a client too for cloud
if (socketClass) {
// Case if it is a server
if (!this.initialized) {
// @ts-expect-error socket.io v2 has listen function
if (typeof socketClass.listen === 'function') {
// old socket.io@2.x and ws
// @ts-expect-error socket.io v2 has the listen function
this.server = socketClass.listen(server, socketOptions);
}
else if (typeof socketClass.constructor === 'function') {
// iobroker socket
this.server = new socketClass(server);
}
else {
// socket.io 4.x
// @ts-expect-error socket.io v4 could be created without new
this.server = socketClass(server, socketOptions);
}
// @ts-expect-error socket.io v4 supports namespaces
if (typeof this.server.of === 'function') {
// @ts-expect-error socket.io v4 supports namespaces
this.allNamespaces = this.server.of(/.*/);
}
this.initialized = true;
this.adapter.log.info(`${this.settings.secure ? 'Secure ' : ''}socket.io server listening on port ${this.settings.port}`);
}
if (this.settings.auth && this.server) {
this.__initAuthentication(authOptions);
}
// Enable cross-domain access
// deprecated, because no more used in socket.io@4 only(in @2)
// @ts-expect-error socket.io v2 has "set" for options
if (this.settings.crossDomain && this.server.set) {
// @ts-expect-error socket.io v2 has "set" for options
this.server.set('origins', '*:*');
}
this.server.on('connection', (socket, cb) => {
// Support of handlers for web sockets by path
// Todo: support of wildcards like /cameras.0/*
if (socket.conn?.request?.pathname && this.wsRoutes[socket.conn.request.pathname]) {
this.wsRoutes[socket.conn.request.pathname](socket, cb);
return;
}
this.eventHandlers.connect?.(socket);
this._initSocket(socket, cb);
});
// support of dynamic namespaces (because of reverse proxy) for socket.io 4
this.allNamespaces?.on('connection', (socket, cb) => {
// Support of handlers for web sockets by path
// Todo: support of wildcards like /cameras.0/*
if (socket.conn?.request?.pathname && this.wsRoutes[socket.conn.request.pathname]) {
this.wsRoutes[socket.conn.request.pathname](socket, cb);
return;
}
this.eventHandlers.connect?.(socket);
this._initSocket(socket, cb);
});
}
this.server?.on('error', (error, details) => {
// ignore "failed connection" as it already shown
if (!error?.message?.includes('failed connection')) {
if (error?.message?.includes('authentication failed') ||
details?.toString().includes('authentication failed')) {
this.adapter.log.debug(`Error: ${error?.message || JSON.stringify(error)}${details ? ` - ${!details || typeof details === 'object' ? JSON.stringify(details) : details.toString()}` : ''}`);
}
else {
this.adapter.log.error(`Error: ${error?.message || JSON.stringify(error)}${details ? ` - ${!details || typeof details === 'object' ? JSON.stringify(details) : details.toString()}` : ''}`);
}
}
});
// support of dynamic namespaces (because of reverse proxy)
this.allNamespaces?.on('error', (error, details) => {
// ignore "failed connection" as it already shown
if (!error?.message?.includes('failed connection')) {
if (error?.message?.includes('authentication failed')) {
this.adapter.log.debug(`Error: ${error?.message || JSON.stringify(error)}${details ? ` - ${!details || typeof details === 'object' ? JSON.stringify(details) : details.toString()}` : ''}`);
}
else {
this.adapter.log.error(`Error: ${error?.message || JSON.stringify(error)}${details ? ` - ${!details || typeof details === 'object' ? JSON.stringify(details) : details.toString()}` : ''}`);
}
}
});
this.#updateConnectedInfo();
}
_initSocket(socket, cb) {
this.commands.disableEventThreshold();
const address = this.__getClientAddress(socket);
if (!socket._acl) {
if (this.settings.auth) {
this.__getUserFromSocket(socket, (err, user, expirationTime) => {
if (err || !user) {
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
this.adapter.log.silly(`socket.io [init] ${err || 'No user found in cookies'}`);
// ws does not require disconnect
if (!this.noDisconnect) {
socket.close();
}
if (cb) {
cb();
}
}
else {
socket._secure = true;
socket._sessionExpiresAt = expirationTime;
this.adapter.log.debug(`socket.io client ${user} connected`);
if (!user.startsWith('system.user.')) {
user = `system.user.${user}`;
}
void this.adapter.calculatePermissions(user, socketCommands_1.SocketCommands.COMMANDS_PERMISSIONS, (acl) => {
socket._acl = SocketCommon._mergeACLs(address.address, acl, this.settings.whiteListSettings);
this._socketEvents(socket, address.address, cb);
});
}
});
}
else {
void this.adapter.calculatePermissions(this.settings.defaultUser || '', socketCommands_1.SocketCommands.COMMANDS_PERMISSIONS, (acl) => {
socket._acl = SocketCommon._mergeACLs(address.address, acl, this.settings.whiteListSettings);
this._socketEvents(socket, address.address, cb);
});
}
}
else {
this._socketEvents(socket, address.address, cb);
}
}
unsubscribeSocket(socket, type) {
return this.commands.unsubscribeSocket(socket, type);
}
_unsubscribeAll() {
if (this.server?.ioBroker) {
// this could be an object or array
const sockets = this.getSocketsList();
// this could be an object or array: an array is ioBroker, object is socket.io
if (Array.isArray(sockets)) {
for (const socket of sockets) {
this.commands.unsubscribeSocket(socket);
}
}
else {
Object.values(sockets).forEach(socket => {
this.commands.unsubscribeSocket(socket);
});
}
}
else if (this.server?.sockets) {
// It is socket.io
for (const socket in this.server.sockets) {
if (Object.prototype.hasOwnProperty.call(this.server.sockets, socket)) {
// @ts-expect-error socket.io has own structure
this.commands.unsubscribeSocket(this.server.sockets[socket]);
}
}
}
}
static getWhiteListIpForAddress(address, whiteList) {
if (!whiteList) {
return null;
}
// check IPv6 or IPv4 direct match
if (Object.prototype.hasOwnProperty.call(whiteList, address)) {
return address;
}
// check if the address is IPv4
const addressParts = address.split('.');
if (addressParts.length !== 4) {
return null;
}
// do we have settings for wild-carded ips?
const wildCardIps = Object.keys(whiteList).filter(key => key.includes('*'));
if (!wildCardIps.length) {
// no wild-carded ips => no ip configured
return null;
}
wildCardIps.forEach(ip => {
const ipParts = ip.split('.');
if (ipParts.length === 4) {
for (let i = 0; i < 4; i++) {
if (ipParts[i] === '*' && i === 3) {
// match
return ip;
}
if (ipParts[i] !== addressParts[i]) {
break;
}
}
}
});
return null;
}
static _getPermissionsForIp(address, whiteList) {
return whiteList[SocketCommon.getWhiteListIpForAddress(address, whiteList) || 'default'];
}
static _mergeACLs(address, acl, whiteList) {
if (whiteList && address) {
const whiteListAcl = SocketCommon._getPermissionsForIp(address, whiteList);
if (whiteListAcl) {
if (acl.object && whiteListAcl.object) {
if (whiteListAcl.object.list !== undefined) {
acl.object.list = acl.object.list && whiteListAcl.object.list;
}
if (whiteListAcl.object.read !== undefined) {
acl.object.read = acl.object.read && whiteListAcl.object.read;
}
if (whiteListAcl.object.write !== undefined) {
acl.object.write = acl.object.write && whiteListAcl.object.write;
}
if (whiteListAcl.object.delete !== undefined) {
acl.object.delete = acl.object.delete && whiteListAcl.object.delete;
}
}
if (acl.state && whiteListAcl.state) {
if (whiteListAcl.state.list !== undefined) {
acl.state.list = acl.state.list && whiteListAcl.state.list;
}
if (whiteListAcl.state.read !== undefined) {
acl.state.read = acl.state.read && whiteListAcl.state.read;
}
if (whiteListAcl.state.write !== undefined) {
acl.state.write = acl.state.write && whiteListAcl.state.write;
}
if (whiteListAcl.state.create !== undefined) {
acl.state.create = acl.state.create && whiteListAcl.state.create;
}
if (whiteListAcl.state.delete !== undefined) {
acl.state.delete = acl.state.delete && whiteListAcl.state.delete;
}
}
if (acl.file && whiteListAcl.file) {
if (whiteListAcl.file.list !== undefined) {
acl.file.list = acl.file.list && whiteListAcl.file.list;
}
if (whiteListAcl.file.read !== undefined) {
acl.file.read = acl.file.read && whiteListAcl.file.read;
}
if (whiteListAcl.file.write !== undefined) {
acl.file.write = acl.file.write && whiteListAcl.file.write;
}
if (whiteListAcl.file.create !== undefined) {
acl.file.create = acl.file.create && whiteListAcl.file.create;
}
if (whiteListAcl.file.delete !== undefined) {
acl.file.delete = acl.file.delete && whiteListAcl.file.delete;
}
}
if (whiteListAcl.user !== 'auth') {
acl.user = `system.user.${whiteListAcl.user}`;
}
}
}
return acl;
}
// install event handlers on socket
_socketEvents(socket, address, cb) {
if (this.serverMode) {
this.adapter.log.info(`==> Connected ${socket._acl?.user} from ${address}`);
}
else {
this.adapter.log.info(`Trying to connect as ${socket._acl?.user} to ${address}`);
}
this.#updateConnectedInfo();
if (!this.commands.getCommandHandler('name')) {
// socket sends its name => update list of sockets
this.addCommandHandler('name', (_socket, name, cb) => {
this.adapter.log.debug(`Connection from "${name}"`);
if (_socket._name === undefined) {
_socket._name = name;
this.#updateConnectedInfo();
}
else if (_socket._name !== name) {
this.adapter.log.warn(`socket ${_socket.id} changed socket name from ${_socket._name} to ${name}`);
_socket._name = name;
this.#updateConnectedInfo();
}
if (typeof cb === 'function') {
cb();
}
});
}
this.commands.applyCommands(socket);
// disconnect
socket.on('disconnect', (error) => {
this.commands.unsubscribeSocket(socket);
this.#updateConnectedInfo();
// Disable logging if no one browser is connected
if (this.adapter.requireLog && this.commands?.isLogEnabled()) {
this.adapter.log.debug('Disable logging, because no one socket connected');
void this.adapter.requireLog(!!this.server?.engine?.clientsCount);
}
if (socket._sessionTimer) {
clearTimeout(socket._sessionTimer);
socket._sessionTimer = undefined;
}
if (this.eventHandlers.disconnect) {
this.eventHandlers.disconnect(socket, error?.toString());
}
else {
this.adapter.log.info(`<== Disconnect ${socket._acl?.user} from ${this.__getClientAddress(socket).address} ${socket._name || ''}`);
}
});
if (typeof this.settings.extensions === 'function') {
this.settings.extensions(socket);
}
// if server mode
if (this.serverMode && this.settings.auth) {
let accessToken;
if (socket.conn.request.headers?.cookie) {
// If OAuth2 authentication is used
accessToken = socket.conn.request.headers.cookie
.split(';')
.find(c => c.trim().startsWith('access_token='));
if (accessToken) {
accessToken = accessToken.split('=')[1];
}
else if (socket.conn.request.headers.authorization?.startsWith('Bearer ')) {
accessToken = socket.conn.request.headers.authorization.split(' ')[1];
}
else if (socket.conn.request.query?.token) {
accessToken = socket.conn.request.query.token;
}
if (accessToken) {
socket._secure = true;
this.store?.get(`a:${accessToken}`, (err, token) => {
const tokenData = token;
if (err || !tokenData?.user) {
if (socket._acl) {
socket._acl.user = '';
}
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
// ws does not require disconnect
if (!this.noDisconnect) {
socket.close();
}
}
if (socket._authPending) {
socket._authPending(!!socket._acl?.user, true);
delete socket._authPending;
}
});
}
}
if (!accessToken) {
// Legacy Session ID method
const sessionId = this.__getSessionID(socket);
if (sessionId) {
socket._secure = true;
socket._sessionID = sessionId;
// Get user for session
this.store?.get(socket._sessionID, (err, obj) => {
if (!obj?.passport) {
if (socket._acl) {
socket._acl.user = '';
}
socket.emit(SocketCommon.COMMAND_RE_AUTHENTICATE);
// ws does not require disconnect
if (!this.noDisconnect) {
socket.close();
}
}
if (socket._authPending) {
socket._authPending(!!socket._acl?.user, true);
delete socket._authPending;
}
});
}
}
}
this.commands.subscribeSocket(socket);
if (typeof cb === 'function') {
cb();
}
}
#updateConnectedInfo() {
// only in server mode
if (this.serverMode && !this.settings.noInfoConnected) {
if (this.infoTimeout) {
clearTimeout(this.infoTimeout);
this.infoTimeout = null;
}
this.infoTimeout = setTimeout(() => {
this.infoTimeout = null;
if (this.server) {
const clientsArray = [];
const sockets = this.getSocketsList();
// this could be an object or array: an array is ioBroker, an object is socket.io
if (Array.isArray(sockets)) {
for (const socket of sockets) {
clientsArray.push(socket._name || 'noname');
}
}
else {
Object.values(sockets).forEach(socket => {
clientsArray.push(socket._name || 'noname');
});
}
const text = `[${clientsArray.length}]${clientsArray.join(', ')}`;
void this.adapter.setState('info.connected', text, true);
}
}, 1000);
}
}
checkPermissions(socket, command, callback, ...args) {
return this.commands._checkPermissions(socket, command, callback, args);
}
addCommandHandler(command, handler) {
this.commands.addCommandHandler(command, handler);
}
sendLog(obj) {
// TODO Build in some threshold
this.server?.sockets?.emit('log', obj);
}
publish(socket, type, id, obj) {
return this.commands.publish(socket, type, id, obj);
}
publishInstanceMessage(socket, sourceInstance, messageType, data) {
return this.commands.publishInstanceMessage(socket, sourceInstance, messageType, data);
}
publishFile(socket, id, fileName, size) {
return this.commands.publishFile(socket, id, fileName, size);
}
getSocketsList() {
if (this.server?.sockets) {
// this could be an object or array
return this.server.sockets.sockets || this.server.sockets.connected;
}
return null;
}
publishInstanceMessageAll(sourceInstance, messageType, sid, data) {
const sockets = this.getSocketsList();
if (sockets) {
// this could be an object or array: an array is ioBroker, an object is socket.io
if (Array.isArray(sockets)) {
for (const socket of sockets) {
if (socket.id === sid) {
this.publishInstanceMessage(socket, sourceInstance, messageType, data);
}
}
}
else {
Object.values(sockets).forEach(socket => {
if (socket.id === sid) {
this.publishInstanceMessage(socket, sourceInstance, messageType, data);
}
});
}
}
}
close() {
this._unsubscribeAll();
this.commands.destroy();
const sockets = this.getSocketsList();
if (Array.isArray(sockets)) {
// this could be an object or array
for (const socket of sockets) {
if (socket._sessionTimer) {
clearTimeout(socket._sessionTimer);
socket._sessionTimer = undefined;
}
}
}
else if (sockets) {
Object.keys(sockets).forEach(i => {
const socket = sockets[i];
if (socket._sessionTimer) {
clearTimeout(socket._sessionTimer);
socket._sessionTimer = undefined;
}
});
}
// IO server will be closed
try {
this.server?.close?.();
this.server = null;
}
catch {
// ignore
}
if (this.infoTimeout) {
clearTimeout(this.infoTimeout);
this.infoTimeout = null;
}
}
}
exports.SocketCommon = SocketCommon;
//# sourceMappingURL=socketCommon.js.map