This commit is contained in:
Cat /dev/Nulo 2023-02-08 11:46:33 -03:00
parent 9af663a0c9
commit f981518d88
13 changed files with 491 additions and 25 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules/
build-javascript
cache/
artifacts/
secrets/

View file

@ -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<void> {
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<void> {
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<void> {
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<PasswdEntry> {
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<void> {
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;
}
}

52
forgejo/build.ts Normal file
View file

@ -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<string> {
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;
}

171
forgejo/index.ts Normal file
View file

@ -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" <email@example.com>' format
FROM = Forgejo <giteanuloin@riseup.net>
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
`
);
}

19
forgejo/secrets.test.ts Normal file
View file

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

44
forgejo/secrets.ts Normal file
View file

@ -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<ForgejoSecrets>("forgejo");
export const generateForgejoSecretsFile = generateSecretsFile(
"forgejo",
generateForgejoSecrets
);
export async function generateForgejoSecrets(): Promise<ForgejoSecrets> {
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<string> {
// XXX: crosscompilation?
const { stdout } = await execFile(bin, ["generate", "secret", kind]);
return stdout;
}

40
fstab.ts Normal file
View file

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

37
helpers/passwd.ts Normal file
View file

@ -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<PasswdEntry[]> {
const content = await sudoReadFile(path);
return parsePasswd(content);
}

23
helpers/repro-run.ts Normal file
View file

@ -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<void> {
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;
}

22
helpers/secrets.ts Normal file
View file

@ -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<T>(name: string): () => Promise<T> {
return async () => {
const file = await readFile(secretsFileName(name), "utf-8");
return JSON.parse(file);
};
}
export function generateSecretsFile<T>(
name: string,
generate: () => Promise<T>
): () => Promise<void> {
return async () => {
const secrets = await generate();
await mkdir("secrets", { recursive: true });
await writeFile(secretsFileName(name), JSON.stringify(secrets, null, 2), {
flag: "wx",
});
};
}

View file

@ -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<void> {
await sudoChown(path, "" + getuid());
} else throw new Error("No tengo getuid");
}
export async function sudoChmod(path: string, mod: string): Promise<void> {
await execFile("sudo", ["chmod", mod, path]);
}
export async function sudoRm(path: string): Promise<void> {
await execFile("sudo", ["rm", path]);
}
export async function sudoMkdirP(path: string): Promise<void> {
await execFile("sudo", ["mkdir", "-p", path]);
}
export async function sudoCopy(input: string, target: string): Promise<void> {
await execFile("sudo", ["cp", "--reflink=auto", input, target]);
}
export async function sudoReadFile(path: string): Promise<string> {
const { stdout } = await execFile("sudo", ["cat", path]);
return stdout;
}
export async function sudoWriteFile(
filePath: string,
content: string
): Promise<void> {
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<void> {
await sudoWriteFile(filePath, content);
await sudoChmod(filePath, "700");
}

View file

@ -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");

View file

@ -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": [],