Support uploading config files. (#14)
This adds a new `/upload/config` endpoint to the API for uploading encrypted configuration files for later use by the deployer.
The payload takes the form:
```
authorized:
- accounta
- accountb
config:
env:
FOO: bar
BAR: baz
```
The request is the encrypted using the deployer's public key (discoverable from its `WebappDeployer` record). This is handled automatically by `laconic-so` but can also be handled manually using standard CLI tools like `gpg` and `curl`.
For example:
```
# Get the key
$ laconic -c ~/.laconic/testnet-a-cercio.yml registry name resolve lrn://laconic/deployers/webapp-deployer-api.dev.vaasl.io | jq -r '.[0].attributes.publicKey' | base64 -d > webapp-deployer-api.dev.vaasl.io.pgp.pub
# Import it
$ gpg --import webapp-deployer-api.dev.vaasl.io.pgp.pub
# Encrypt your config file.
$ gpg --yes --encrypt --recipient webapp-deployer-api.dev.vaasl.io --trust-model always config.yaml
# Post it
$ curl -s -X POST -d '@config.yaml.gpg' https://webapp-deployer-api.dev.vaasl.io/upload/config | jq
{
"id": "B56C65AB96B741B7B219520A3ABFCD10"
}
```
Reviewed-on: cerc-io/webapp-deployment-status-api#14
Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com>
Co-committed-by: Thomas E Lackey <telackey@bozemanpass.com>
This commit is contained in:
@@ -13,11 +13,15 @@ export const Config = {
|
||||
LISTEN_PORT: parseInt(process.env.LISTEN_PORT || '9555'),
|
||||
LISTEN_ADDR: process.env.LISTEN_ADDR || '0.0.0.0',
|
||||
LACONIC_CONFIG: process.env.LACONIC_CONFIG || '/etc/config/laconic.yml',
|
||||
UPLOAD_DIRECTORY: process.env.UPLOAD_DIRECTORY || '/srv/uploads',
|
||||
DEPLOYER_STATE:
|
||||
process.env.DEPLOYER_STATE || '/srv/deployments/autodeploy.state',
|
||||
UNDEPLOYER_STATE:
|
||||
process.env.UNDEPLOYER_STATE || '/srv/deployments/autoundeploy.state',
|
||||
BUILD_LOGS: process.env.BUILD_LOGS || '/srv/logs',
|
||||
UPLOAD_MAX_SIZE: process.env.BUILD_LOGS || '1MB',
|
||||
OPENPGP_PASSPHRASE: process.env.OPENPGP_PASSPHRASE,
|
||||
OPENPGP_PRIVATE_KEY_FILE: process.env.OPENPGP_PRIVATE_KEY_FILE,
|
||||
};
|
||||
|
||||
export const getRegistry = (): Registry => {
|
||||
|
||||
+12
-12
@@ -152,15 +152,22 @@ export class RegHelper {
|
||||
const status = new RequestStatus(r.id, r.createTime);
|
||||
ret.push(status);
|
||||
|
||||
const app = await this.getRecord(r.attributes.application);
|
||||
if (!app) {
|
||||
status.lastState = 'ERROR';
|
||||
continue;
|
||||
}
|
||||
|
||||
status.app = r.attributes.application;
|
||||
const hostname = r.attributes.dns ?? generateHostnameForApp(app);
|
||||
|
||||
if (deploymentsByRequest.has(r.id)) {
|
||||
const deployment = deploymentsByRequest.get(r.id);
|
||||
status.url = deployment.attributes.url;
|
||||
status.lastUpdate = deployment.createTime;
|
||||
const shortHost = new URL(status.url).host.split('.').shift();
|
||||
if (!latestByHostname.has(shortHost)) {
|
||||
latestByHostname.set(shortHost, status);
|
||||
|
||||
if (!latestByHostname.has(hostname)) {
|
||||
latestByHostname.set(hostname, status);
|
||||
}
|
||||
status.deployment = deployment.names ? deployment.names[0] : null;
|
||||
if (status.deployment) {
|
||||
@@ -176,19 +183,12 @@ export class RegHelper {
|
||||
continue;
|
||||
}
|
||||
|
||||
const app = await this.getRecord(r.attributes.application);
|
||||
if (!app) {
|
||||
status.lastState = 'ERROR';
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortHost = r.attributes.dns ?? generateHostnameForApp(app);
|
||||
if (latestByHostname.has(shortHost)) {
|
||||
if (latestByHostname.has(hostname)) {
|
||||
status.lastState = 'CANCELLED';
|
||||
continue;
|
||||
}
|
||||
|
||||
latestByHostname.set(shortHost, status);
|
||||
latestByHostname.set(hostname, status);
|
||||
}
|
||||
|
||||
return ret;
|
||||
|
||||
+17
@@ -1,13 +1,18 @@
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import {existsSync, readdirSync, readFileSync} from 'fs';
|
||||
|
||||
import {Config} from './config.js';
|
||||
|
||||
import {RegHelper} from './deployments.js';
|
||||
import { Uploader } from './upload.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const configUploader = new Uploader(Config.UPLOAD_DIRECTORY);
|
||||
const configUploadParser = bodyParser.raw({limit: Config.UPLOAD_MAX_SIZE, type: "*/*"})
|
||||
|
||||
app.use(function (_req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept',);
|
||||
@@ -95,6 +100,18 @@ app.get('/:id/log', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/upload/config', configUploadParser, async (req, res) => {
|
||||
try {
|
||||
const id = await configUploader.upload(req.body);
|
||||
res.json({
|
||||
id
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
// deprecated
|
||||
app.get('/log/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import assert from 'node:assert';
|
||||
import openpgp from 'openpgp';
|
||||
import YAML from 'yaml';
|
||||
import { atob } from 'node:buffer';
|
||||
|
||||
import { Config } from './config.js';
|
||||
|
||||
let privateKey: openpgp.PrivateKey | null = null;
|
||||
|
||||
const loadPrivateKey = async () => {
|
||||
if (null == privateKey) {
|
||||
privateKey = await openpgp.decryptKey({
|
||||
privateKey: await openpgp.readPrivateKey({
|
||||
binaryKey: fs.readFileSync(Config.OPENPGP_PRIVATE_KEY_FILE)
|
||||
}),
|
||||
passphrase: Config.OPENPGP_PASSPHRASE,
|
||||
});
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
const randomId = (): string =>
|
||||
crypto
|
||||
.randomUUID({ disableEntropyCache: true })
|
||||
.replaceAll('-', '')
|
||||
.toUpperCase();
|
||||
|
||||
const validateConfig = (obj): undefined => {
|
||||
assert(obj.authorized, "'authorized' is required");
|
||||
assert(Array.isArray(obj.authorized), "'authorized' must be an array");
|
||||
assert(obj.authorized.length >= 1, "'authorized' cannot be empty");
|
||||
assert(obj.config, "'config' is required");
|
||||
};
|
||||
|
||||
export const b64ToBytes = (base64): Uint8Array => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const decrypt = async (binaryMessage: Uint8Array): Promise<any> => {
|
||||
const message = await openpgp.readMessage({
|
||||
binaryMessage,
|
||||
});
|
||||
|
||||
const { data } = await openpgp.decrypt({
|
||||
message,
|
||||
decryptionKeys: await loadPrivateKey(),
|
||||
});
|
||||
|
||||
const config = data.toString();
|
||||
return config.charAt(0) === '{' ? JSON.parse(config) : YAML.parse(config);
|
||||
};
|
||||
|
||||
export class Uploader {
|
||||
directory: string;
|
||||
|
||||
constructor(dir: string) {
|
||||
this.directory = dir;
|
||||
}
|
||||
|
||||
async upload(body: string | Uint8Array): Promise<string> {
|
||||
let raw: any;
|
||||
try {
|
||||
raw = b64ToBytes(body);
|
||||
} catch {
|
||||
raw = body;
|
||||
}
|
||||
|
||||
// We decrypt only to make sure the content is valid.
|
||||
// Once we know it is good, we want to store the encrypted copy.
|
||||
const obj = await decrypt(raw);
|
||||
validateConfig(obj);
|
||||
|
||||
let id: string;
|
||||
let destination: string;
|
||||
do {
|
||||
id = randomId();
|
||||
destination = `${this.directory}/${id}`;
|
||||
} while (fs.existsSync(destination));
|
||||
|
||||
console.log(`Wrote config to: ${destination}`);
|
||||
fs.writeFileSync(destination, raw);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user