diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 34054a6..0ce5984 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -45,6 +45,7 @@ class AdminController extends SiteController { ); router.use('/content-report',await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); + router.use('/core-node',await this.loadChild(path.join(__dirname, 'admin', 'core-node'))); router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host'))); router.use('/job-queue', await this.loadChild(path.join(__dirname, 'admin', 'job-queue'))); router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log'))); diff --git a/app/controllers/admin/core-node.js b/app/controllers/admin/core-node.js new file mode 100644 index 0000000..3941536 --- /dev/null +++ b/app/controllers/admin/core-node.js @@ -0,0 +1,60 @@ +// admin/content-report.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'admin:content-report'; + +const express = require('express'); +// const multer = require('multer'); + +const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib'); + +class CoreNodeController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); + + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'core-node'; + return next(); + }); + + router.post('/connect', this.postCoreNodeConnect.bind(this)); + router.get('/connect', this.getCoreNodeConnectForm.bind(this)); + + router.get('/', this.getIndex.bind(this)); + + return router; + } + + async postCoreNodeConnect (req, res, next) { + // const { coreNode: coreNodeService } = this.dtp.services; + try { + + } catch (error) { + this.log.error('failed to create Core Node connection request', { error }); + return next(error); + } + } + + async getCoreNodeConnectForm (req, res) { + res.render('admin/core-node/connect'); + } + + async getIndex (req, res) { + res.render('admin/core-node/index'); + } +} + +module.exports = async (dtp) => { + let controller = new CoreNodeController(dtp); + return controller; +}; \ No newline at end of file diff --git a/app/controllers/hive.js b/app/controllers/hive.js new file mode 100644 index 0000000..f0199b2 --- /dev/null +++ b/app/controllers/hive.js @@ -0,0 +1,64 @@ +// hive.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'hive'; + +const path = require('path'); +const express = require('express'); + +const { SiteController } = require('../../lib/site-lib'); + +class HiveController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + this.services = [ ]; + } + + async start ( ) { + const router = express.Router(); + this.dtp.app.use('/hive', router); + + router.use( + async (req, res, next) => { + res.locals.currentView = 'hive'; + res.locals.hiveView = 'home'; + + /* + * TODO: H1V3 authentication before processing request (HTTP Bearer token) + */ + + return next(); + }, + ); + + router.use('/kaleidoscope',await this.loadChild(path.join(__dirname, 'hive', 'kaleidoscope'))); + this.services.push({ name: 'kaleidoscope', url: '/hive/kaleidoscope' }); + + router.get('/', this.getHiveRoot.bind(this)); + + return router; + } + + async getHiveRoot (req, res) { + res.status(200).json({ + component: DTP_COMPONENT_NAME, + host: this.dtp.pkg.name, + description: this.dtp.pkg.description, + version: this.dtp.pkg.version, + services: this.services, + }); + } +} + +module.exports = { + slug: 'hive', + name: 'hive', + create: async (dtp) => { + let controller = new HiveController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/controllers/hive/kaleidoscope.js b/app/controllers/hive/kaleidoscope.js new file mode 100644 index 0000000..cbfe9f2 --- /dev/null +++ b/app/controllers/hive/kaleidoscope.js @@ -0,0 +1,72 @@ +// hive/kaleidoscope.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'hive:kaleidoscope'; + +const express = require('express'); + +const { SiteController } = require('../../../lib/site-lib'); + +class HostController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + + this.methods = [ + { + name: 'postEvent', + url: '/kaleidoscope/event', + method: 'POST', + }, + ]; + } + + async start ( ) { + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'hive'; + res.locals.hiveView = 'kaleidoscope'; + return next(); + }); + + router.post('/core-node/connect', this.postCoreNodeConnect.bind(this)); + router.post('/event', this.postEvent.bind(this)); + + router.get('/', this.getKaleidoscopeRoot.bind(this)); + + return router; + } + + async postCoreNodeConnect (req, res, next) { + const { coreNode: coreNodeService } = this.dtp.services; + try { + await coreNodeService.connect(req.body); + } catch (error) { + this.log.error('failed to create Core Node connection', { error }); + return next(error); + } + } + + async postEvent (req, res) { + this.log.debug('kaleidoscope event received', { event: req.body.event }); + this.emit('kaleidoscope:event', req, res); + res.status(200).json({ success: true }); + } + + async getKaleidoscopeRoot (req, res) { + res.status(200).json({ + component: DTP_COMPONENT_NAME, + version: this.dtp.pkg.version, + services: this.services, + methods: this.methods, + }); + } +} + +module.exports = async (dtp) => { + let controller = new HostController(dtp); + return controller; +}; diff --git a/app/models/core-node-request.js b/app/models/core-node-request.js new file mode 100644 index 0000000..dde2409 --- /dev/null +++ b/app/models/core-node-request.js @@ -0,0 +1,37 @@ +// core-node-request.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +/* + * Used for authenticating responses received and gathering performance and use + * metrics for communications with Cores. + * + * When a request is created, an authentication token is generated and + * information about the request is stored. This also provides the request ID. + * + * When a resonse is received for a request, this record is fetched. The token + * claimed status and value are checked. Information about the response is + * recorded, and request execution time information is recorded. + */ + +const CoreNodeRequestSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1 }, + core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' }, + token: { + value: { type: String, required: true }, + claimed: { type: Boolean, default: false, required: true }, + }, + url: { type: String }, + response: { + received: { type: Date }, + elapsed: { type: Number }, + isError: { type: Boolean, default: false }, + }, +}); + +module.exports = mongoose.model('CoreNodeRequest', CoreNodeRequestSchema); \ No newline at end of file diff --git a/app/models/core-node.js b/app/models/core-node.js new file mode 100644 index 0000000..f837683 --- /dev/null +++ b/app/models/core-node.js @@ -0,0 +1,18 @@ +// core-node.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const CoreNodeSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1 }, + address: { + host: { type: String, required: true }, + port: { type: Number, min: 1, max: 65535, required: true }, + }, +}); + +module.exports = mongoose.model('CoreNode', CoreNodeSchema); \ No newline at end of file diff --git a/app/services/core-node.js b/app/services/core-node.js new file mode 100644 index 0000000..6ab593e --- /dev/null +++ b/app/services/core-node.js @@ -0,0 +1,97 @@ +// core-node.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const uuidv4 = require('uuid').v4; + +const mongoose = require('mongoose'); +const fetch = require('node-fetch'); // jshint ignore:line + +const CoreNode = mongoose.model('CoreNode'); +const CoreNodeRequest = mongoose.model('CoreNodeRequest'); + +const { SiteService, SiteError } = require('../../lib/site-lib'); + +class CoreNodeService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async create (coreDefinition) { + const core = new CoreNode(); + core.created = new Date(); + + core.address = { }; + + if (!coreDefinition.host) { + throw new SiteError(406, 'Must provide Core Node host address'); + } + core.address.host = coreDefinition.host.trim(); + + if (!coreDefinition.port) { + throw new SiteError(406, 'Must provide Core Node TCP port number'); + } + + coreDefinition.port = parseInt(coreDefinition.port, 10); + if (coreDefinition.port < 1 || coreDefinition.port > 65535) { + throw new SiteError(406, 'Core Node port number out of range'); + } + + await core.save(); + + return core.toObject(); + } + + async broadcast (request) { + const results = [ ]; + await CoreNode + .find() + .cursor() + .eachAsync(async (core) => { + try { + const response = await this.sendRequest(core, request); + results.push({ coreId: core._id, request, response }); + } catch (error) { + this.log.error('failed to send Core Node request', { core: core._id, request: request.url, error }); + } + }); + return results; + } + + async sendRequest (core, request) { + const requestUrl = `https://${core.address.host}:${core.address.port}${request.url}`; + + const req = new CoreNodeRequest(); + req.created = new Date(); + req.core = core._id; + req.token = { + value: uuidv4(), + claimed: false, + }; + req.url = request.url; + await req.save(); + + try { + const response = await fetch(requestUrl, { + method: request.method, + body: request.body, + }); + const json = await response.json(); + return { request: req.toObject(), response: json }; + } catch (error) { + this.log.error('failed to send Core Node request', { core: core._id, request: request.url, error }); + throw error; + } + + return req.toObject(); + } +} + +module.exports = { + slug: 'core-node', + name: 'coreNode', + create: (dtp) => { return new CoreNodeService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/oauth2.js b/app/services/oauth2.js new file mode 100644 index 0000000..2f56fc4 --- /dev/null +++ b/app/services/oauth2.js @@ -0,0 +1,191 @@ +// oauth2.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const passport = require('passport'); + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const uuidv4 = require('uuid').v4; +const oauth2orize = require('oauth2orize'); + +const { SiteService/*, SiteError*/ } = require('../../lib/site-lib'); + +class OAuth2Service extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + this.models = { }; + + /* + * OAuth2Client Model + */ + const ClientSchema = new Schema({ + created: { type: Date, default: Date.now, required: true }, + updated: { type: Date, default: Date.now, required: true }, + secret: { type: String, required: true }, + redirectURI: { type: String, required: true }, + }); + this.log.info('registering OAuth2Client model'); + this.models.Client = mongoose.model('OAuth2Client', ClientSchema); + + /* + * OAuth2AuthorizationCode model + */ + const AuthorizationCodeSchema = new Schema({ + code: { type: String, required: true, index: 1 }, + clientId: { type: Schema.ObjectId, required: true, index: 1 }, + redirectURI: { type: String, required: true }, + user: { type: Schema.ObjectId, required: true, index: 1 }, + scope: { type: [String], required: true }, + }); + this.log.info('registering OAuth2AuthorizationCode model'); + this.models.AuthorizationCode = mongoose.model('OAuth2AuthorizationCode', AuthorizationCodeSchema); + + /* + * OAuth2AccessToken model + */ + const AccessTokenSchema = new Schema({ + token: { type: String, required: true, unique: true, index: 1 }, + user: { type: Schema.ObjectId, required: true, index: 1 }, + clientId: { type: Schema.ObjectId, required: true, index: 1 }, + scope: { type: [String], required: true }, + }); + this.log.info('registering OAuth2AccessToken model'); + this.models.AccessToken = mongoose.model('OAuth2AccessToken', AccessTokenSchema); + + /* + * Create OAuth2 server instance + */ + const options = { }; + this.log.info('creating OAuth2 server instance', { options }); + this.server = oauth2orize.createServer(options); + this.server.grant(oauth2orize.grant.code(this.processGrant.bind(this))); + this.server.exchange(oauth2orize.exchange.code(this.processExchange.bind(this))); + + /* + * Register client serialization callbacks + */ + this.log.info('registering OAuth2 client serialization routines'); + this.server.serializeClient(this.serializeClient.bind(this)); + this.server.deserializeClient(this.deserializeClient.bind(this)); + } + + async serializeClient (client, done) { + return done(null, client.id); + } + + async deserializeClient (clientId, done) { + try { + const client = await this.models.Client.findOne({ _id: clientId }).lean(); + return done(null, client); + } catch (error) { + this.log.error('failed to deserialize OAuth2 client', { clientId, error }); + return done(error); + } + } + + attachRoutes (app) { + const { session: sessionService } = this.dtp.services; + + const requireLogin = sessionService.authCheckMiddleware({ requireLogin: true }); + + app.get( + '/dialog/authorize', + requireLogin, + this.server.authorize(this.processAuthorize.bind(this)), + this.renderAuthorizeDialog.bind(this), + ); + + app.post( + '/dialog/authorize/decision', + requireLogin, + this.server.decision(), + ); + + app.post( + '/token', + passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), + this.server.token(), + this.server.errorHandler(), + ); + } + + async renderAuthorizeDialog (req, res) { + res.locals.transactionID = req.oauth2.transactionID; + res.locals.client = req.oauth2.client; + res.render('oauth2/authorize-dialog'); + } + + async processAuthorize (clientID, redirectURI, done) { + try { + const client = await this.models.Clients.findOne({ clientID }); + if (!client) { + return done(null, false); + } + if (client.redirectUri !== redirectURI) { + return done(null, false); + } + return done(null, client, client.redirectURI); + } catch (error) { + this.log.error('failed to process OAuth2 authorize', { error }); + return done(error); + } + } + + async processGrant (client, redirectURI, user, ares, done) { + try { + var code = uuidv4(); + var ac = new this.models.AuthorizationCode({ + code, + clientId: client.id, + redirectURI, + user: user.id, + scope: ares.scope, + }); + await ac.save(); + return done(null, code); + } catch (error) { + this.log.error('failed to process OAuth2 grant', { error }); + return done(error); + } + } + + async processExchange (client, code, redirectURI, done) { + try { + const ac = await this.models.AuthorizationCode.findOne({ code }); + if (client.id !== ac.clientId) { + return done(null, false); + } + if (redirectURI !== ac.redirectUri) { + return done(null, false); + } + + var token = uuidv4(); + var at = new this.models.AccessToken({ + token, + user: ac.userId, + clientId: ac.clientId, + scope: ac.scope, + }); + await at.save(); + + return done(null, token); + } catch (error) { + this.log.error('failed to process OAuth2 exchange', { error }); + return done(error); + } + } +} + +module.exports = { + slug: 'oauth2', + name: 'oauth2', + create: (dtp) => { return new OAuth2Service(dtp); }, +}; \ No newline at end of file diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index a8654cc..ad77d69 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -27,6 +27,11 @@ ul.uk-nav.uk-nav-default li.uk-nav-divider + li(class={ 'uk-active': (adminView === 'core-node') }) + a(href="/admin/core-node") + span.nav-item-icon + i.fas.fa-project-diagram + span.uk-margin-small-left Core Nodes li(class={ 'uk-active': (adminView === 'host') }) a(href="/admin/host") span.nav-item-icon diff --git a/app/views/admin/core-node/connect.pug b/app/views/admin/core-node/connect.pug new file mode 100644 index 0000000..3dc1da5 --- /dev/null +++ b/app/views/admin/core-node/connect.pug @@ -0,0 +1,18 @@ +extends ../layouts/main +block content + + form(method="POST", action="/admin/core-node/connect").uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Connect to New Core + + .uk-card-body + .uk-margin + label(for="host").uk-form-label Address + input(id="host", name="host", placeholder="Enter host name or address", required).uk-input + .uk-margin + label(for="port").uk-form-label Port Number + input(id="port", name="port", min="1", max="65535", step="1", value="4200", required).uk-input + + .uk-card-footer + button(type="submit").uk-button.uk-button-primary Send Request \ No newline at end of file diff --git a/app/views/admin/core-node/index.pug b/app/views/admin/core-node/index.pug new file mode 100644 index 0000000..d079705 --- /dev/null +++ b/app/views/admin/core-node/index.pug @@ -0,0 +1,14 @@ +extends ../layouts/main +block content + + h1 Core Nodes + a(href="/admin/core-node/connect").uk-button.uk-button-primary Connect Core + + p You can register with one or more Core nodes to exchange information with those nodes. + + if Array.isArray(coreNodes) && (coreNodes.length > 0) + ul.uk-list + each node in coreNodes + pre= JSON.stringify(node, null, 2) + else + p There are no registered core nodes. \ No newline at end of file diff --git a/package.json b/package.json index 7829c7d..aa19843 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "node-fetch": "2", "nodemailer": "^6.7.2", "numeral": "^2.0.6", + "oauth2orize": "^1.11.1", "otplib": "^12.0.1", "passport": "^0.5.2", "passport-local": "^1.0.0", diff --git a/start-local b/start-local index 83688ab..329ee85 100755 --- a/start-local +++ b/start-local @@ -1,8 +1,11 @@ #!/bin/bash +MINIO_CI_CD=1 + MINIO_ROOT_USER="webapp" MINIO_ROOT_PASSWORD="302888b9-c3d8-40f5-92de-6a3c57186af5" -export MINIO_ROOT_USER MINIO_ROOT_PASSWORD + +export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/reeeper.js diff --git a/yarn.lock b/yarn.lock index 22fb53b..8a8d755 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2795,7 +2795,7 @@ date-now@^0.1.4: resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= -debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@~2.6.4: +debug@2.6.9, debug@2.x.x, debug@^2.2.0, debug@^2.3.3, debug@~2.6.4: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -6061,6 +6061,15 @@ o-stream@^0.3.0: resolved "https://registry.yarnpkg.com/o-stream/-/o-stream-0.3.0.tgz#204d27bc3fb395164507d79b381e91752e8daedc" integrity sha512-gbzl6qCJZ609x/M2t25HqCYQagFzWYCtQ84jcuObGr+V8D1Am4EVubkF4J+XFs6ukfiv96vNeiBb8FrbbMZYiQ== +oauth2orize@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/oauth2orize/-/oauth2orize-1.11.1.tgz#00b6cafe2036a0a3aab0380627dc7cfd5b5e9a9c" + integrity sha512-9dSx/Gwm0J2Rvj4RH9+h7iXVnRXZ6biwWRgb2dCeQhCosODS0nYdM9I/G7BUGsjbgn0pHjGcn1zcCRtzj2SlRA== + dependencies: + debug "2.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -8335,6 +8344,11 @@ uid2@0.0.3: resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" integrity sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I= +uid2@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" + integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== + uikit@^3.11.1: version "3.11.1" resolved "https://registry.yarnpkg.com/uikit/-/uikit-3.11.1.tgz#3f0b47f4b2e7610375c5f7cdbf550d086c81e22f" @@ -8528,7 +8542,7 @@ util@^0.12.3: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -utils-merge@1.0.1: +utils-merge@1.0.1, utils-merge@1.x.x: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=