mirror of
https://github.com/koush/scrypted.git
synced 2026-09-17 17:20:39 +01:00
server: user/acl work roughed in
This commit is contained in:
18
server/package-lock.json
generated
18
server/package-lock.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"@mapbox/node-pre-gyp": "^1.0.10",
|
||||
"@scrypted/types": "^0.2.29",
|
||||
"@scrypted/types": "^0.2.36",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "^0.21.4",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -245,9 +245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@scrypted/types": {
|
||||
"version": "0.2.29",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.29.tgz",
|
||||
"integrity": "sha512-l6BCe+2jHPnLaKbUfNxjtgfBkzzIVDhUo8iTjAKyJUfdWZsHZ8IFkzP9GTwenUHmOapDwYDboRum9NCxnjt7AA=="
|
||||
"version": "0.2.36",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.36.tgz",
|
||||
"integrity": "sha512-eXOGIeYUyecupHXsezp/nYkvSgLw/u1X71Cakn93t6suK96JUZwdW6vjF2s0dcSpUh4GCS+gwcUgqU8jF3znRA=="
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
@@ -3281,9 +3281,9 @@
|
||||
}
|
||||
},
|
||||
"@scrypted/types": {
|
||||
"version": "0.2.29",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.29.tgz",
|
||||
"integrity": "sha512-l6BCe+2jHPnLaKbUfNxjtgfBkzzIVDhUo8iTjAKyJUfdWZsHZ8IFkzP9GTwenUHmOapDwYDboRum9NCxnjt7AA=="
|
||||
"version": "0.2.36",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.36.tgz",
|
||||
"integrity": "sha512-eXOGIeYUyecupHXsezp/nYkvSgLw/u1X71Cakn93t6suK96JUZwdW6vjF2s0dcSpUh4GCS+gwcUgqU8jF3znRA=="
|
||||
},
|
||||
"@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"@mapbox/node-pre-gyp": "^1.0.10",
|
||||
"@scrypted/types": "^0.2.29",
|
||||
"@scrypted/types": "^0.2.36",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "^0.21.4",
|
||||
"body-parser": "^1.19.0",
|
||||
|
||||
@@ -20,7 +20,7 @@ export class ScryptedUser extends ScryptedDocument {
|
||||
passwordHash: string;
|
||||
token: string;
|
||||
salt: string;
|
||||
restricted: boolean;
|
||||
aclId: string;
|
||||
}
|
||||
|
||||
export class ScryptedAlert extends ScryptedDocument {
|
||||
|
||||
104
server/src/plugin/acl.ts
Normal file
104
server/src/plugin/acl.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { EventDetails, ScryptedInterface, ScryptedUserAccessControl } from "@scrypted/types";
|
||||
|
||||
/**
|
||||
* Scrypted Access Controls allow selective reading of state, subscription to evemts,
|
||||
* and invocation of methods.
|
||||
* Everything else should be rejected.
|
||||
*/
|
||||
export class AccessControls {
|
||||
constructor(public acl: ScryptedUserAccessControl) {
|
||||
}
|
||||
|
||||
deny(reason: string = 'User does not have permission') {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
shouldRejectDevice(id: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
return !dacls.length;
|
||||
}
|
||||
|
||||
shouldRejectProperty(id: string, property: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.properties || dacl.properties.includes(property))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectEvent(id: string, eventDetails: EventDetails) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
const { property } = eventDetails;
|
||||
if (property) {
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.properties || dacl.properties.includes(property))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const { eventInterface } = eventDetails;
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.interfaces || dacl.interfaces.includes(eventInterface))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectInterface(id: string, scryptedInterface: ScryptedInterface) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.interfaces || dacl.interfaces.includes(scryptedInterface))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectMethod(id: string, method: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.methods || dacl.methods.includes(method))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ScryptedNativeId, ScryptedDevice, Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, ScryptedInterfaceProperty, MediaObject, SystemDeviceState, MediaManager, HttpRequest, ScryptedInterfaceDescriptor } from '@scrypted/types'
|
||||
import type { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, MediaManager, MediaObject, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId, SystemDeviceState } from '@scrypted/types';
|
||||
import { AccessControls } from './acl';
|
||||
|
||||
export interface PluginLogger {
|
||||
log(level: string, message: string): Promise<void>;
|
||||
@@ -14,7 +15,7 @@ export interface PluginAPI {
|
||||
onDeviceEvent(nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise<void>;
|
||||
onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise<void>;
|
||||
onDeviceRemoved(nativeId: string): Promise<void>;
|
||||
setStorage(nativeId: string, storage: {[key: string]: any}): Promise<void>;
|
||||
setStorage(nativeId: string, storage: { [key: string]: any }): Promise<void>;
|
||||
|
||||
getDeviceById(id: string): Promise<ScryptedDevice>;
|
||||
setDeviceProperty(id: string, property: ScryptedInterfaceProperty, value: any): Promise<void>;
|
||||
@@ -22,11 +23,9 @@ export interface PluginAPI {
|
||||
listen(EventListener: (id: string, eventDetails: EventDetails, eventData: any) => void): Promise<EventListenerRegister>;
|
||||
listenDevice(id: string, event: string | EventListenerOptions, callback: (eventDetails: EventDetails, eventData: any) => void): Promise<EventListenerRegister>;
|
||||
|
||||
deliverPush(endpoint: string, request: HttpRequest): Promise<void>;
|
||||
|
||||
getLogger(nativeId: ScryptedNativeId): Promise<PluginLogger>;
|
||||
|
||||
getComponent(id: string): Promise<any>;
|
||||
getComponent(id: string): Promise<any>;
|
||||
|
||||
getMediaManager(): Promise<MediaManager>;
|
||||
|
||||
@@ -66,57 +65,82 @@ export class PluginAPIManagedListeners {
|
||||
}
|
||||
|
||||
export class PluginAPIProxy extends PluginAPIManagedListeners implements PluginAPI {
|
||||
acl: AccessControls;
|
||||
|
||||
constructor(public api: PluginAPI, public mediaManager?: MediaManager) {
|
||||
super();
|
||||
}
|
||||
|
||||
setScryptedInterfaceDescriptors(typesVersion: string, descriptors: { [scryptedInterface: string]: ScryptedInterfaceDescriptor }): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.setScryptedInterfaceDescriptors(typesVersion, descriptors);
|
||||
}
|
||||
|
||||
setState(nativeId: ScryptedNativeId, key: string, value: any): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.setState(nativeId, key, value);
|
||||
}
|
||||
onDevicesChanged(deviceManifest: DeviceManifest): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.onDevicesChanged(deviceManifest);
|
||||
}
|
||||
onDeviceDiscovered(device: Device): Promise<string> {
|
||||
this.acl?.deny();
|
||||
return this.api.onDeviceDiscovered(device);
|
||||
}
|
||||
onDeviceEvent(nativeId: ScryptedNativeId, eventInterface: any, eventData?: any): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.onDeviceEvent(nativeId, eventInterface, eventData);
|
||||
}
|
||||
onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.onMixinEvent(id, nativeId, eventInterface, eventData);
|
||||
}
|
||||
onDeviceRemoved(nativeId: string): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.onDeviceRemoved(nativeId);
|
||||
}
|
||||
setStorage(nativeId: ScryptedNativeId, storage: { [key: string]: any; }): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.setStorage(nativeId, storage);
|
||||
}
|
||||
getDeviceById(id: string): Promise<ScryptedDevice> {
|
||||
if (this.acl?.shouldRejectDevice(id))
|
||||
return;
|
||||
return this.api.getDeviceById(id);
|
||||
}
|
||||
setDeviceProperty(id: string, property: ScryptedInterfaceProperty, value: any): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.setDeviceProperty(id, property, value);
|
||||
}
|
||||
removeDevice(id: string): Promise<void> {
|
||||
this.acl?.deny();
|
||||
return this.api.removeDevice(id);
|
||||
}
|
||||
async listen(callback: (id: string, eventDetails: EventDetails, eventData: any) => void): Promise<EventListenerRegister> {
|
||||
return this.manageListener(await this.api.listen(callback));
|
||||
if (!this.acl)
|
||||
return this.manageListener(await this.api.listen(callback));
|
||||
|
||||
return this.manageListener(await this.api.listen((id, details, data) => {
|
||||
if (!this.acl.shouldRejectEvent(id, details))
|
||||
callback(id, details, data);
|
||||
}));
|
||||
}
|
||||
async listenDevice(id: string, event: string | EventListenerOptions, callback: (eventDetails: EventDetails, eventData: any) => void): Promise<EventListenerRegister> {
|
||||
return this.manageListener(await this.api.listenDevice(id, event, callback));
|
||||
}
|
||||
deliverPush(endpoint: string, request: HttpRequest): Promise<void> {
|
||||
return this.api.deliverPush(endpoint, request);
|
||||
if (!this.acl)
|
||||
return this.manageListener(await this.api.listenDevice(id, event, callback));
|
||||
|
||||
return this.manageListener(await this.api.listenDevice(id, event, (details, data) => {
|
||||
if (!this.acl.shouldRejectEvent(id, details))
|
||||
callback(details, data);
|
||||
}));
|
||||
}
|
||||
getLogger(nativeId: ScryptedNativeId): Promise<PluginLogger> {
|
||||
this.acl?.deny();
|
||||
return this.api.getLogger(nativeId);
|
||||
}
|
||||
getComponent(id: string): Promise<any> {
|
||||
this.acl?.deny();
|
||||
return this.api.getComponent(id);
|
||||
}
|
||||
async getMediaManager(): Promise<MediaManager> {
|
||||
@@ -124,6 +148,7 @@ export class PluginAPIProxy extends PluginAPIManagedListeners implements PluginA
|
||||
}
|
||||
|
||||
async requestRestart() {
|
||||
this.acl?.deny();
|
||||
return this.api.requestRestart();
|
||||
}
|
||||
}
|
||||
@@ -142,11 +167,11 @@ export interface PluginRemoteLoadZipOptions {
|
||||
}
|
||||
|
||||
export interface PluginRemote {
|
||||
loadZip(packageJson: any, zipData: Buffer|string, options?: PluginRemoteLoadZipOptions): Promise<any>;
|
||||
setSystemState(state: {[id: string]: {[property: string]: SystemDeviceState}}): Promise<void>;
|
||||
setNativeId(nativeId: ScryptedNativeId, id: string, storage: {[key: string]: any}): Promise<void>;
|
||||
updateDeviceState(id: string, state: {[property: string]: SystemDeviceState}): Promise<void>;
|
||||
notify(id: string, eventTime: number, eventInterface: string, property: string|undefined, value: SystemDeviceState|any, changed?: boolean): Promise<void>;
|
||||
loadZip(packageJson: any, zipData: Buffer | string, options?: PluginRemoteLoadZipOptions): Promise<any>;
|
||||
setSystemState(state: { [id: string]: { [property: string]: SystemDeviceState } }): Promise<void>;
|
||||
setNativeId(nativeId: ScryptedNativeId, id: string, storage: { [key: string]: any }): Promise<void>;
|
||||
updateDeviceState(id: string, state: { [property: string]: SystemDeviceState }): Promise<void>;
|
||||
notify(id: string, eventTime: number, eventInterface: string, property: string | undefined, value: SystemDeviceState | any, changed?: boolean): Promise<void>;
|
||||
|
||||
ioEvent(id: string, event: string, message?: any): Promise<void>;
|
||||
|
||||
@@ -156,5 +181,5 @@ export interface PluginRemote {
|
||||
}
|
||||
|
||||
export interface MediaObjectRemote extends MediaObject {
|
||||
getData(): Promise<Buffer|string>;
|
||||
getData(): Promise<Buffer | string>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PrimitiveProxyHandler, RpcPeer } from "../rpc";
|
||||
import { ScryptedRuntime } from "../runtime";
|
||||
import { sleep } from "../sleep";
|
||||
import { getState } from "../state";
|
||||
import { AccessControls } from "./acl";
|
||||
import { allInterfaceProperties, getInterfaceMethods, getPropertyInterfaces } from "./descriptor";
|
||||
import { PluginError } from "./plugin-error";
|
||||
|
||||
@@ -396,6 +397,11 @@ export class PluginDeviceProxyHandler implements PrimitiveProxyHandler<any>, Scr
|
||||
async apply(target: any, thisArg: any, argArray?: any): Promise<any> {
|
||||
const method = target();
|
||||
|
||||
const { activeRpcPeer } = RpcPeer;
|
||||
const acl: AccessControls = activeRpcPeer?.tags?.acl;
|
||||
if (acl?.shouldRejectMethod(this.id, method))
|
||||
acl.deny();
|
||||
|
||||
this.ensureProxy();
|
||||
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, HttpRequest, MediaManager, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types';
|
||||
import { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, MediaManager, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Plugin } from '../db-types';
|
||||
import { Logger } from '../logger';
|
||||
@@ -21,7 +21,6 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP
|
||||
'onDeviceEvent',
|
||||
'setStorage',
|
||||
'setDeviceProperty',
|
||||
'deliverPush',
|
||||
'requestRestart',
|
||||
"setState",
|
||||
];
|
||||
@@ -76,10 +75,6 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP
|
||||
return this.mediaManager;
|
||||
}
|
||||
|
||||
async deliverPush(endpoint: string, httpRequest: HttpRequest) {
|
||||
return this.scrypted.deliverPush(endpoint, httpRequest);
|
||||
}
|
||||
|
||||
async getLogger(nativeId: ScryptedNativeId): Promise<Logger> {
|
||||
const device = this.scrypted.findPluginDevice(this.pluginId, nativeId);
|
||||
return this.scrypted.getDeviceLogger(device);
|
||||
|
||||
@@ -9,13 +9,14 @@ import path from 'path';
|
||||
import rimraf from 'rimraf';
|
||||
import { Duplex } from 'stream';
|
||||
import WebSocket from 'ws';
|
||||
import { Plugin } from '../db-types';
|
||||
import { Plugin, ScryptedUser } from '../db-types';
|
||||
import { IOServer, IOServerSocket } from '../io';
|
||||
import { Logger } from '../logger';
|
||||
import { RpcPeer } from '../rpc';
|
||||
import { createDuplexRpcPeer, createRpcSerializer } from '../rpc-serializer';
|
||||
import { ScryptedRuntime } from '../runtime';
|
||||
import { sleep } from '../sleep';
|
||||
import { AccessControls } from './acl';
|
||||
import { MediaManagerHostImpl } from './media';
|
||||
import { PluginAPIProxy, PluginRemote, PluginRemoteLoadZipOptions } from './plugin-api';
|
||||
import { ConsoleServer, createConsoleServer } from './plugin-console';
|
||||
@@ -134,6 +135,12 @@ export class PluginHost {
|
||||
|
||||
this.io.on('connection', async (socket) => {
|
||||
try {
|
||||
const {
|
||||
accessControls,
|
||||
endpointRequest,
|
||||
pluginDevice,
|
||||
} = (socket.request as any).scrypted;
|
||||
|
||||
try {
|
||||
if (socket.request.url.indexOf('/engine.io/api') !== -1) {
|
||||
if (socket.request.url.indexOf('/public') !== -1) {
|
||||
@@ -141,7 +148,7 @@ export class PluginHost {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.createRpcIoPeer(socket);
|
||||
await this.createRpcIoPeer(socket, accessControls);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -150,10 +157,6 @@ export class PluginHost {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
endpointRequest,
|
||||
pluginDevice,
|
||||
} = (socket.request as any).scrypted;
|
||||
|
||||
const handler = this.scrypted.getDevice<EngineIOHandler>(pluginDevice._id);
|
||||
|
||||
@@ -340,7 +343,7 @@ export class PluginHost {
|
||||
});
|
||||
|
||||
this.worker.on('rpc', (message, sendHandle) => {
|
||||
const socket = sendHandle as net.Socket;
|
||||
const socket = sendHandle as net.Socket;
|
||||
const { pluginId } = message;
|
||||
const host = this.scrypted.plugins[pluginId];
|
||||
if (!host) {
|
||||
@@ -355,7 +358,7 @@ export class PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
async createRpcIoPeer(socket: IOServerSocket) {
|
||||
async createRpcIoPeer(socket: IOServerSocket, accessControls: AccessControls) {
|
||||
const serializer = createRpcSerializer({
|
||||
sendMessageBuffer: buffer => socket.send(buffer),
|
||||
sendMessageFinish: message => socket.send(JSON.stringify(message)),
|
||||
@@ -378,11 +381,13 @@ export class PluginHost {
|
||||
reject?.(e);
|
||||
}
|
||||
});
|
||||
rpcPeer.tags.acl = accessControls;
|
||||
serializer.setupRpcPeer(rpcPeer);
|
||||
|
||||
// wrap the host api with a connection specific api that can be torn down on disconnect
|
||||
const createMediaManager = await this.peer.getParam('createMediaManager');
|
||||
const api = new PluginAPIProxy(this.api, await createMediaManager());
|
||||
api.acl = accessControls;
|
||||
const kill = () => {
|
||||
serializer.onDisconnected();
|
||||
api.removeListeners();
|
||||
|
||||
@@ -95,6 +95,7 @@ export abstract class PluginHttp<T> {
|
||||
url: req.url,
|
||||
isPublicEndpoint,
|
||||
username: res.locals.username,
|
||||
aclId: res.locals.aclId,
|
||||
};
|
||||
|
||||
if (isEngineIOEndpoint && !isUpgrade && isPublicEndpoint) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Device, DeviceManager, DeviceManifest, DeviceState, EndpointManager, HttpRequest, Logger, MediaManager, ScryptedInterface, ScryptedInterfaceProperty, ScryptedMimeTypes, ScryptedNativeId, ScryptedStatic, SystemDeviceState, SystemManager } from '@scrypted/types';
|
||||
import { Device, DeviceManager, DeviceManifest, DeviceState, EndpointManager, Logger, MediaManager, ScryptedInterface, ScryptedInterfaceProperty, ScryptedMimeTypes, ScryptedNativeId, ScryptedStatic, SystemDeviceState, SystemManager } from '@scrypted/types';
|
||||
import { RpcPeer, RPCResultError } from '../rpc';
|
||||
import { AccessControls } from './acl';
|
||||
import { BufferSerializer } from './buffer-serializer';
|
||||
import { PluginAPI, PluginLogger, PluginRemote, PluginRemoteLoadZipOptions } from './plugin-api';
|
||||
import { createWebSocketClass, WebSocketConnectCallbacks, WebSocketConnection, WebSocketMethods, WebSocketSerializer } from './plugin-remote-websocket';
|
||||
@@ -121,10 +122,6 @@ class EndpointManagerImpl implements EndpointManager {
|
||||
return this.mediaManager.convertMediaObjectToUrl(mo, ScryptedMimeTypes.PushEndpoint);
|
||||
}
|
||||
|
||||
async deliverPush(id: string, request: HttpRequest) {
|
||||
return this.api.deliverPush(id, request);
|
||||
}
|
||||
|
||||
async getPath(nativeId?: string, options?: { public?: boolean; }): Promise<string> {
|
||||
return `/endpoint/${this.getEndpoint(nativeId)}/${options?.public ? 'public/' : ''}`
|
||||
}
|
||||
@@ -133,7 +130,7 @@ class EndpointManagerImpl implements EndpointManager {
|
||||
const protocol = options?.insecure ? 'http' : 'https';
|
||||
const port = await this.api.getComponent(options?.insecure ? 'SCRYPTED_INSECURE_PORT' : 'SCRYPTED_SECURE_PORT');
|
||||
const path = await this.getPath(nativeId, options);
|
||||
const url = `${protocol}://${await this.getUrlSafeIp()}:${port}${path}`;
|
||||
const url = `${protocol}://${await this.getUrlSafeIp()}:${port}${path}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -368,8 +365,48 @@ export async function setupPluginRemote(peer: RpcPeer, api: PluginAPI, pluginId:
|
||||
const getRemote = await peer.getParam('getRemote');
|
||||
const remote = await getRemote(api, pluginId) as PluginRemote;
|
||||
|
||||
await remote.setSystemState(getSystemState());
|
||||
const accessControls: AccessControls = peer.tags.acl;
|
||||
|
||||
const getAccessControlDeviceState = (id: string, state?: { [property: string]: SystemDeviceState } ) => {
|
||||
state = state || getSystemState()[id];
|
||||
if (accessControls && state) {
|
||||
state = Object.assign({}, state);
|
||||
for (const property of Object.keys(state)) {
|
||||
if (accessControls.shouldRejectProperty(id, property))
|
||||
delete state[property];
|
||||
}
|
||||
let interfaces: ScryptedInterface[] = state.interfaces?.value;
|
||||
if (interfaces) {
|
||||
interfaces = interfaces.filter(scryptedInterface => !accessControls.shouldRejectInterface(id, scryptedInterface));
|
||||
state.interfaces = {
|
||||
value: interfaces,
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
const getAccessControlSystemState = () => {
|
||||
let state = getSystemState();
|
||||
if (accessControls) {
|
||||
state = Object.assign({}, state);
|
||||
for (const id of Object.keys(state)) {
|
||||
if (accessControls.shouldRejectDevice(id)) {
|
||||
delete state[id];
|
||||
continue;
|
||||
}
|
||||
state[id] = getAccessControlDeviceState(id, state[id]);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
await remote.setSystemState(getAccessControlSystemState());
|
||||
api.listen((id, eventDetails, eventData) => {
|
||||
if (accessControls?.shouldRejectEvent(eventDetails.property === ScryptedInterfaceProperty.id ? eventData : id, eventDetails))
|
||||
return;
|
||||
|
||||
// ScryptedDevice events will be handled specially and repropagated by the remote.
|
||||
if (eventDetails.eventInterface === ScryptedInterface.ScryptedDevice) {
|
||||
if (eventDetails.property === ScryptedInterfaceProperty.id) {
|
||||
@@ -378,7 +415,7 @@ export async function setupPluginRemote(peer: RpcPeer, api: PluginAPI, pluginId:
|
||||
}
|
||||
else {
|
||||
// a change on anything else is a descriptor update
|
||||
remote.updateDeviceState(id, getSystemState()[id]);
|
||||
remote.updateDeviceState(id, getAccessControlDeviceState(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -430,7 +467,7 @@ export function attachPluginRemote(peer: RpcPeer, options?: PluginRemoteAttachOp
|
||||
|
||||
peer.params.getRemote = async (api: PluginAPI, pluginId: string) => {
|
||||
websocketSerializer.WebSocket = createWebSocketClass((connection, callbacks) => {
|
||||
const {url} = connection;
|
||||
const { url } = connection;
|
||||
if (url.startsWith('io://') || url.startsWith('ws://')) {
|
||||
const id = url.substring('xx://'.length);
|
||||
|
||||
|
||||
@@ -229,11 +229,12 @@ export class RpcPeer {
|
||||
transportSafeArgumentTypes = RpcPeer.getDefaultTransportSafeArgumentTypes();
|
||||
killed: Promise<void>;
|
||||
killedDeferred: Deferred;
|
||||
tags: any = {};
|
||||
|
||||
static readonly finalizerIdSymbol = Symbol('rpcFinalizerId');
|
||||
static remotesCollected = 0;
|
||||
static remotesCreated = 0;
|
||||
|
||||
static activeRpcPeer: RpcPeer;
|
||||
|
||||
static isRpcProxy(value: any) {
|
||||
return !!value?.[RpcPeer.PROPERTY_PROXY_ID];
|
||||
@@ -507,7 +508,17 @@ export class RpcPeer {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
async handleMessage(message: RpcMessage, deserializationContext?: any) {
|
||||
handleMessage(message: RpcMessage, deserializationContext?: any) {
|
||||
try {
|
||||
RpcPeer.activeRpcPeer = this;
|
||||
this.handleMessageInternal(message, deserializationContext);
|
||||
}
|
||||
finally {
|
||||
RpcPeer.activeRpcPeer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessageInternal(message: RpcMessage, deserializationContext?: any) {
|
||||
if (Object.isFrozen(this.pendingResults))
|
||||
return;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Device, DeviceInformation, EngineIOHandler, HttpRequest, HttpRequestHandler, OauthClient, PushHandler, ScryptedDevice, ScryptedInterface, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types';
|
||||
import { Device, DeviceInformation, DeviceProvider, EngineIOHandler, HttpRequest, HttpRequestHandler, OauthClient, ScryptedDevice, ScryptedInterface, ScryptedInterfaceMethod, ScryptedInterfaceProperty, ScryptedNativeId, ScryptedUser as SU } from '@scrypted/types';
|
||||
import AdmZip from 'adm-zip';
|
||||
import axios from 'axios';
|
||||
import * as io from 'engine.io';
|
||||
@@ -14,13 +14,14 @@ import { PassThrough } from 'stream';
|
||||
import tar from 'tar';
|
||||
import { URL } from "url";
|
||||
import WebSocket, { Server as WebSocketServer } from "ws";
|
||||
import { Plugin, PluginDevice, ScryptedAlert } from './db-types';
|
||||
import { Plugin, PluginDevice, ScryptedAlert, ScryptedUser } from './db-types';
|
||||
import { createResponseInterface } from './http-interfaces';
|
||||
import { getDisplayName, getDisplayRoom, getDisplayType, getProvidedNameOrDefault, getProvidedRoomOrDefault, getProvidedTypeOrDefault } from './infer-defaults';
|
||||
import { IOServer } from './io';
|
||||
import { Level } from './level';
|
||||
import { LogEntry, Logger, makeAlertId } from './logger';
|
||||
import { hasMixinCycle } from './mixin/mixin-cycle';
|
||||
import { AccessControls } from './plugin/acl';
|
||||
import { PluginDebug } from './plugin/plugin-debug';
|
||||
import { PluginDeviceProxyHandler } from './plugin/plugin-device';
|
||||
import { PluginHost } from './plugin/plugin-host';
|
||||
@@ -34,6 +35,7 @@ import { CORSControl, CORSServer } from './services/cors';
|
||||
import { Info } from './services/info';
|
||||
import { PluginComponent } from './services/plugin';
|
||||
import { ServiceControl } from './services/service-control';
|
||||
import { UsersService } from './services/users';
|
||||
import { getState, ScryptedStateManager, setState } from './state';
|
||||
|
||||
interface DeviceProxyPair {
|
||||
@@ -76,6 +78,7 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
alerts = new Alerts(this);
|
||||
corsControl = new CORSControl(this);
|
||||
addressSettings = new AddressSettings(this);
|
||||
usersService = new UsersService(this);
|
||||
|
||||
constructor(datastore: Level, insecure: http.Server, secure: https.Server, app: express.Application) {
|
||||
super(app);
|
||||
@@ -91,6 +94,11 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
});
|
||||
|
||||
app.all('/engine.io/shell', (req, res) => {
|
||||
if (res.locals.aclId) {
|
||||
res.writeHead(401);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
this.shellHandler(req, res);
|
||||
});
|
||||
|
||||
@@ -251,21 +259,6 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
};
|
||||
}
|
||||
|
||||
async deliverPush(endpoint: string, request: HttpRequest) {
|
||||
const { pluginHost, pluginDevice } = await this.getPluginForEndpoint(endpoint);
|
||||
if (!pluginDevice) {
|
||||
console.error('plugin device missing for', endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginDevice?.state.interfaces.value.includes(ScryptedInterface.PushHandler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.getDevice<PushHandler>(pluginDevice._id);
|
||||
return handler.onPush(request);
|
||||
}
|
||||
|
||||
async shellHandler(req: Request, res: Response) {
|
||||
const isUpgrade = isConnectionUpgrade(req.headers);
|
||||
|
||||
@@ -377,6 +370,8 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
return this.corsControl;
|
||||
case 'addresses':
|
||||
return this.addressSettings;
|
||||
case "users":
|
||||
return this.usersService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,9 +387,31 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
handleEngineIOEndpoint(req: Request, res: ServerResponse, endpointRequest: HttpRequest, pluginData: HttpPluginData) {
|
||||
async handleEngineIOEndpoint(req: Request, res: ServerResponse & { locals: any }, endpointRequest: HttpRequest, pluginData: HttpPluginData) {
|
||||
const { pluginHost, pluginDevice } = pluginData;
|
||||
|
||||
const { username } = res.locals;
|
||||
let accessControls: AccessControls;
|
||||
if (username) {
|
||||
const user = await this.datastore.tryGet(ScryptedUser, username);
|
||||
if (user.aclId) {
|
||||
const accessControl = this.getDevice<SU>(user.aclId);
|
||||
try {
|
||||
const acls = await accessControl.getScryptedUserAccessControl();
|
||||
if (acls) {
|
||||
accessControls = new AccessControls(acls);
|
||||
if (accessControls.shouldRejectMethod(pluginDevice._id, ScryptedInterfaceMethod.onConnection))
|
||||
accessControls.deny();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
res.writeHead(401);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pluginHost || !pluginDevice) {
|
||||
console.error('plugin does not exist or is still starting up.');
|
||||
res.writeHead(500);
|
||||
@@ -405,6 +422,7 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
(req as any).scrypted = {
|
||||
endpointRequest,
|
||||
pluginDevice,
|
||||
accessControls,
|
||||
};
|
||||
if ((req as any).upgradeHead)
|
||||
pluginHost.io.handleUpgrade(req, res.socket, (req as any).upgradeHead)
|
||||
@@ -635,7 +653,7 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
this.plugins[pluginId] = pluginHost;
|
||||
|
||||
for (const pluginDevice of pluginDevices) {
|
||||
this.getDevice(pluginDevice._id)?.probe().catch(() => {});
|
||||
this.getDevice(pluginDevice._id)?.probe().catch(() => { });
|
||||
}
|
||||
|
||||
return pluginHost;
|
||||
@@ -691,6 +709,7 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
continue;
|
||||
await this.removeDevice(provided);
|
||||
}
|
||||
const providerId = device.state?.providerId?.value;
|
||||
device.state = undefined;
|
||||
|
||||
this.invalidatePluginDevice(device._id);
|
||||
@@ -713,6 +732,8 @@ export class ScryptedRuntime extends PluginHttp<HttpPluginData> {
|
||||
// notify the plugin that a device was removed.
|
||||
const plugin = this.plugins[device.pluginId];
|
||||
await plugin.remote.setNativeId(device.nativeId, undefined, undefined);
|
||||
const provider = this.getDevice<DeviceProvider>(providerId);
|
||||
await provider?.releaseDevice(device._id, device.nativeId);
|
||||
}
|
||||
catch (e) {
|
||||
// may throw if the plugin is killed, etc.
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PluginError } from './plugin/plugin-error';
|
||||
import { getScryptedVolume } from './plugin/plugin-volume';
|
||||
import { ONE_DAY_MILLISECONDS, UserToken } from './usertoken';
|
||||
import os from 'os';
|
||||
import { setScryptedUserPassword } from './services/users';
|
||||
|
||||
if (!semver.gte(process.version, '16.0.0')) {
|
||||
throw new Error('"node" version out of date. Please update node to v16 or higher.')
|
||||
@@ -123,7 +124,8 @@ async function start() {
|
||||
realm: 'Scrypted',
|
||||
}, async (username, password, callback) => {
|
||||
const user = await db.tryGet(ScryptedUser, username);
|
||||
if (!user) {
|
||||
// disallow basic auth for non-admin as it can deploy plugins, etc.
|
||||
if (!user || user.aclId) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +190,7 @@ async function start() {
|
||||
|
||||
const userToken = getSignedLoginUserToken(req);
|
||||
if (userToken) {
|
||||
const { username } = userToken;
|
||||
const { username, aclId } = userToken;
|
||||
|
||||
// this database lookup on every web request is not necessary, the cookie
|
||||
// itself is the auth, and is signed. furthermore, this is currently
|
||||
@@ -202,6 +204,7 @@ async function start() {
|
||||
// }
|
||||
|
||||
res.locals.username = username;
|
||||
res.locals.aclId = aclId;
|
||||
}
|
||||
else if (req.headers.authorization?.startsWith('Bearer ')) {
|
||||
const [checkHash, ...tokenParts] = req.headers.authorization.substring('Bearer '.length).split('#');
|
||||
@@ -216,6 +219,7 @@ async function start() {
|
||||
const userToken = validateToken(tokenPart);
|
||||
if (userToken)
|
||||
res.locals.username = userToken.username;
|
||||
res.locals.aclId = userToken.aclId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +244,7 @@ async function start() {
|
||||
|
||||
// verify all plugin related requests have some sort of auth
|
||||
app.all('/web/component/*', (req, res, next) => {
|
||||
if (!res.locals.username) {
|
||||
if (!res.locals.username || res.locals.aclId) {
|
||||
res.status(401);
|
||||
res.send('Not Authorized');
|
||||
return;
|
||||
@@ -466,7 +470,7 @@ async function start() {
|
||||
return;
|
||||
}
|
||||
|
||||
const userToken = new UserToken(username, timestamp, maxAge);
|
||||
const userToken = new UserToken(username, user.aclId, timestamp, maxAge);
|
||||
const login_user_token = userToken.toString();
|
||||
res.cookie(getLoginUserToken(req.secure), login_user_token, {
|
||||
maxAge,
|
||||
@@ -476,9 +480,7 @@ async function start() {
|
||||
});
|
||||
|
||||
if (change_password) {
|
||||
user.salt = crypto.randomBytes(64).toString('base64');
|
||||
user.passwordHash = crypto.createHash('sha256').update(user.salt + change_password).digest().toString('hex');
|
||||
user.passwordDate = timestamp;
|
||||
setScryptedUserPassword(user, change_password, timestamp);
|
||||
await db.upsert(user);
|
||||
}
|
||||
|
||||
@@ -502,14 +504,12 @@ async function start() {
|
||||
|
||||
const user = new ScryptedUser();
|
||||
user._id = username;
|
||||
user.salt = crypto.randomBytes(64).toString('base64');
|
||||
user.passwordHash = crypto.createHash('sha256').update(user.salt + password).digest().toString('hex');
|
||||
user.passwordDate = timestamp;
|
||||
setScryptedUserPassword(user, password, timestamp);
|
||||
user.token = crypto.randomBytes(16).toString('hex');
|
||||
await db.upsert(user);
|
||||
hasLogin = true;
|
||||
|
||||
const userToken = new UserToken(username, timestamp);
|
||||
const userToken = new UserToken(username, user.aclId, timestamp);
|
||||
const login_user_token = userToken.toString();
|
||||
res.cookie(getLoginUserToken(req.secure), login_user_token, {
|
||||
maxAge,
|
||||
|
||||
@@ -29,12 +29,10 @@ export class PluginComponent {
|
||||
await this.reload(pluginDevice.pluginId);
|
||||
}
|
||||
|
||||
getNativeId(id: string) {
|
||||
return this.scrypted.findPluginDeviceById(id)?.nativeId;
|
||||
}
|
||||
getStorage(id: string) {
|
||||
return this.scrypted.findPluginDeviceById(id)?.storage || {};
|
||||
}
|
||||
|
||||
async setStorage(id: string, storage: { [key: string]: string }) {
|
||||
const pluginDevice = this.scrypted.findPluginDeviceById(id);
|
||||
pluginDevice.storage = storage;
|
||||
@@ -65,17 +63,9 @@ export class PluginComponent {
|
||||
async getIdForNativeId(pluginId: string, nativeId: ScryptedNativeId) {
|
||||
return this.scrypted.findPluginDevice(pluginId, nativeId)?._id;
|
||||
}
|
||||
/**
|
||||
* @deprecated available as device.pluginId now.
|
||||
* Remove at some point after core/ui rolls out 6/20/2022.
|
||||
*/
|
||||
async getPluginId(id: string) {
|
||||
const pluginDevice = this.scrypted.findPluginDeviceById(id);
|
||||
return pluginDevice.pluginId;
|
||||
}
|
||||
async reload(pluginId: string) {
|
||||
const plugin = await this.scrypted.datastore.tryGet(Plugin, pluginId);
|
||||
await this.scrypted.runPlugin(plugin);
|
||||
this.scrypted.runPlugin(plugin);
|
||||
}
|
||||
async kill(pluginId: string) {
|
||||
return this.scrypted.plugins[pluginId]?.kill();
|
||||
|
||||
43
server/src/services/users.ts
Normal file
43
server/src/services/users.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ScryptedUser } from "../db-types";
|
||||
import { ScryptedRuntime } from "../runtime";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class UsersService {
|
||||
constructor(public scrypted: ScryptedRuntime) {
|
||||
}
|
||||
|
||||
async getAllUsers() {
|
||||
const users: ScryptedUser[] = [];
|
||||
for await (const user of this.scrypted.datastore.getAll(ScryptedUser)) {
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
return users.map(user => ({
|
||||
username: user._id,
|
||||
admin: !user.aclId,
|
||||
}));
|
||||
}
|
||||
|
||||
async removeUser(username: string) {
|
||||
await this.scrypted.datastore.removeId(ScryptedUser, username);
|
||||
}
|
||||
|
||||
async removeAllUsers() {
|
||||
await this.scrypted.datastore.removeAll(ScryptedUser);
|
||||
}
|
||||
|
||||
async addUser(username: string, password: string, aclId: string) {
|
||||
const user = new ScryptedUser();
|
||||
user._id = username;
|
||||
user.aclId = aclId;
|
||||
setScryptedUserPassword(user, password, Date.now());
|
||||
await this.scrypted.datastore.upsert(user);
|
||||
}
|
||||
}
|
||||
|
||||
export function setScryptedUserPassword(user: ScryptedUser, password: string, timestamp: number) {
|
||||
user.salt = crypto.randomBytes(64).toString('base64');
|
||||
user.passwordHash = crypto.createHash('sha256').update(user.salt + password).digest().toString('hex');
|
||||
user.passwordDate = timestamp;
|
||||
user.token = crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
@@ -2,21 +2,27 @@ export const ONE_DAY_MILLISECONDS = 86400000;
|
||||
export const ONE_YEAR_MILLISECONDS = ONE_DAY_MILLISECONDS * 365;
|
||||
|
||||
export class UserToken {
|
||||
constructor(public username: string, public timestamp = Date.now(), public duration = ONE_DAY_MILLISECONDS) {
|
||||
constructor(public username: string, public aclId: string, public timestamp = Date.now(), public duration = ONE_DAY_MILLISECONDS) {
|
||||
}
|
||||
|
||||
static validateToken(token: string): UserToken {
|
||||
let json: any;
|
||||
let json: {
|
||||
u: string,
|
||||
a: string,
|
||||
t: number,
|
||||
d: number,
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(token);
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error('Token malformed, unparseable.');
|
||||
}
|
||||
let { u, t, d } = json;
|
||||
let { u, a, t, d } = json;
|
||||
u = u?.toString();
|
||||
t = parseInt(t);
|
||||
d = parseInt(d);
|
||||
t = parseInt(t?.toString());
|
||||
d = parseInt(d?.toString());
|
||||
a = a?.toString();
|
||||
if (!u || !t || !d)
|
||||
throw new Error('Token malformed, missing properties.');
|
||||
if (d > ONE_YEAR_MILLISECONDS)
|
||||
@@ -25,12 +31,13 @@ export class UserToken {
|
||||
throw new Error('Token from the future.');
|
||||
if (t + d < Date.now())
|
||||
throw new Error('Token expired.');
|
||||
return new UserToken(u, t, d);
|
||||
return new UserToken(u, a, t, d);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify({
|
||||
u: this.username,
|
||||
a: this.aclId,
|
||||
t: this.timestamp,
|
||||
d: this.duration,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user