server: user/acl work roughed in

This commit is contained in:
Koushik Dutta
2022-12-19 10:02:29 -08:00
parent f3d7a0128a
commit 3a349205ed
16 changed files with 346 additions and 101 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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;
}
}

View File

@@ -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>;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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.

View File

@@ -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,

View File

@@ -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();

View 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');
}

View File

@@ -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,
})