diff --git a/README.md b/README.md new file mode 100644 index 0000000..892e4bf --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# BDMGR.D TS + +Implémentation de BDMGR daemon en typescript. + +## Dépendances + +Deno : + +```sh +curl -fsSL https://deno.land/x/install/install.sh | sh +``` + +## Usage + +Démon : +```sh +sudo ./daemon.ts +``` + +Controleur : +```sh +control.ts - controls BDMGRDTS daemon. +Usage: + ./control.ts Command [ARG...] + +Commands: + help Prints a help message. + stop Stops the daemon. + status Get a list of enabled containers. + create NAME BASE Create a new container from a specific base. + enable NAME Enables a container. + disable NAME Disables a container. + reload NAME Reloads a container, equivalent to enable then disable. +``` \ No newline at end of file diff --git a/instance/control.ts b/instance/control.ts index f67bbf2..0bdd825 100755 --- a/instance/control.ts +++ b/instance/control.ts @@ -1,31 +1,93 @@ #!/bin/env -S deno run -A --unstable -import { daemon_send, new_cmd_disable, new_cmd_enable, new_cmd_status } from "./src/lib.ts"; -import { socket_path } from "./src/lib/paths.ts"; +import { daemon_send, new_cmd_disable, new_cmd_enable, new_cmd_status, new_cmd_stop } from "./src/lib.ts"; +import { load_all_configs, new_container_config, save_container_config } from "./src/lib/config.ts"; +import { load_base, new_container_context } from "./src/lib/create.ts"; +import { container_paths, socket_path } from "./src/lib/paths.ts"; +import { log_from, run } from "./src/lib/utils.ts"; -await main(); +const log = log_from("control"); + +if (import.meta.main) await main(); async function main() { - const [kind, ...rest] = Deno.args; + const [command, ...rest] = Deno.args; - if (kind === "status") { - const res = await daemon_send(socket_path(), new_cmd_status()); - console.log({ res }); + if (["-h", "--help", "help"].includes(command)) { + return console.log(`control.ts - controls BDMGRDTS daemon. +Usage: + ./control.ts Command [ARG...] + +Commands: + help Prints a help message. + stop Stops the daemon. + status Get a list of enabled containers. + create NAME BASE Create a new container from a specific base. + enable NAME Enables a container. + disable NAME Disables a container. + reload NAME Reloads a container, equivalent to enable then disable. +`); } - if (kind === "enable") { + if (command === "create") { + const [name, base] = rest; + if (name === undefined) log("expects NAME argument"); + if (base === undefined) log("expects BASE argument"); + await create(name, base); + return; + } + + if (command === "status") { + const res = await daemon_send(socket_path(), new_cmd_status()); + log(res); + return; + } + + if (command === "enable") { const [name] = rest; + if (name === undefined) log("expects NAME argument"); const res = await daemon_send(socket_path(), new_cmd_enable(name)); - console.log({ res }); + log(res); + return; } - if (kind === "disable") { + if (command === "disable") { const [name] = rest; + if (name === undefined) log("expects NAME argument"); const res = await daemon_send(socket_path(), new_cmd_disable(name)); - console.log({ res }); + log(res); + return; } - if (kind === "stop") { - const res = await daemon_send(socket_path(), new_cmd_status()); - console.log({ res }); + if (command === "reload") { + const [name] = rest; + if (name === undefined) log("expects NAME argument"); + const res = await daemon_send(socket_path(), new_cmd_enable(name)); + log(res); + return; } + + if (command === "stop") { + const res = await daemon_send(socket_path(), new_cmd_stop()); + log(res); + return; + } + + log("unknown command", command); +} + +export async function create(name: string, base_name: string) { + log("loading base", base_name); + const base = await load_base(base_name); + const known_containers = await load_all_configs(); + + const paths = container_paths(name); + await Deno.mkdir(paths.base); + const configuration = new_container_config(name); + + log("creating the container", name, "at", paths.base); + const context = new_container_context(paths.root, configuration, known_containers); + await Deno.mkdir(paths.root); + await run("sudo", "chown", "root:root", paths.root); + await base.build(context); + await save_container_config(configuration); } diff --git a/instance/daemon.ts b/instance/daemon.ts index b12ac1e..5c89e8a 100755 --- a/instance/daemon.ts +++ b/instance/daemon.ts @@ -1,50 +1,127 @@ #!/bin/env -S deno run -A --unstable import { daemon_listen, Runner, start_runner } from "./src/lib.ts"; -import { new_container_config } from "./src/lib/config.ts"; -import { socket_path } from "./src/lib/paths.ts"; +import { load_container_config } from "./src/lib/config.ts"; +import { socket_path, state_path } from "./src/lib/paths.ts"; +import { log_from, sleep } from "./src/lib/utils.ts"; -await main(); +const log = log_from("daemon"); + +if (import.meta.main) await main(); async function main() { - const server = daemon_listen(socket_path()); - const enabled = new Map(); + const state = await create_state(); + const server = await daemon_listen(socket_path()); + log("listening to", socket_path()); + async function finish() { + log("stopping"); server.server.close(); + for (const runner of state.enabled.values()) { + try { + await runner.stop(); + } catch (_) { /* on s'en fou */ } + } await Deno.remove(socket_path()); - for (const runner of enabled.values()) await runner.stop(); Deno.exit(0); } Deno.addSignalListener("SIGINT", finish); - console.log("listening to", socket_path()); for await (const { cmd, respond } of server) { - console.log("received", { cmd }); + log("received", cmd); if (cmd.kind === "status") { - await respond(JSON.stringify(Array.from(enabled.keys()))); + await respond(JSON.stringify(state.list())); continue; } if (cmd.kind === "enable") { - const config = new_container_config(cmd.name); // TODO - const runner = start_runner(config); - enabled.set(cmd.name, runner); - await respond("enabled"); + (async () => { + try { + await state.enable(cmd.name); + await state.save(); + await respond("enabled"); + } catch (error) { + log("experienced failure", error); + await respond(`${error}`); + } + })(); continue; } if (cmd.kind === "disable") { - await enabled.get(cmd.name)?.stop(); - enabled.delete(cmd.name); - await respond("disabled"); + (async () => { + try { + await state.disable(cmd.name); + await state.save(); + await respond("disabled"); + } catch (error) { + log("experienced failure", error); + await respond(`${error}`); + } + })(); + continue; + } + + if (cmd.kind === "reload") { + (async () => { + try { + await state.disable(cmd.name); + await sleep(500); + await state.enable(cmd.name); + await respond("reloaded"); + } catch (error) { + log("experienced failure", error); + await respond(`${error}`); + } + })(); continue; } if (cmd.kind === "stop") { - await respond(JSON.stringify("stopping, ++")); + await state.save(); + await respond("adios"); await finish(); } - await respond("unknown"); + await respond("unknown command"); } } + +async function create_state() { + const self = { + enabled: new Map(), + async enable(name: string) { + if (self.enabled.has(name)) throw new Error("Container already enabled"); + log("loading container", name); + const config = await load_container_config(name); // TODO + if (config === null) throw new Error("can't read config"); + log("starting container", name); + self.enabled.set(name, await start_runner(config)); + log("container", name, "started"); + }, + async disable(name: string) { + const container = self.enabled.get(name); + if (container === undefined) throw new Error("Container not found"); + await container.stop(); + self.enabled.delete(name); + }, + list() { + return Array.from(self.enabled.keys()); + }, + async save() { + const content = JSON.stringify({ enabled: self.list() }); + await Deno.writeTextFile(state_path(), content); + }, + }; + try { + log("trying to recover state from", state_path()); + const loaded = JSON.parse(await Deno.readTextFile(state_path())); + for (const name of loaded.enabled ?? []) { + await self.enable(name); + } + log("successfully loaded from", state_path()); + } catch (error) { + log("experienced failure", error); + } + return self; +} diff --git a/instance/local/bases/arch/module.ts b/instance/local/bases/arch/module.ts new file mode 100644 index 0000000..0ff099e --- /dev/null +++ b/instance/local/bases/arch/module.ts @@ -0,0 +1,26 @@ +import { Base, BaseContext } from "../../../src/lib.ts"; + +export { build }; + +const build: Base = async (c) => { + // installs base + await c.sh(`pacstrap -K -c '${c.root_dir}' base`); + await c.sh(`rm -fr '${c.root_dir}/etc/securetty' '${c.root_dir}/usr/share/factory/etc/securetty'`); + + await c.sh_in(`printf 'root:noussommesdescrabes' | chpasswd --crypt-method DES`); + await pacman(c, "nano", "git", "base-devel"); + await c.sh_in(`systemctl enable systemd-networkd.service`); + + // ssh + await pacman(c, "openssh"); + await c.sh_in(` + echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config + systemctl enable sshd.service + `); + const port = c.available_port(2250); + c.redirect(port, 22); +}; + +async function pacman(c: BaseContext, ...packages: string[]) { + await c.sh_in(`pacman -Syu --noconfirm '${packages.join("' '")}'`); +} diff --git a/instance/src/bin/proxy.ts b/instance/src/bin/proxy.ts old mode 100644 new mode 100755 index 2b7464b..c291337 --- a/instance/src/bin/proxy.ts +++ b/instance/src/bin/proxy.ts @@ -1,11 +1,15 @@ #!/bin/env -S deno run -A --unstable -await main(); +if (import.meta.main) await main(); async function main() { const [from_port, to_ip, to_port] = Deno.args; - if (to_port === undefined) Deno.exit(2); + if (to_port === undefined) { + console.error("[proxy] usage: ./proxy.ts "); + Deno.exit(2); + } const server = Deno.listen({ transport: "tcp", port: parseInt(from_port) }); + console.log("[proxy] listening on port", from_port, "redirecting to", to_ip, "port", to_port); for await (const connection of server) { try { @@ -16,3 +20,9 @@ async function main() { } catch (_) { /* isok */ } } } + +export function proxy_command(from_port: number, to_ip: string, to_port: number) { + const path = new URL("", import.meta.url).pathname; + const args = [from_port, to_ip, to_port].map((v) => v.toString()); + return new Deno.Command(path, { args }); +} diff --git a/instance/src/lib.ts b/instance/src/lib.ts index 1798f8e..6e1e18f 100644 --- a/instance/src/lib.ts +++ b/instance/src/lib.ts @@ -1,6 +1,13 @@ +export type { Base, BaseContext } from "./lib/create.ts"; + import { toText } from "https://deno.land/std@0.208.0/streams/to_text.ts"; -import { lines } from "./lib/utils.ts"; +import { lines, log_from, loop_process, LoopProcess, run, sleep } from "./lib/utils.ts"; import { ContainerConfig } from "./lib/config.ts"; +import { container_paths } from "./lib/paths.ts"; +import { container_command, get_container_addresses } from "./lib/nspawn.ts"; +import { proxy_command } from "./bin/proxy.ts"; + +const log = log_from("lib"); export type CmdStatus = ReturnType; export function new_cmd_status() { @@ -17,15 +24,22 @@ export function new_cmd_disable(name: string) { return { kind: "disable" as const, name }; } +export type CmdReload = ReturnType; +export function new_cmd_reload(name: string) { + return { kind: "reload" as const, name }; +} + export type CmdStop = ReturnType; export function new_cmd_stop() { return { kind: "stop" as const }; } -export type Cmd = CmdStatus | CmdEnable | CmdDisable | CmdStop; +export type Cmd = CmdStatus | CmdEnable | CmdDisable | CmdReload | CmdStop; -export function daemon_listen(sock_path: string) { +export async function daemon_listen(sock_path: string) { const server = Deno.listen({ transport: "unix", path: sock_path }); + await Deno.chmod(sock_path, 0o775); + await run("chgrp", "wheel", sock_path); const generator = async function* () { for await (const request of server) { const respond = async (message: string) => { @@ -53,12 +67,52 @@ export async function daemon_send(sock_path: string, command: Cmd) { return await toText(request.readable); } -export type Runner = ReturnType; +export type Runner = Awaited>; export function start_runner(config: ContainerConfig) { + const { name } = config; + const paths = container_paths(name); + const command = container_command(name, paths.root, { + boot: true, + veth: true, + cmd_opts: { + stdin: "null", + stdout: "null", + }, + }); + const proxies = [] as LoopProcess[]; + const container_loop = loop_process(command, { + on_start: () => { + log("container", name, "started"); + (async () => { + await sleep(1_000); + const [address] = await get_container_addresses(name); + proxies.push(...start_proxies(address, config.redirects)); + })(); + }, + on_stop: async () => { + log("container", name, "stopped"); + for (const p of proxies) await p.kill(); + await sleep(500); + proxies.splice(0, proxies.length); + }, + }); return { - name: config.name, + name, + config, + container_loop, stop: async () => { - // TODO + for (const p of proxies) await p.kill(); + await container_loop.kill(); }, }; } + +function start_proxies(address: string, redirects: [number, number][]) { + const redirections = redirects + .map(([from_port, to_port]) => { + return loop_process(proxy_command(from_port, address, to_port), { + on_start: () => console.log("starting proxy", from_port, "to", address, "port", to_port), + }); + }); + return redirections; +} diff --git a/instance/src/lib/config.ts b/instance/src/lib/config.ts index 0ec0c44..4d411ff 100644 --- a/instance/src/lib/config.ts +++ b/instance/src/lib/config.ts @@ -1,5 +1,7 @@ -import { container_paths } from "./paths.ts"; -import { exists } from "./utils.ts"; +import { container_paths, containers_path } from "./paths.ts"; +import { exists, log_from } from "./utils.ts"; + +const log = log_from("config"); export type ContainerConfig = ReturnType; export function new_container_config(name: string) { @@ -11,11 +13,27 @@ export function new_container_config(name: string) { } export async function load_container_config(name: string) { - const config_path = container_paths(name).config; + const config_path = container_paths(name).configuration; + log("loading config for", name); if (!exists(config_path)) return null; const content = await Deno.readTextFile(config_path); const read = JSON.parse(content); const default_ = new_container_config(name); if (read.version < default_.version) throw new Error("read conf version is outdated"); - return { ...default_, ...read }; + return { ...default_, ...read } as ContainerConfig; +} + +export async function save_container_config(config: ContainerConfig) { + log("saving config of", config.name); + const config_path = container_paths(config.name).configuration; + const serialized = JSON.stringify(config, null, 4); + await Deno.writeTextFile(config_path, serialized); +} + +export async function load_all_configs() { + const names = Array.from(Deno.readDirSync(containers_path())) + .filter((e) => e.isDirectory) + .map((e) => e.name); + const results = await Promise.all(names.map((name) => load_container_config(name))); + return results.filter((item) => item != null) as ContainerConfig[]; } diff --git a/instance/src/lib/create.ts b/instance/src/lib/create.ts new file mode 100644 index 0000000..52c78ca --- /dev/null +++ b/instance/src/lib/create.ts @@ -0,0 +1,69 @@ +// import { blob } from "https://deno.land/std@0.208.0/streams/mod.ts" + +import { ContainerConfig } from "./config.ts"; +import { base_paths } from "./paths.ts"; +import { lines, log_from } from "./utils.ts"; + +export type Base = (context: BaseContext) => unknown; +const log = log_from("BUILDING"); + +export type BaseContext = ReturnType; +export function new_container_context(root_dir: string, config: ContainerConfig, known_containers: ContainerConfig[]) { + return { + root_dir, + config, + sh: async (script: string) => { + log(`running '${script.replace("\n", " ").slice(0, 20)}...'`); + const process = new Deno.Command("sudo", { + args: ["sh", "-c", "set -e;" + script], + stdin: "null", + stdout: "piped", + }).spawn(); + log_lines_prefixed(" | ", process.stdout); + const res = await process.status; + if (!res.success) throw new Error("process failed"); + }, + sh_in: async (script: string) => { + log(`inside '${root_dir}' running '${script.replace("\n", " ").slice(0, 20)}...'`); + const process = new Deno.Command("sudo", { + args: ["systemd-nspawn", `--directory=${root_dir}`, "-P"], + stdin: "piped", + stdout: "piped", + }).spawn(); + write_all(process.stdin, "set -e;" + script + "\nexit 0\n"); + log_lines_prefixed(" | ", process.stdout); + const res = await process.status; + if (!res.success) throw new Error("process failed"); + }, + redirect: (from: number, to: number) => { + log("redirects", from, "to", to); + config.redirects.push([from, to]); + }, + available_port: (target: number) => { + const used_by_known_containers = known_containers.map((c) => c.redirects[0]).flat(); + const used_by_self = config.redirects.map((r) => r[0]); + const used = [...used_by_known_containers, ...used_by_self]; + let result = target; + while (used.includes(result)) result += 1; + return result; + }, + }; +} + +async function log_lines_prefixed(prefix: string, stream: ReadableStream) { + for await (const line of lines(stream)) { + console.log(prefix + line); + } +} + +async function write_all(stream: WritableStream, text: string) { + await stream.getWriter().write(new TextEncoder().encode(text)); +} + +export async function load_base(name: string) { + const paths = base_paths(name); + const module = await import(paths.module); + const build = module["build"] as (context: BaseContext) => unknown; + if (build === undefined) throw new Error("module does not contain a build procedure"); + return { build }; +} diff --git a/instance/src/lib/nspawn.ts b/instance/src/lib/nspawn.ts index 9f58fae..d5fc189 100644 --- a/instance/src/lib/nspawn.ts +++ b/instance/src/lib/nspawn.ts @@ -1 +1,37 @@ // wrapper + +import { log_from, sleep } from "./utils.ts"; + +const log = log_from("nspawn"); + +export function container_command(name: string, directory: string, opts?: { + veth?: boolean; + boot?: boolean; + cmd_opts?: Deno.CommandOptions; +}) { + const args = [ + `--machine=${name}`, + `--directory=${directory}`, + ]; + if (opts?.veth ?? false) args.push("--network-veth"); + if (opts?.boot ?? false) args.push("--boot"); + const command = new Deno.Command("systemd-nspawn", { ...opts?.cmd_opts, args }); + return command; +} + +export async function get_container_addresses(name: string) { + const command = new Deno.Command("machinectl", { args: ["status", name], stdout: "piped" }); + while (true) { + await sleep(500); + log("getting address of", name); + const { code, stdout } = await command.output(); + if (code != 0) continue; + const output = new TextDecoder().decode(stdout); + const [_, rest] = output.split(" Address: "); + if (rest === undefined) continue; + const [raw] = rest.split(" OS: "); + const addresses = raw.trim().split("\n").map((l) => l.trim()); + if (addresses.length === 0) continue; + return addresses; + } +} diff --git a/instance/src/lib/paths.ts b/instance/src/lib/paths.ts index abdfe6b..622a905 100644 --- a/instance/src/lib/paths.ts +++ b/instance/src/lib/paths.ts @@ -16,7 +16,21 @@ export function containers_path() { export function container_paths(name: string) { const base = containers_path() + "/" + name; - const conf = base + "/config.json"; + const configuration = base + "/config.json"; const root = base + "/root"; - return { base, configuration: conf, root }; + return { base, configuration, root }; +} + +export function state_path() { + return instance_root_path() + "/local/state.json"; +} + +export function bases_path() { + return instance_root_path() + "/local/bases"; +} + +export function base_paths(name: string) { + const base = bases_path() + "/" + name; + const module = base + "/module.ts"; + return { base, module }; } diff --git a/instance/src/lib/utils.ts b/instance/src/lib/utils.ts index 8421f50..a770923 100644 --- a/instance/src/lib/utils.ts +++ b/instance/src/lib/utils.ts @@ -1,5 +1,6 @@ import { TextLineStream } from "https://deno.land/std@0.208.0/streams/mod.ts"; +export type Channel = ReturnType>; export function channel() { const inner = { queue: [] as T[], @@ -41,3 +42,54 @@ export function lines(readable: ReadableStream) { .pipeThrough(new TextDecoderStream()) .pipeThrough(new TextLineStream()); } + +const async_noop = async () => {}; +export type LoopProcess = ReturnType; +export function loop_process( + command: Deno.Command, + opts?: { delay?: number; on_start?: () => unknown; on_stop?: () => unknown }, +) { + const events = { + on_start: opts?.on_start ?? async_noop, + on_stop: opts?.on_stop ?? async_noop, + }; + const kill_sig = channel<"kill">(); + async function launch() { + while (true) { + await events.on_start(); + const child_process = command.spawn(); + const result = await Promise.any([kill_sig.receive(), child_process.output()]); + if (result === "kill") { + await events.on_stop(); + child_process.kill(); + break; + } + await events.on_stop(); + await sleep(opts?.delay ?? 500); + } + } + const looping = launch(); + return { + kill: async () => { + kill_sig.send("kill"), await looping; + }, + events, + looping, + }; +} + +export function sleep(ms: number) { + return new Promise((resolver) => setTimeout(resolver, ms)); +} + +export async function run(command: string, ...args: string[]) { + const { code } = await new Deno.Command(command, { args }).output(); + if (code != 0) throw new Error(`command ${command} failed`); +} + +export function log_from(...prefixes: string[]) { + const prefix = prefixes.map((p) => `[${p}]`).join("").padStart(10); + return function (...args: unknown[]) { + console.log(prefix, ...args); + }; +} diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..52b3422 --- /dev/null +++ b/sync.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e +cd "$(dirname "$(realpath "$0")")" + + +if [ $# -lt 1 ] +then exit 2 +fi + + +DEST="$1" + + +rsync -avzhP ./instance "$DEST"