Skip to content
This repository has been archived by the owner on Apr 2, 2024. It is now read-only.

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexGodbehere committed Mar 30, 2023
1 parent 8bc9c1d commit d5d1b90
Show file tree
Hide file tree
Showing 8 changed files with 3,280 additions and 3 deletions.
22 changes: 22 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
ARG utility_prefix=ghcr.io/amrc-factoryplus/utilities
ARG utility_ver=v1.0.6

FROM ${utility_prefix}-build:${utility_ver} AS build

# Install the node application on the build container where we can
# compile the native modules.
RUN install -d -o node -g node /home/node/app
WORKDIR /home/node/app
USER node
COPY package*.json ./
RUN npm install --save=false
COPY . .

FROM ${utility_prefix}-run:${utility_ver}

# Copy across from the build container.
WORKDIR /home/node/app
COPY --from=build --chown=root:root /home/node/app ./

USER node
CMD npm start
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
> **Note**
> The AMRC Connectivity Stack is an open-source implementation of the AMRC's [Factory+ Framework](https://factoryplus.app.amrc.co.uk/).
# ACS Command Escalation Service

The [COMPONENT] component of the AMRC Connectivity Stack.
> The [AMRC Connectivity Stack (ACS)](https://github.com/AMRC-FactoryPlus/amrc-connectivity-stack) is an open-source implementation of the AMRC's [Factory+ Framework](https://factoryplus.app.amrc.co.uk).
This `acs-cmdesc` service satisfies the **Command Escalation** component of the Factory+ framework and provides a service that handles Command Escalation requests on behalf of clients. As per Factory+, this service manages escalation requests, authenticating the client, verifying the request is authorised, and actually transmitting the CMD to the device.

For more information about the Command Escalation component of Factory+ see the [specification](https://factoryplus.app.amrc.co.uk) or for an example of how to deploy this service see the [AMRC Connectivity Stack repository](https://github.com/AMRC-FactoryPlus/amrc-connectivity-stack).
59 changes: 59 additions & 0 deletions bin/cmdescd.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Factory+ / AMRC Connectivity Stack (ACS) Command Escalation component
* Main entrypoint
* Copyright 2022 AMRC
*/

import { ServiceClient, WebAPI, pkgVersion } from "@amrc-factoryplus/utilities";

import ApiV1 from "../lib/api_v1.js";
import CmdEscD from "../lib/cmdescd.js";
import MqttCli from "../lib/mqttcli.js";

const Service_Cmdesc = "78ea7071-24ac-4916-8351-aa3e549d8ccd";

const Version = pkgVersion(import.meta);
const Device_UUID = process.env.DEVICE_UUID;

const fplus = await new ServiceClient({
root_principal: process.env.ROOT_PRINCIPAL,
directory_url: process.env.DIRECTORY_URL,
}).init();

const cmdesc = await new CmdEscD({
fplus,
}).init();

const mqtt = await new MqttCli({
fplus,
sparkplug_address: process.env.SPARKPLUG_ADDRESS,
device_uuid: Device_UUID,
service: Service_Cmdesc,
http_url: process.env.HTTP_API_URL,
}).init();

const v1 = await new ApiV1({
cmdesc,
}).init();

const web = await new WebAPI({
ping: {
version: Version,
service: Service_Cmdesc,
device: Device_UUID,
},
realm: process.env.REALM,
hostname: process.env.HOSTNAME,
keytab: process.env.SERVER_KEYTAB,
http_port: process.env.PORT,
max_age: process.env.CACHE_MAX_AGE,

routes: app => {
app.use("/v1", v1.routes);
},
}).init();

mqtt.set_cmdesc(cmdesc);
cmdesc.set_mqtt(mqtt);
mqtt.run();
web.run();
42 changes: 42 additions & 0 deletions lib/api_v1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Factory+ / AMRC Connectivity Stack (ACS) Command Escalation component
* v1 HTTP API
* Copyright 2022 AMRC
*/

import express from "express";
import typeis from "type-is";

import {Address} from "@amrc-factoryplus/utilities";

export default class ApiV1 {
constructor(opts) {
this.cmdesc = opts.cmdesc;

this.routes = express.Router();
}

async init() {
this.setup_routes();
return this;
}

setup_routes() {
const api = this.routes;

api.post("/address/:group/:node", this.by_address.bind(this));
api.post("/address/:group/:node/:device", this.by_address.bind(this));
}

async by_address(req, res) {
if (!typeis(req, "application/json"))
return res.status(415).end();

const to = new Address(
req.params.group, req.params.node, req.params.device);

const st = await this.cmdesc.execute_command(
req.auth, to, req.body);
res.status(st).end();
}
}
166 changes: 166 additions & 0 deletions lib/cmdescd.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Factory+ / AMRC Connectivity Stack (ACS) Command Escalation component
* MQTT client
* Copyright 2022 AMRC
*/

import {Address, Debug, UUIDs,} from "@amrc-factoryplus/utilities";

const CCL_Perms = "9584ee09-a35a-4278-bc13-21a8be1f007c";
const CCL_Template = "60e99f28-67fe-4344-a6ab-b1edb8b8e810";

const debug = new Debug();

export default class CmdEscD {
constructor(opts) {
this.fplus = opts.fplus;
}

async init() {
return this;
}

set_mqtt(m) {
this.mqtt = m;
}

async find_principal_for_address(from) {
if (from.isDevice()) {
debug.log("cmdesc", "Cannot authorise request from a Device");
return;
}

const res = await this.fplus.fetch({
service: UUIDs.Service.Registry,
url: `/v1/app/${UUIDs.App.SparkplugAddress}/search`,
query: {
group_id: JSON.stringify(from.group),
node_id: JSON.stringify(from.node),
},
});
if (!res.ok) {
debug.log("cmdesc", `Failed to look up address ${from}: ${res.status}`);
return;
}
const json = await res.json();

switch (json.length) {
case 0:
debug.log("cmdesc", `No UUID found for address ${from}`);
return;
case 1:
break;
default:
debug.log("cmdesc", `More than one UUID found for address ${from}`);
return;
}

debug.log("cmdesc", `Request from ${from} = ${json[0]}`);
return json[0];
}

async fetch_acl(princ, by_uuid) {
const res = await this.fplus.fetch({
service: UUIDs.Service.Authentication,
url: "/authz/acl",
query: {
principal: princ,
permission: CCL_Perms,
"by-uuid": !!by_uuid,
},
});
if (!res.ok) {
debug.log("acl", `Can't get ACL for ${princ}: ${res.status}`);
return [];
}

return await res.json();
}

async expand_acl(princ) {
let acl;
if (princ instanceof Address) {
const uuid = await this.find_principal_for_address(princ);
if (!uuid) return [];
acl = await this.fetch_acl(uuid, true);
} else {
acl = await this.fetch_acl(princ, false);
}

/* Fetch the CDB entries we need. Don't fetch any entry more
* than once, the HTTP caching logic can't return a cached
* result for a request still in flight. */
const perms = new Map(acl.map(a => [a.permission, null]));
const targs = new Map(
/* We don't need to look up the wildcard address. */
acl.filter(a => a.target != UUIDs.Null)
.map(a => [a.target, null]));

await Promise.all([
...[...perms.keys()].map(perm =>
this.fplus.fetch_configdb(CCL_Template, perm)
.then(tmpl => perms.set(perm, tmpl))),

...[...targs.keys()].map(targ =>
this.fplus.fetch_configdb(UUIDs.App.SparkplugAddress, targ)
.then(a => a
? new Address(a.group_id, a.node_id, a.device_id)
: null)
.then(addr => targs.set(targ, addr))),
]);

const res_targ = t => t == UUIDs.Null ? true : targs.get(t);

return acl.flatMap(ace => {
const tags = perms.get(ace.permission);
const address = res_targ(ace.target);
return tags && address ? [{tags, address}] : [];
});
}

/* Potential return values:
* 200: OK
* 403: Forbidden
* 404: Metric does not exist
* 409: Wrong type / metric otherwise can't be set
* 503: Device offline / not responding
*
* from can be an Address or a Kerberos principal string.
* to must be an Address.
* cmd is { name, type?, value }
*/
async execute_command(from, to, cmd) {
const log = stat => {
debug.log("cmdesc", `${stat}: ${from} -> ${to}[${cmd.name} = ${cmd.value}]`);
return stat;
};

/* We can only give root rights if a type is explicitly
* supplied. Otherwise we don't know what type to send. */
const is_root =
typeof (from) == "string" &&
"type" in cmd &&
from == this.fplus.root_principal;

if (!is_root) {
const acl = await this.expand_acl(from);
debug.log("acl", "ACL for %s: %o", from, acl);

const tag = acl
.find(ace => ace.address === true ||
ace.address.matches(to))
?.tags
?.find(t => t.name == cmd.name);
if (!tag) return log(403);

if (cmd.type == undefined) {
cmd.type = tag.type ?? "Boolean";
} else if (cmd.type != tag.type) {
return log(409);
}
}

this.mqtt.publish(to, "CMD", [cmd]);
return log(200);
}
}
Loading

0 comments on commit d5d1b90

Please sign in to comment.