fireactions/server/index.js
2023-07-20 15:25:07 -03:00

293 lines
7.8 KiB
JavaScript

import { delay } from "nanodelay";
import { customAlphabet as nanoidCustomAlphabet, nanoid } from "nanoid";
import which from "which";
import { spawn, execFile as _execFile } from "node:child_process";
import { createServer, request as _request } from "node:http";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { downloadImage, parseImageRef } from "./container-baby.js";
import { promisify } from "node:util";
import { access, chmod, chown, link, mkdir } from "node:fs/promises";
const execFile = promisify(_execFile);
const server = createServer(listener);
/**
* @param {import("node:http").IncomingMessage} req
* @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
*/
async function listener(req, res) {
const url = getUrl(req);
if (url.pathname == "/run") {
if (req.method == "POST") {
const image = url.searchParams.get("image");
if (!image) return res.writeHead(400).end("missing image param");
await runVm(image);
} else res.writeHead(405).end("wrong method");
} else res.writeHead(404).end("not found");
}
/**
* @param {string} image - ref to an OCI image
*/
async function runVm(image) {
try {
const ref = parseImageRef(image);
const { squashfsFile } = await downloadImage(ref.image, ref.tag);
await FirecrackerInstance.run({
drives: [
{
drive_id: "image",
is_read_only: true,
is_root_device: false,
path_on_host: squashfsFile,
},
],
});
} catch (err) {
console.error(err);
}
}
/**
* @typedef {object} Drive
* @prop {string} drive_id
* @prop {string} path_on_host
* @prop {boolean} is_root_device
* @prop {boolean} is_read_only
*/
/**
* gets absolute path to firecracker executable
* @returns {Promise<string>}
*/
async function getFirecrackerExe() {
const firecrackerExe = await which("firecracker", { nothrow: true });
console.debug(
`[fireactions] Using firecracker executable '${firecrackerExe}'`
);
return firecrackerExe;
}
const jailerDir = "./jailer";
const [jailerUid, jailerGid] = [65534, 65534];
// we need to use a custom alphabet because jailer is picky
const jailerNanoid = nanoidCustomAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyz-",
64
);
class FirecrackerInstance {
vmid;
socketPath;
proc;
/**
* @param {{ firecrackerExe: string }} param0
* @private
*/
constructor({ firecrackerExe }) {
this.vmid = jailerNanoid();
this.socketPath = join(this.chrootPath, `firecracker.sock`);
console.debug(this.socketPath);
this.proc = spawn(
"jailer",
[
...["--id", this.vmid],
...["--exec-file", firecrackerExe],
...["--uid", "" + jailerUid],
...["--gid", "" + jailerGid],
...["--chroot-base-dir", jailerDir],
"--",
...["--api-sock", "/firecracker.sock"],
// ...["--level", "Debug"],
// ...["--log-path", "/stderr.log"],
// "--show-level",
// "--show-log-origin",
],
{
stdio: "inherit",
}
);
console.debug(
`[fireactions] Started firecracker with jailer at ${this.chrootPath}`
);
}
get chrootPath() {
return join(jailerDir, "firecracker", this.vmid, "root");
}
/**
* @param {string} path path to file to hardlink to root of chroot
* @param {number} mod mod inside the chroot
*/
async hardlinkToChroot(path, mod = 0o600) {
const chrootPath = join(this.chrootPath, basename(path));
let accesed = false;
try {
await access(chrootPath);
accesed = true;
} catch {}
if (accesed)
throw new Error("file with same name already exists inside chroot");
await link(path, chrootPath);
await chown(chrootPath, jailerUid, jailerGid);
await chmod(chrootPath, mod);
}
/**
* @param [opts] {{ drives?: Drive[] }}
*/
static async run(opts) {
await mkdir(jailerDir, { recursive: true });
const self = new FirecrackerInstance({
firecrackerExe: await getFirecrackerExe(),
});
await mkdir(self.chrootPath, { recursive: true });
// TODO: retry until success
await delay(15);
const { ifname, hostAddr, guestAddr } = await createNetworkInterface();
await self.hardlinkToChroot("../vmlinux.bin", 0o400);
await self.request("PUT", "/boot-source", {
kernel_image_path: "/vmlinux.bin",
boot_args: `console=ttyS0 reboot=k panic=1 pci=off fireactions_ip=${guestAddr}:${hostAddr}`,
});
// TODO: readonly
self.attachDrive({
drive_id: "rootfs",
path_on_host: "/rootfs.ext4",
is_root_device: true,
is_read_only: false,
});
if (opts?.drives) {
for (const drive of opts.drives) {
await self.attachDrive(drive);
}
}
await self.request("PUT", "/network-interfaces/eth0", {
iface_id: "eth0",
guest_mac: "AA:FC:00:00:00:01",
host_dev_name: ifname,
});
// API requests are handled asynchronously, it is important the configuration is
// set, before `InstanceStart`.
// TODO: avoid race condition somehow? this is the way of the firecracker quickstart guide. check firectl/go-sdk
await delay(15);
await self.request("PUT", "/actions", {
action_type: "InstanceStart",
});
}
/**
* @param {Drive} drive
*/
async attachDrive(drive) {
const perms = drive.is_read_only ? 0o400 : 0o600;
await this.hardlinkToChroot(drive.path_on_host, perms);
await this.request("PUT", "/drives/" + drive.drive_id, {
...drive,
path_on_host: "/" + basename(drive.path_on_host),
});
}
/**
* @param {string} method
* @param {string} path
* @param {object} body
*/
async request(method, path, body) {
const jsonBody = JSON.stringify(body);
const { response, body: respBody } = await request(
{
method,
path,
socketPath: this.socketPath,
headers: {
"Content-Type": "application/json",
"Content-Length": jsonBody.length,
},
},
jsonBody
);
// @ts-ignore
if (response.statusCode > 299)
throw new Error(
`Status code: ${response.statusCode} / Body: ${respBody}`,
{
cause: `${method} ${path} ${JSON.stringify(body, null, 4)}`,
}
);
}
}
/**
* @param {import("http").IncomingMessage} req
*/
function getUrl(req) {
// prettier-ignore
const urlString = /** @type {string} */(req.url);
const url = new URL(urlString, `http://${req.headers.host}`);
return url;
}
/**
* @param {string | import("url").URL | import("http").RequestOptions} opts
* @param {string} [body]
* @returns {Promise<{ response: import("node:http").IncomingMessage, body: string }>}
*/
function request(opts, body) {
return new Promise((resolve, reject) => {
const req = _request(opts, (res) => {
let respBody = "";
res.on("data", (data) => {
respBody += data;
});
res.on("end", () => {
resolve({ response: res, body: respBody });
});
});
req.on("error", reject);
if (body)
req.write(body, (error) => {
if (error) reject(error);
});
req.end();
});
}
/**
* @type {string[]}
*/
let interfaces = [];
let ifIndex = 2;
async function createNetworkInterface() {
const ifname = "f" + nanoid(13);
await execFile("ip", ["tuntap", "add", ifname, "mode", "tap"]);
interfaces.push(ifname);
const hostAddr = `172.16.0.${ifIndex}`;
const guestAddr = `172.16.0.${ifIndex + 1}`;
ifIndex += 2;
await execFile("ip", ["addr", "add", `${hostAddr}/31`, "dev", ifname]);
await execFile("ip", ["link", "set", ifname, "up"]);
// TODO: setup masquerade
return { hostAddr, guestAddr, ifname };
}
process.on("beforeExit", async () => {
for (const ifname of interfaces) {
await execFile("ip", ["tuntap", "del", ifname, "mode", "tap"]);
}
process.exit(0);
});
server.listen("8080");