diff --git a/README.md b/README.md index dbf0dad..be1aa5c 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,119 @@ +# Installing in Production + +Be familiar with the steps in [Installation][] below, since it is needed to +build the Angular app first. + +Once the Angular app is built, provision a small base-64-lts 20.4.0 VM, +connected solely to the external network (aka public Internet). From within +the VM, the following steps are needed: + + pkgin in gmake + mkdir -p /opt/spearhead/portal + +From this repo, copy in bin/, cfg/, smf/, static/ (since this is a symlink, +this means the build in app/dist should be copied into static/ in prod), and \*. +Notably, avoid app/ and node\_modules. In production, adjust the config in +/opt/spearhead/portal/cfg/prod.json. Lastly: + + pushd /opt/spearhead/portal + npm install + svccfg import smf/service.xml + svcadm enable portal + popd + +The application will now be running. + # Installation +First install the server-side libraries: + npm install -# Generate server certificates +Then install the Angular compiler needed for the client-side app: -From within the config/ directory: + npm install -g @angular/cli + pushd app && npm install && popd +## Build the client-side app: + + pushd app && npm run build && popd + +## Generate server certificates + + pushd config openssl genrsa -out key.pem openssl req -new -key key.pem -out csr.pem openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.pem rm csr.pem + popd -# Configuration +## Configuration -Ensure the config file in config/ matches your details. +Ensure the config file in config/ matches your details. If running in +production, name the config file config/prod.json. + +Relevant configuration attributes: + +- server.port: the port this server will serve the app from +- server.key: path to the private key for TLS +- server.cert: path to the PKIX certificate for TLS +- urls.local: the domain or IP the SSO will redirect back to (aka this server) +- urls.sso: the URL to the SSO +- urls.cloudapi: the URL to cloudapi +- key.user: name of Triton user who has "Registered Developer" permission set +- key.id: SSH fingerprint of Triton user (same as what node-triton uses) +- key.path: path to private key of Triton user The SSH key used must be the correct format, e.g. generated with: ssh-keygen -m PEM -t rsa -C "your@email.address" -# Running the server +## Running the server node bin/server.js config/prod.json +The server generates a lot of JSON data about every request. This is easier +for a human to handle if they have bunyan installed ("npm install -g bunyan"), +and instead: + + node bin/server.js config/prod.json | bunyan + # Endpoints -## GET /static/* +## GET /\* This is where all the front-end code goes. All files will be served as-is as -found in that directory. The default is static/index.html. There is no -authentication; all files are public. +found in that directory (by default a symlink from static/ to app/dist). The +default is static/index.html. There is no authentication; all files are public. -## GET /login +## GET /api/login Call this endpoint to begin the login cycle. It will redirect you to the SSO login page: an HTTP 302, with a Location header. -## GET /token +## GET/POST/PUT/DELETE/HEAD /api/\* -Upon successful login, the SSO login page will redirect to this endpoint. This -endpoint will return a 204, along with a X-Auth-Token header that must be saved -by the front-end code. All subsequent calls should provide this X-Auth-Token -header. - -## Other - -All other calls will be passed through to cloudapi. For these calls to succeed, -they MUST provide the X-Auth-Token header that the /token endpoint returns. +All calls will be passed through to cloudapi. For these calls to succeed, +they MUST provide an X-Auth-Token header, containing the token returned from +SSO. # Interaction cycle -client --- GET /login --------> this server - <-- 302 Location #1 ---- + client --- GET /api/login --------> this server + <-- 302 Location #1 ---- -client --- GET --> SSO server - - <-- 302 Location #2 ---- - -client --- GET --> this server - <-- 204 X-Auth-Token ---- + client --- GET --> SSO server + + <-- 302 with token query arg From now on call this server as if it were a cloudapi server (using [cloudapi paths](https://github.com/joyent/sdc-cloudapi/blob/master/docs/index.md#api-introduction)), -always providing the X-Auth-Token. For example, to retrieve a list of packages: +except prefixing any path with "/api". Also always provide the X-Auth-Token. -client --- GET /my/packages --> this server - <-- 200 JSON body ------ +For example, to retrieve a list of packages: + + client --- GET /api/my/packages --> this server + <-- 200 JSON body ------ The most useful cloudapi endpoints to begin with will be ListPackages, GetPackage, ListImages, GetImage, ListMachines, GetMachine, CreateMachine and diff --git a/bin/server.js b/bin/server.js old mode 100644 new mode 100755 index 79c2015..75bd31b --- a/bin/server.js +++ b/bin/server.js @@ -6,6 +6,7 @@ const mod_cueball = require('cueball'); const mod_crypto = require('crypto'); const mod_fs = require('fs'); const mod_sdcauth = require('smartdc-auth'); +const mod_bunyan = require('bunyan'); // Globals that are assigned to by main(). They are used by proxy() and login(). @@ -15,6 +16,10 @@ let CLOUDAPI = {}; let CLOUDAPI_HOST = ''; let SIGNER = {}; +const LOGIN_PATH = '/api/login'; +const API_PATH = '/api'; // all calls here go to cloudapi +const API_RE = new RegExp('^' + API_PATH + '/'); +const STATIC_RE = new RegExp('^/'); // Take any HTTP request that has a token, sign that request with an // HTTP-Signature header, and pass it along to cloudapi. Return any response @@ -38,16 +43,19 @@ function proxy(req, res, cb) { // check the X-Auth-Token is present if (req.header('X-Auth-Token') == undefined) { - res.send({"Error": "X-Auth-Token header missing"}); + res.send({'Error': 'X-Auth-Token header missing'}); res.send(401); return cb(); } + // strip off /api from path before forwarding to cloudapi + let url = req.url.substr(API_PATH.length); + // sign the request before forwarding to cloudapi let headers = req.headers; var rs = mod_sdcauth.requestSigner({ sign: SIGNER }); headers.date = rs.writeDateHeader(); - rs.writeTarget(req.method, req.url); + rs.writeTarget(req.method, url); rs.sign(function signedCb(err, authz) { if (err) { @@ -58,7 +66,7 @@ function proxy(req, res, cb) { headers.authorization = authz; const opts = { - path: req.url, + path: url, headers: headers }; @@ -80,7 +88,7 @@ function proxy(req, res, cb) { function login(req, res, cb) { const query = { permissions: '{"cloudapi":["/my/*"]}', - returnto: CONFIG.urls.local + '/token', + returnto: CONFIG.urls.local, now: new Date().toUTCString(), keyid: '/' + CONFIG.key.user + '/keys/' + CONFIG.key.id, nonce: mod_crypto.randomBytes(15).toString('base64') @@ -98,19 +106,21 @@ function login(req, res, cb) { const signature = signer.sign(PRIVATE_KEY, 'base64'); url += '&sig=' + encodeURIComponent(signature); - res.redirect(url, cb); + res.json({ url }); } -// Once a user successfully logs in, they are redirected to here. We convert -// the token that was returned to use as query arg into an X-Auth-Token header -// that is returned to the client caller. This header must be provided by the -// client from now on in order to communicate with Cloudapi. -function token(req, res, cb) { - const token = decodeURIComponent(req.query().split('=')[1]); - res.header('X-Auth-Token', token); - res.send(204); - return cb(); +// Logging function useful to silence bunyan-based logging systems +function silentLogger() { + let stub = function () {}; + + return { + child: silentLogger, + warn: stub, + trace: stub, + info: stub, + debug: stub + } } @@ -136,8 +146,11 @@ function main() { CLOUDAPI = mod_restify.createStringClient({ url: CONFIG.urls.cloudapi, agent: new mod_cueball.HttpsAgent({ - spares: 0, - maximum: 4, + log: silentLogger(), // temporary + spares: 2, + maximum: 5, + ping: '/', + pingInterval: 5000, // in ms recovery: { default: { timeout: 2000, @@ -158,24 +171,30 @@ function main() { }; const server = mod_restify.createServer(options); + server.use(mod_restify.requestLogger()); server.use(mod_restify.authorizationParser()); server.use(mod_restify.bodyReader()); - // where to server static content from - server.get(/^\/static.*/, mod_restify.plugins.serveStatic({ + // log requests + server.on('after', mod_restify.auditLogger({ + log: mod_bunyan.createLogger({ name: 'proxy' }) + })); + + // login path is /api/login + server.get(LOGIN_PATH, login); + + // all cloudapi calls are proxied through /api + server.get(API_RE, proxy); + server.put(API_RE, proxy); + server.del(API_RE, proxy); + server.post(API_RE, proxy); + server.head(API_RE, proxy); + + // where to serve static content from + server.get(STATIC_RE, mod_restify.plugins.serveStatic({ directory: 'static', default: 'index.html' })); - - // route HTTP requests to proper functions - server.get('/login', login); - server.get('/token', token); - - server.get(/^/, proxy); - server.put(/^/, proxy); - server.del(/^/, proxy); - server.post(/^/, proxy); - server.head(/^/, proxy); // enable HTTP server server.listen(CONFIG.server.port, function listening() { diff --git a/smf/run.sh b/smf/run.sh new file mode 100755 index 0000000..9a02084 --- /dev/null +++ b/smf/run.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +cd /opt/spearhead/portal +/opt/local/bin/node bin/server.js cfg/prod.json & diff --git a/smf/service.xml b/smf/service.xml new file mode 100644 index 0000000..ea68512 --- /dev/null +++ b/smf/service.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static b/static new file mode 120000 index 0000000..ddac10c --- /dev/null +++ b/static @@ -0,0 +1 @@ +app/dist \ No newline at end of file diff --git a/static/index.html b/static/index.html deleted file mode 100644 index e45adde..0000000 --- a/static/index.html +++ /dev/null @@ -1,5 +0,0 @@ - - - Hi! - - diff --git a/static/static b/static/static deleted file mode 120000 index 945c9b4..0000000 --- a/static/static +++ /dev/null @@ -1 +0,0 @@ -. \ No newline at end of file