webapp-deployment-status-api/src/upload.ts
Thomas E Lackey 3372ce29f3 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: #14
Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com>
Co-committed-by: Thomas E Lackey <telackey@bozemanpass.com>
2024-08-27 19:44:52 +00:00

92 lines
2.3 KiB
TypeScript

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