293 lines
7.8 KiB
JavaScript
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");
|