diff --git a/.gitignore b/.gitignore index f5cd5ef..4a1d4df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ build-javascript cache/ -artifacts/ \ No newline at end of file +artifacts/ +secrets/ \ No newline at end of file diff --git a/alpine.ts b/alpine.ts index 4203652..9d1c1a2 100644 --- a/alpine.ts +++ b/alpine.ts @@ -2,22 +2,23 @@ import { chmod, copyFile, mkdir, - mkdtemp, opendir, - rm, symlink, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; import { cwd } from "node:process"; +import { Fstab } from "./fstab.js"; import { execFile } from "./helpers/better-api.js"; +import { PasswdEntry, sudoReadPasswd } from "./helpers/passwd.js"; +import { sudoWriteExecutable } from "./helpers/sudo.js"; export class Alpine { dir: string; private constructor({ dir }: { dir: string }) { this.dir = dir; } + fstab: Fstab = new Fstab(this); async mkdir(dir: string, opts?: { recursive: boolean }): Promise { await mkdir(path.join(this.dir, dir), opts); @@ -30,24 +31,11 @@ export class Alpine { await this.writeFile(filePath, content); await chmod(path.join(this.dir, filePath), 700); } - async sudoWriteExecutable(filePath: string, content: string): Promise { - const dir = await mkdtemp( - path.join(tmpdir(), "define-alpine-sudoWriteExecutable-") - ); - try { - const tmpFile = path.join(dir, path.basename(filePath)); - const finalPath = path.join(this.dir, filePath); - await writeFile(tmpFile, content); - await execFile("sudo", [ - "mkdir", - "--parents", - path.join(this.dir, path.dirname(filePath)), - ]); - await execFile("sudo", ["mv", tmpFile, finalPath]); - await execFile("sudo", ["chmod", "700", finalPath]); - } finally { - await rm(dir, { recursive: true, force: true }); - } + path(p: string): string { + return path.join(this.dir, p); + } + sudoWriteExecutable(filePath: string, content: string): Promise { + return sudoWriteExecutable(this.path(filePath), content); } private getRelativeSymlink( target: string, @@ -71,6 +59,22 @@ export class Alpine { await execFile("sudo", ["ln", "-s", target, filePath]); } + async userAdd(user: string): Promise { + await execFile("sudo", [ + "useradd", + "--user-group", + "--root", + this.dir, + user, + ]); + const passwd = await sudoReadPasswd(this.path("/etc/passwd")); + const entry = passwd.find((e) => e.name === user); + if (!entry) { + throw new Error("fatal(userAdd): no encontré el usuario " + user); + } + return entry; + } + async addPackages(packages: string[]): Promise { await execFile("sudo", [ "apk", @@ -128,6 +132,8 @@ export class Alpine { ...(packages || []), ]); - return new Alpine({ dir }); + const alpine = new Alpine({ dir }); + await alpine.fstab.write(); + return alpine; } } diff --git a/forgejo/build.ts b/forgejo/build.ts new file mode 100644 index 0000000..c8c08e3 --- /dev/null +++ b/forgejo/build.ts @@ -0,0 +1,52 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { reproRun } from "../helpers/repro-run.js"; + +const FORGEJO_VERSION = "v1.18.3-0"; + +// returns path to statically compiled binary +export async function buildForgejo(): Promise { + const dir = "cache/forgejo"; + await mkdir(dir, { recursive: true }); + const versionFile = join(dir, "version"); + const output = join(dir, "rootfs/forgejo"); + try { + if ((await readFile(versionFile, "utf-8")) === FORGEJO_VERSION) + return output; + } catch {} + + { + const buildScript = join(dir, "build"); + await writeFile( + buildScript, + `#!/bin/sh -e +runprint() { + echo "==> $@" + "$@" +} + +runprint apk add --quiet git nodejs npm go make + +# TODO: cachear clon de repo +runprint git clone https://codeberg.org/forgejo/forgejo --branch '${FORGEJO_VERSION}' --depth 1 --single-branch +cd forgejo + +runprint env GOOS=linux GOARCH=amd64 LDFLAGS="-linkmode external -extldflags '-static' $LDFLAGS" TAGS="bindata sqlite sqlite_unlock_notify" make build +mv gitea /forgejo +` + ); + await chmod(buildScript, 0o700); + } + await reproRun({ + cwd: dir, + command: "/src/build", + cache: [ + "/home/repro/.cache/go-build", + "/home/repro/go", + "/home/repro/.npm", + ], + }); + await writeFile(versionFile, FORGEJO_VERSION); + + return output; +} diff --git a/forgejo/index.ts b/forgejo/index.ts new file mode 100644 index 0000000..09f2404 --- /dev/null +++ b/forgejo/index.ts @@ -0,0 +1,171 @@ +import { buildForgejo } from "./build.js"; +import { Alpine } from "../alpine.js"; +import { Runit } from "../runit/index.js"; +import { constants, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { loadForgejoSecretsFile } from "./secrets.js"; +import { sudoChmod, sudoChown, sudoCopy } from "../helpers/sudo.js"; + +export async function setupForgejo(alpine: Alpine, runit: Runit) { + const bin = await buildForgejo(); + await sudoCopy(bin, join(alpine.dir, "/usr/local/bin/forgejo")); + + await alpine.addPackages(["tzdata", "git"]); + const entry = await alpine.userAdd("_forgejo"); + + // TODO: persistir + await alpine.fstab.addTmpfs("/var/lib/forgejo", { + uid: entry.uid, + mode: "700", + }); + + const secrets = await loadForgejoSecretsFile(); + const configPath = join(alpine.dir, "/etc/forgejo.conf"); + await writeFile( + configPath, + ` +; see https://docs.gitea.io/en-us/config-cheat-sheet/ for additional documentation. + +APP_NAME = cat /dev/null +RUN_USER = _forgejo +RUN_MODE = prod + +[server] +PROTOCOL = http +DOMAIN = gitea.nulo.in +ROOT_URL = https://gitea.nulo.in/ +HTTP_ADDR = 127.0.0.1 +HTTP_PORT = 3000 +UNIX_SOCKET_PERMISSION = 660 + +DISABLE_SSH = false +START_SSH_SERVER = false +SSH_PORT = 993 +;; Enable exposure of SSH clone URL to anonymous visitors, default is false +SSH_EXPOSE_ANONYMOUS = true +OFFLINE_MODE = true +DISABLE_ROUTER_LOG = false +STATIC_ROOT_PATH = /var/lib/forgejo +APP_DATA_PATH = /var/lib/forgejo/data +ENABLE_GZIP = true +LANDING_PAGE = explore + +LFS_START_SERVER = true +LFS_JWT_SECRET = ${secrets.LFS_JWT_SECRET} + +;; Doesn't work, setup under nginx +;[cors] +;ENABLED = true +;ALLOW_DOMAIN = * +;ALLOW_SUBDOMAIN = false +;METHODS = GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS +;MAX_AGE = 10m +;ALLOW_CREDENTIALS = false +;X_FRAME_OPTIONS = SAMEORIGIN + +[log] +LEVEL = Warn + +[database] +DB_TYPE = sqlite3 +PATH = data/forgejo.db + +[security] +INSTALL_LOCK = true +SECRET_KEY = ${secrets.SECRET_KEY} +INTERNAL_TOKEN = ${secrets.INTERNAL_TOKEN} +PASSWORD_HASH_ALGO = argon2 + +[oauth2] +ENABLE = true +JWT_SECRET = ${secrets.OAUTH_JWT_SECRET} +JWT_SIGNING_ALGORITHM = HS512 + +[service] +REGISTER_EMAIL_CONFIRM = true +DISABLE_REGISTRATION = true +;; Mail notification +ENABLE_NOTIFY_MAIL = true +DEFAULT_KEEP_EMAIL_PRIVATE = true + +[repository] +ROOT=/var/lib/gitea/data/gitea-repositories +;PREFERRED_LICENSES = Apache License 2.0,MIT License +DEFAULT_BRANCH = antifascista +ENABLE_PUSH_CREATE_USER = true +ENABLE_PUSH_CREATE_ORG = true + +[repository.pull-request] +WORK_IN_PROGRESS_PREFIXES = WIP:,[WIP],Draft +CLOSE_KEYWORDS = close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved,cierra +REOPEN_KEYWORDS = reopen,reopens,reopened,reabre + +[project] +PROJECT_BOARD_BASIC_KANBAN_TYPE = Hacer, Haciendo, Hecho +PROJECT_BOARD_BUG_TRIAGE_TYPE = Needs Triage, Prioridad: Alta, Prioridad: Baja, Cerrado + +[ui] +REACTIONS = +1, -1, laugh, hooray, confused, heart, rocket, eyes, oh_no +CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs, oh_no + +DEFAULT_SHOW_FULL_NAME = true + +[ui.meta] +AUTHOR = Nulo +DESCRIPTION = ¡Acá hacemos software, y más! +KEYWORDS = go,git,self-hosted,forgejo + +[mailer] +ENABLED = true +;; Prefix displayed before subject in mail +;SUBJECT_PREFIX = +;; +;; As per RFC 8314 using Implicit TLS/SMTPS on port 465 (if supported) is recommended, +;; otherwise STARTTLS on port 587 should be used. +SMTP_ADDR = mail.riseup.net +SMTP_PORT = 465 +;; +;; Mail from address, RFC 5322. This can be just an email address, or the '"Name" ' format +FROM = Forgejo +USER = catdevnull +PASSWD = ${secrets.EMAIL_PASSWORD} +PROTOCOL = smtps + +[session] +;; 7 días +SESSION_LIFE_TIME = 604800 + +[time] +DEFAULT_UI_LOCATION = America/Argentina/Buenos_Aires + +[webhook] +ALLOWED_HOST_LIST=external,loopback + +[indexer] +REPO_INDEXER_ENABLED=true +REPO_INDEXER_EXCLUDE=**.mp4,**.jpg + `, + { mode: 0o600 } + ); + await sudoChown(configPath, `${entry.uid}:${entry.gid}`); + await runit.addService( + "forgejo", + `#!/bin/sh + +# USER and HOME are needed because forgejo doesn't actually check the user it +# runs as, but instead just grabs the variables from the variables. +export USER=_forgejo +export HOME=/var/lib/forgejo + +umask 0027 + +# forgejo needs to run from its home for SSH to work properly +# TODO: check if this does anything +export FORGEJO_WORK_DIR="$HOME" + +cd "$HOME" + +exec chpst -u $USER:$USER /usr/local/bin/forgejo web --config /etc/forgejo.conf 2>&1 +` + ); +} diff --git a/forgejo/secrets.test.ts b/forgejo/secrets.test.ts new file mode 100644 index 0000000..1e51c3b --- /dev/null +++ b/forgejo/secrets.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert"; +import test from "node:test"; +import { generateForgejoSecrets } from "./secrets.js"; + +function somewhatValidJwt(input: string): void { + assert.equal(input.startsWith("ey"), true); + assert.equal(input.match(/\./g)?.length, 2); + assert.equal(input.match("\n"), null); +} + +test("can generate secrets", async () => { + const secrets = await generateForgejoSecrets(); + somewhatValidJwt(secrets.INTERNAL_TOKEN); + // https://github.com/go-gitea/gitea/blob/e81ccc406bf723a5a58d685e7782f281736affd4/modules/generate/generate.go#L43 + assert.equal(Buffer.from(secrets.LFS_JWT_SECRET, "base64").byteLength, 32); + assert.equal(Buffer.from(secrets.OAUTH_JWT_SECRET, "base64").byteLength, 32); + // https://github.com/go-gitea/gitea/blob/e81ccc406bf723a5a58d685e7782f281736affd4/modules/generate/generate.go#L62 + assert.equal(secrets.SECRET_KEY.length, 64); +}); diff --git a/forgejo/secrets.ts b/forgejo/secrets.ts new file mode 100644 index 0000000..f55b9dc --- /dev/null +++ b/forgejo/secrets.ts @@ -0,0 +1,44 @@ +import { execFile } from "../helpers/better-api.js"; +import { generateSecretsFile, loadSecretsFile } from "../helpers/secrets.js"; +import { buildForgejo } from "./build.js"; + +export interface ForgejoSecrets { + SECRET_KEY: string; + INTERNAL_TOKEN: string; + LFS_JWT_SECRET: string; + OAUTH_JWT_SECRET: string; + EMAIL_PASSWORD: string; +} + +export const loadForgejoSecretsFile = + loadSecretsFile("forgejo"); +export const generateForgejoSecretsFile = generateSecretsFile( + "forgejo", + generateForgejoSecrets +); +export async function generateForgejoSecrets(): Promise { + const bin = await buildForgejo(); + console.info( + "Reemplaza la contraseña de mail en secrets/forgejo.json, ¡porfa!" + ); + return { + ...Object.fromEntries( + await Promise.all([ + ...["SECRET_KEY", "INTERNAL_TOKEN", "LFS_JWT_SECRET"].map( + async (kind) => [kind, await genSecret(bin, kind as any)] + ), + genSecret(bin, "JWT_SECRET").then((s) => ["OAUTH_JWT_SECRET", s]), + ]) + ), + EMAIL_PASSWORD: "REEMPLAZAR POR CONTRASEÑA", + }; +} + +async function genSecret( + bin: string, + kind: "INTERNAL_TOKEN" | "JWT_SECRET" | "LFS_JWT_SECRET" | "SECRET_KEY" +): Promise { + // XXX: crosscompilation? + const { stdout } = await execFile(bin, ["generate", "secret", kind]); + return stdout; +} diff --git a/fstab.ts b/fstab.ts new file mode 100644 index 0000000..9eb2531 --- /dev/null +++ b/fstab.ts @@ -0,0 +1,40 @@ +import { Alpine } from "./alpine.js"; +import { sudoChmod, sudoMkdirP, sudoWriteFile } from "./helpers/sudo.js"; + +export class Fstab { + private alpine: Alpine; + constructor(alpine: Alpine) { + this.alpine = alpine; + } + + private mounts: string[] = ["tmpfs /tmp tmpfs defaults 0 0"]; + async addMount(mount: string) { + this.mounts.push(mount); + await this.write(); + } + async addTmpfs(path: string, opts: TmpfsOptions = {}) { + const add = Object.entries(opts) + .map(([key, val]) => `,${key}=${val}`) + .join(""); + await this.addMount(`tmpfs ${path} tmpfs defaults,noexec,nosuid${add} 0 0`); + await sudoMkdirP(this.alpine.path(path)); + } + + // Writes fstab to disk. + // Intended for internal use only, use addMount or addTmpfs instead + async write() { + const path = this.alpine.path("/etc/fstab"); + await sudoWriteFile( + path, + this.mounts.join("\n") + + // Busybox mount no entiende la última línea sino + "\n" + ); + await sudoChmod(path, "600"); + } +} +interface TmpfsOptions { + uid?: number; + gid?: number; + mode?: string; +} diff --git a/helpers/passwd.ts b/helpers/passwd.ts new file mode 100644 index 0000000..c4e927f --- /dev/null +++ b/helpers/passwd.ts @@ -0,0 +1,37 @@ +import { sudoReadFile } from "./sudo.js"; + +export interface PasswdEntry { + name: string; + password: string; + uid: number; + gid: number; + gecos: string; + directory: string; + shell: string; +} +export function parsePasswd(content: string): PasswdEntry[] { + const lines = content.split("\n"); + return lines + .filter((line) => line.length > 0) + .map((line, index) => { + const values = line.split(":"); + if (values.length !== 7) + throw new Error( + `La línea ${index + 1} no tiene la cantidad correcta de partes` + ); + const entry: PasswdEntry = { + name: values[0], + password: values[1], + uid: parseInt(values[2]), + gid: parseInt(values[3]), + gecos: values[4], + directory: values[5], + shell: values[6], + }; + return entry; + }); +} +export async function sudoReadPasswd(path: string): Promise { + const content = await sudoReadFile(path); + return parsePasswd(content); +} diff --git a/helpers/repro-run.ts b/helpers/repro-run.ts new file mode 100644 index 0000000..797a732 --- /dev/null +++ b/helpers/repro-run.ts @@ -0,0 +1,23 @@ +import { Writable } from "node:stream"; +import { execFile } from "./better-api.js"; + +export async function reproRun(opts: { + // cwd stores the code available inside the container as /src, and also + // cache/ and rootfs/ + cwd: string; + command: string; + cache: string[]; +}): Promise { + const run = execFile("repro-run", { cwd: opts.cwd }); + if (!run.child.stdin) throw false; + run.child.stdin.write( + JSON.stringify({ + Command: opts.command, + Cache: opts.cache, + }) + ); + run.child.stdin.end(); + run.child.stdout?.pipe(process.stdout); + run.child.stderr?.pipe(process.stderr); + await run; +} diff --git a/helpers/secrets.ts b/helpers/secrets.ts new file mode 100644 index 0000000..32d5916 --- /dev/null +++ b/helpers/secrets.ts @@ -0,0 +1,22 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const secretsFileName = (name: string) => join("secrets", name + ".json"); +export function loadSecretsFile(name: string): () => Promise { + return async () => { + const file = await readFile(secretsFileName(name), "utf-8"); + return JSON.parse(file); + }; +} +export function generateSecretsFile( + name: string, + generate: () => Promise +): () => Promise { + return async () => { + const secrets = await generate(); + await mkdir("secrets", { recursive: true }); + await writeFile(secretsFileName(name), JSON.stringify(secrets, null, 2), { + flag: "wx", + }); + }; +} diff --git a/helpers/sudo.ts b/helpers/sudo.ts index 48b38c3..1447313 100644 --- a/helpers/sudo.ts +++ b/helpers/sudo.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; import { getuid } from "node:process"; import { execFile } from "./better-api.js"; @@ -9,6 +12,42 @@ export async function sudoChownToRunningUser(path: string): Promise { await sudoChown(path, "" + getuid()); } else throw new Error("No tengo getuid"); } +export async function sudoChmod(path: string, mod: string): Promise { + await execFile("sudo", ["chmod", mod, path]); +} export async function sudoRm(path: string): Promise { await execFile("sudo", ["rm", path]); } +export async function sudoMkdirP(path: string): Promise { + await execFile("sudo", ["mkdir", "-p", path]); +} +export async function sudoCopy(input: string, target: string): Promise { + await execFile("sudo", ["cp", "--reflink=auto", input, target]); +} +export async function sudoReadFile(path: string): Promise { + const { stdout } = await execFile("sudo", ["cat", path]); + return stdout; +} +export async function sudoWriteFile( + filePath: string, + content: string +): Promise { + const dir = await mkdtemp( + join(tmpdir(), "define-alpine-sudoWriteExecutable-") + ); + try { + const tmpFile = join(dir, basename(filePath)); + await writeFile(tmpFile, content); + await sudoMkdirP(dirname(filePath)); + await execFile("sudo", ["mv", tmpFile, filePath]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} +export async function sudoWriteExecutable( + filePath: string, + content: string +): Promise { + await sudoWriteFile(filePath, content); + await sudoChmod(filePath, "700"); +} diff --git a/index.ts b/index.ts index 638b813..de8c474 100644 --- a/index.ts +++ b/index.ts @@ -1,14 +1,21 @@ import { mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { cwd } from "node:process"; +import { cwd, exit } from "node:process"; import { Alpine } from "./alpine.js"; +import { setupForgejo } from "./forgejo/index.js"; +import { generateForgejoSecretsFile } from "./forgejo/secrets.js"; import { execFile } from "./helpers/better-api.js"; import { sudoChownToRunningUser } from "./helpers/sudo.js"; import { setupKernel } from "./kernel.js"; import { runQemu } from "./qemu.js"; import { Runit } from "./runit/index.js"; +if (process.argv[2] === "generate-secrets") { + await generateForgejoSecretsFile(); + exit(0); +} + { console.time("Building"); @@ -20,7 +27,10 @@ import { Runit } from "./runit/index.js"; const rootfsDir = await mkdtemp(path.join(tmpdir(), "define-alpine-")); console.debug(rootfsDir); const alpine = await Alpine.makeWorld({ dir: rootfsDir }); + + await alpine.addPackages(["helix", "iproute2-ss", "socat"]); const runit = await Runit.setup(alpine); + await setupForgejo(alpine, runit); const kernel = await setupKernel(alpine, kernelDir); const squashfs = path.join(artifactsDir, "image.squashfs"); diff --git a/package.json b/package.json index 5627ab2..8c482bf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "description": "", "main": "index.js", "scripts": { - "run": "esbuild --log-level=warning --target=node18 --sourcemap --outdir=build-javascript --outbase=. *.ts **/*.ts && node --enable-source-maps build-javascript/index.js", + "build": "esbuild --log-level=warning --target=node18 --sourcemap --outdir=build-javascript --outbase=. *.ts **/*.ts", + "run": "pnpm build && node --enable-source-maps build-javascript/index.js", + "test": "pnpm build && node --enable-source-maps build-javascript/**/*.test.js", "tsc:check": "tsc --noEmit" }, "keywords": [],