mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-09-13 02:14:32 +02:00
feat: implement landlock
This commit is contained in:
@@ -50,3 +50,4 @@ package-lock.json
|
||||
/Bruno
|
||||
/tsconfig.tsbuildinfo
|
||||
/public/generated.css
|
||||
/bin
|
||||
|
||||
+8
-4
@@ -12,9 +12,9 @@ RUN apt-get update && apt-get install -y \
|
||||
# if architecture is arm64, use the arm64 version of bun
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "aarch64" ]; then \
|
||||
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
|
||||
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
|
||||
else \
|
||||
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
|
||||
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
|
||||
fi
|
||||
|
||||
RUN unzip -j bun-linux-*.zip -d /usr/local/bin && \
|
||||
@@ -35,6 +35,7 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS prerelease
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y gcc --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -52,6 +53,7 @@ RUN apt-get update && apt-get install -y \
|
||||
dcraw \
|
||||
dvisvgm \
|
||||
ffmpeg \
|
||||
fonts-liberation \
|
||||
ghostscript \
|
||||
graphicsmagick \
|
||||
imagemagick-7.q16 \
|
||||
@@ -90,9 +92,9 @@ ENV PATH="/root/.local/bin:${PATH}"
|
||||
# Install VTracer binary
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "aarch64" ]; then \
|
||||
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
|
||||
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
|
||||
else \
|
||||
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
|
||||
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
|
||||
fi && \
|
||||
curl -L -o /tmp/vtracer.tar.gz "https://github.com/visioncortex/vtracer/releases/download/0.6.4/${VTRACER_ASSET}" && \
|
||||
tar -xzf /tmp/vtracer.tar.gz -C /tmp/ && \
|
||||
@@ -103,6 +105,7 @@ RUN ARCH=$(uname -m) && \
|
||||
COPY --from=install /temp/prod/node_modules node_modules
|
||||
COPY --from=prerelease /app/public/ /app/public/
|
||||
COPY --from=prerelease /app/dist /app/dist
|
||||
COPY --from=prerelease /app/bin/landlock-runner /usr/local/bin/landlock-runner
|
||||
|
||||
# COPY . .
|
||||
RUN mkdir data
|
||||
@@ -110,5 +113,6 @@ RUN mkdir data
|
||||
EXPOSE 3000/tcp
|
||||
# used for calibre
|
||||
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
|
||||
ENV SANDBOX_STRICT="true"
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"heif-info",
|
||||
"potrace",
|
||||
"soffice",
|
||||
"perl"
|
||||
"perl",
|
||||
"gcc"
|
||||
],
|
||||
"tailwind": {
|
||||
"entry": ["src/main.css"]
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"format:eslint": "eslint --fix .",
|
||||
"format:prettier": "prettier --write .",
|
||||
"build:js": "tsc",
|
||||
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:js",
|
||||
"build:runner": "mkdir -p bin && gcc -O2 -Wall -Wextra src/native/landlock-runner.c -o bin/landlock-runner || true",
|
||||
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:runner && bun run build:js",
|
||||
"lint": "npm-run-all 'lint:*'",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:knip": "knip",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -118,7 +117,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -65,7 +64,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("ebook-convert", [filePath, targetPath], (error, stdout, stderr) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "fs";
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -21,7 +20,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
const args = buildDaselArgs(filePath, fileType, convertTo);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
const inputArgs: string[] = [];
|
||||
if (fileType === "eps") {
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import {
|
||||
execFile as execFileOriginal,
|
||||
type ChildProcess,
|
||||
type ExecFileOptions,
|
||||
} from "node:child_process";
|
||||
|
||||
// ffmpeg streams continuous progress to stderr, so a long conversion overflows
|
||||
// execFile's 1 MB default maxBuffer and fails with "stderr maxBuffer length
|
||||
// exceeded" (issue #565). Raise it well above that. The options object must be
|
||||
// passed before the callback: execFile ignores an options argument placed after
|
||||
// the callback, which is why the shared ExecFileFn type cannot carry it.
|
||||
// the callback.
|
||||
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB
|
||||
|
||||
type FfmpegExecFile = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => ChildProcess | void;
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
|
||||
export const properties = {
|
||||
@@ -716,7 +705,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: FfmpegExecFile = execFileOriginal as FfmpegExecFile, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
let extraArgs: string[] = [];
|
||||
let message = "Done";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -315,7 +314,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
// Apply EXIF orientation so photos (e.g. from phones) don't end up sideways
|
||||
// when converted to formats where the orientation tag is lost or ignored
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
// declare possible conversions
|
||||
export const properties = {
|
||||
@@ -447,7 +446,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
let outputArgs: string[] = [];
|
||||
let inputArgs: string[] = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -34,7 +33,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("inkscape", [filePath, "-o", targetPath], (error, stdout, stderr) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("heif-convert", [filePath, targetPath], (error, stdout, stderr) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
// declare possible conversions
|
||||
export const properties = {
|
||||
@@ -19,7 +18,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
let tool = "";
|
||||
if (fileType === "jxl") {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -162,7 +161,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
|
||||
|
||||
|
||||
+74
-32
@@ -1,7 +1,10 @@
|
||||
import { Cookie } from "elysia";
|
||||
import { rmSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import db from "../db/db";
|
||||
import { MAX_CONVERT_PROCESS } from "../helpers/env";
|
||||
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
|
||||
import { createSandboxedExec } from "../helpers/sandbox";
|
||||
import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
|
||||
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
|
||||
import { convert as convertDasel, properties as propertiesDasel } from "./dasel";
|
||||
@@ -16,15 +19,16 @@ import { convert as convertInkscape, properties as propertiesInkscape } from "./
|
||||
import { convert as convertLibheif, properties as propertiesLibheif } from "./libheif";
|
||||
import { convert as convertLibjxl, properties as propertiesLibjxl } from "./libjxl";
|
||||
import { convert as convertLibreOffice, properties as propertiesLibreOffice } from "./libreoffice";
|
||||
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
||||
import { convert as convertMsgconvert, properties as propertiesMsgconvert } from "./msgconvert";
|
||||
import { convert as convertPandoc, properties as propertiesPandoc } from "./pandoc";
|
||||
import { convert as convertPotrace, properties as propertiesPotrace } from "./potrace";
|
||||
import { convert as convertresvg, properties as propertiesresvg } from "./resvg";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
|
||||
import { convert as convertImage, properties as propertiesImage } from "./vips";
|
||||
import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer";
|
||||
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
|
||||
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
|
||||
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
||||
|
||||
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
|
||||
|
||||
@@ -51,8 +55,8 @@ const properties: Record<
|
||||
fileType: string,
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
|
||||
options?: unknown,
|
||||
execFile?: ExecFileFn,
|
||||
) => unknown;
|
||||
}
|
||||
> = {
|
||||
@@ -160,37 +164,67 @@ export async function handleConvert(
|
||||
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
|
||||
);
|
||||
|
||||
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
|
||||
const toProcess: Promise<string>[] = [];
|
||||
for (const fileName of chunk) {
|
||||
const filePath = `${userUploadsDir}${fileName}`;
|
||||
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
|
||||
const fileType = normalizeFiletype(fileTypeOrig);
|
||||
const newFileExt = normalizeOutputFiletype(convertTo);
|
||||
let newFileName: string;
|
||||
if (fileTypeOrig === "") {
|
||||
newFileName = `${fileName}.${newFileExt}`;
|
||||
} else {
|
||||
newFileName = fileName.replace(
|
||||
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
|
||||
newFileExt,
|
||||
const effectiveJobId = jobId.value ?? `convertx_${Date.now()}`;
|
||||
const tempJobDir = `/tmp/convertx_${effectiveJobId}/`;
|
||||
try {
|
||||
await mkdir(tempJobDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error(`Failed to create temp directory ${tempJobDir}:`, err);
|
||||
}
|
||||
|
||||
const sandboxedExec = createSandboxedExec({
|
||||
inputDir: userUploadsDir,
|
||||
outputDir: userOutputDir,
|
||||
tempDir: tempJobDir,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
|
||||
const toProcess: Promise<string>[] = [];
|
||||
for (const fileName of chunk) {
|
||||
const filePath = `${userUploadsDir}${fileName}`;
|
||||
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
|
||||
const fileType = normalizeFiletype(fileTypeOrig);
|
||||
const newFileExt = normalizeOutputFiletype(convertTo);
|
||||
let newFileName: string;
|
||||
if (fileTypeOrig === "") {
|
||||
newFileName = `${fileName}.${newFileExt}`;
|
||||
} else {
|
||||
newFileName = fileName.replace(
|
||||
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
|
||||
newFileExt,
|
||||
);
|
||||
}
|
||||
const targetPath = `${userOutputDir}${newFileName}`;
|
||||
toProcess.push(
|
||||
new Promise((resolve, reject) => {
|
||||
mainConverter(
|
||||
filePath,
|
||||
fileType,
|
||||
convertTo,
|
||||
targetPath,
|
||||
{},
|
||||
converterName,
|
||||
sandboxedExec,
|
||||
)
|
||||
.then((r) => {
|
||||
if (jobId.value) {
|
||||
query.run(jobId.value, fileName, newFileName, r);
|
||||
}
|
||||
resolve(r);
|
||||
})
|
||||
.catch((c) => reject(c));
|
||||
}),
|
||||
);
|
||||
}
|
||||
const targetPath = `${userOutputDir}${newFileName}`;
|
||||
toProcess.push(
|
||||
new Promise((resolve, reject) => {
|
||||
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
|
||||
.then((r) => {
|
||||
if (jobId.value) {
|
||||
query.run(jobId.value, fileName, newFileName, r);
|
||||
}
|
||||
resolve(r);
|
||||
})
|
||||
.catch((c) => reject(c));
|
||||
}),
|
||||
);
|
||||
await Promise.all(toProcess);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tempJobDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup error if temp directory was already removed
|
||||
}
|
||||
await Promise.all(toProcess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +235,7 @@ async function mainConverter(
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
converterName?: string,
|
||||
execFile?: ExecFileFn,
|
||||
) {
|
||||
const fileType = normalizeFiletype(fileTypeOriginal);
|
||||
|
||||
@@ -235,7 +270,14 @@ async function mainConverter(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await converterFunc(inputFilePath, fileType, convertTo, targetPath, options);
|
||||
const result = await converterFunc(
|
||||
inputFilePath,
|
||||
fileType,
|
||||
convertTo,
|
||||
targetPath,
|
||||
options,
|
||||
execFile,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Converted ${inputFilePath} from ${fileType} to ${convertTo} successfully using ${converterName}.`,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("markitdown", [filePath, "-o", targetPath], (err, stdout, stderr) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (fileType === "msg" && convertTo === "eml") {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -126,7 +125,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
// set xelatex here
|
||||
const xelatex = ["pdf", "latex"];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -28,7 +27,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("potrace", [filePath, "-o", targetPath, "-b", convertTo], (error, stdout, stderr) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("resvg", [filePath, targetPath], (error, stdout, stderr) => {
|
||||
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
import type { ChildProcess, ExecFileOptions } from "child_process";
|
||||
import {
|
||||
execFile as execFileOriginal,
|
||||
type ChildProcess,
|
||||
type ExecFileOptions,
|
||||
} from "node:child_process";
|
||||
|
||||
export type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
export type ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
options?: ExecFileOptions,
|
||||
...argsOrOptions:
|
||||
[callback: ExecFileCallback] | [options: ExecFileOptions, callback: ExecFileCallback]
|
||||
) => ChildProcess | void;
|
||||
|
||||
export const defaultExecFile: ExecFileFn = execFileOriginal as unknown as ExecFileFn;
|
||||
|
||||
export type ConvertFnWithExecFile = (
|
||||
filePath: string,
|
||||
fileType: string,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
// declare possible conversions
|
||||
export const properties = {
|
||||
@@ -96,7 +95,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
// if (fileType === "svg") {
|
||||
// const scale = options.scale || 1;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -30,7 +29,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: ExecFileFn = defaultExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Build vtracer arguments
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { defaultExecFile, ExecFileFn } from "./types";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
@@ -16,7 +15,7 @@ export function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
execFile: ExecFileFn = defaultExecFile,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// const fileName: string = (targetPath.split("/").pop() as string).replace(".pdf", "")
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
execFile as execFileOriginal,
|
||||
type ChildProcess,
|
||||
type ExecFileOptions,
|
||||
} from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { ExecFileCallback, ExecFileFn } from "../converters/types";
|
||||
|
||||
export interface SandboxConfig {
|
||||
inputDir: string;
|
||||
outputDir: string;
|
||||
tempDir: string;
|
||||
allowNet?: boolean;
|
||||
}
|
||||
|
||||
let cachedRunnerPath: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Locate the landlock-runner binary if available.
|
||||
*/
|
||||
export function getLandlockRunnerPath(): string | null {
|
||||
if (cachedRunnerPath !== undefined) {
|
||||
return cachedRunnerPath;
|
||||
}
|
||||
|
||||
if (process.env.SANDBOX_DISABLED === "true") {
|
||||
cachedRunnerPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform !== "linux") {
|
||||
cachedRunnerPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.env.LANDLOCK_RUNNER_PATH && existsSync(process.env.LANDLOCK_RUNNER_PATH)) {
|
||||
cachedRunnerPath = process.env.LANDLOCK_RUNNER_PATH;
|
||||
return cachedRunnerPath;
|
||||
}
|
||||
|
||||
const candidatePaths = [
|
||||
"/usr/local/bin/landlock-runner",
|
||||
resolve(process.cwd(), "bin/landlock-runner"),
|
||||
];
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedRunnerPath = candidate;
|
||||
return cachedRunnerPath;
|
||||
}
|
||||
}
|
||||
|
||||
cachedRunnerPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached runner path (primarily for testing).
|
||||
*/
|
||||
export function resetCachedRunnerPath(): void {
|
||||
cachedRunnerPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if Landlock sandboxing is supported and enabled.
|
||||
*/
|
||||
export function isSandboxAvailable(): boolean {
|
||||
return getLandlockRunnerPath() !== null;
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_RO = "/usr:/lib:/lib64:/bin:/sbin:/etc:/proc:/var";
|
||||
|
||||
/**
|
||||
* Create a sandboxed execFile function configured for a specific conversion job.
|
||||
*/
|
||||
export function createSandboxedExec(config: SandboxConfig): ExecFileFn {
|
||||
const runnerPath = getLandlockRunnerPath();
|
||||
const isStrict = process.env.SANDBOX_STRICT === "true";
|
||||
|
||||
const resolvedInput = resolve(config.inputDir);
|
||||
const resolvedOutput = resolve(config.outputDir);
|
||||
const resolvedTemp = resolve(config.tempDir);
|
||||
|
||||
const sandboxedExec = ((
|
||||
cmd: string,
|
||||
args: string[],
|
||||
optionsOrCallback?: ExecFileOptions | ExecFileCallback,
|
||||
callbackOrOptions?: ExecFileOptions | ExecFileCallback,
|
||||
): ChildProcess | void => {
|
||||
let options: ExecFileOptions = {};
|
||||
let callback: ExecFileCallback | undefined;
|
||||
|
||||
if (typeof optionsOrCallback === "function") {
|
||||
callback = optionsOrCallback;
|
||||
if (typeof callbackOrOptions === "object" && callbackOrOptions !== null) {
|
||||
options = callbackOrOptions;
|
||||
}
|
||||
} else {
|
||||
if (typeof optionsOrCallback === "object" && optionsOrCallback !== null) {
|
||||
options = optionsOrCallback;
|
||||
}
|
||||
if (typeof callbackOrOptions === "function") {
|
||||
callback = callbackOrOptions;
|
||||
}
|
||||
}
|
||||
|
||||
const cb: ExecFileCallback = callback ?? (() => {});
|
||||
|
||||
if (!runnerPath) {
|
||||
if (isStrict) {
|
||||
const error = new Error(
|
||||
"SANDBOX_STRICT is enabled, but landlock-runner is not available on this system.",
|
||||
);
|
||||
cb(error, "", error.message);
|
||||
return;
|
||||
}
|
||||
return (
|
||||
execFileOriginal as (
|
||||
c: string,
|
||||
a: string[],
|
||||
o: ExecFileOptions,
|
||||
f: ExecFileCallback,
|
||||
) => ChildProcess
|
||||
)(cmd, args, options, cb);
|
||||
}
|
||||
|
||||
// Build landlock-runner arguments
|
||||
const runnerArgs: string[] = [
|
||||
"--ro",
|
||||
DEFAULT_SYSTEM_RO,
|
||||
"--ro",
|
||||
resolvedInput,
|
||||
"--rw",
|
||||
resolvedOutput,
|
||||
"--rw",
|
||||
`${resolvedTemp}:/dev`,
|
||||
];
|
||||
|
||||
if (!config.allowNet) {
|
||||
runnerArgs.push("--no-net");
|
||||
}
|
||||
|
||||
if (isStrict) {
|
||||
runnerArgs.push("--strict");
|
||||
}
|
||||
|
||||
runnerArgs.push("--", cmd, ...args);
|
||||
|
||||
// Provide isolated per-job TMPDIR and HOME
|
||||
const sandboxedEnv = {
|
||||
...process.env,
|
||||
...options.env,
|
||||
TMPDIR: resolvedTemp,
|
||||
TEMP: resolvedTemp,
|
||||
TMP: resolvedTemp,
|
||||
HOME: resolvedTemp,
|
||||
};
|
||||
|
||||
const sandboxedOptions: ExecFileOptions = {
|
||||
...options,
|
||||
env: sandboxedEnv,
|
||||
};
|
||||
|
||||
return (
|
||||
execFileOriginal as (
|
||||
c: string,
|
||||
a: string[],
|
||||
o: ExecFileOptions,
|
||||
f: ExecFileCallback,
|
||||
) => ChildProcess
|
||||
)(runnerPath, runnerArgs, sandboxedOptions, cb);
|
||||
}) as unknown as ExecFileFn;
|
||||
|
||||
return sandboxedExec;
|
||||
}
|
||||
+12
-3
@@ -1,23 +1,24 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { html } from "@elysiajs/html";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { Elysia } from "elysia";
|
||||
import "./helpers/printVersions";
|
||||
import { rmSync } from "node:fs";
|
||||
import db from "./db/db";
|
||||
import { Jobs } from "./db/types";
|
||||
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
|
||||
import "./helpers/printVersions";
|
||||
import { getLandlockRunnerPath, isSandboxAvailable } from "./helpers/sandbox";
|
||||
import { chooseConverter } from "./pages/chooseConverter";
|
||||
import { convert } from "./pages/convert";
|
||||
import { deleteFile } from "./pages/deleteFile";
|
||||
import { deleteJob } from "./pages/deleteJob";
|
||||
import { download } from "./pages/download";
|
||||
import { healthcheck } from "./pages/healthcheck";
|
||||
import { history } from "./pages/history";
|
||||
import { listConverters } from "./pages/listConverters";
|
||||
import { results } from "./pages/results";
|
||||
import { root } from "./pages/root";
|
||||
import { upload } from "./pages/upload";
|
||||
import { user } from "./pages/user";
|
||||
import { healthcheck } from "./pages/healthcheck";
|
||||
|
||||
export const uploadsDir = "./data/uploads/";
|
||||
export const outputDir = "./data/output/";
|
||||
@@ -73,6 +74,14 @@ app.listen(process.env.PORT || 3000);
|
||||
|
||||
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
|
||||
|
||||
if (isSandboxAvailable()) {
|
||||
console.log(`🔒 Landlock sandbox: ENABLED (${getLandlockRunnerPath()})`);
|
||||
} else if (process.env.SANDBOX_STRICT === "true") {
|
||||
console.warn("⚠️ Landlock sandbox: STRICT mode enabled but runner unavailable!");
|
||||
} else {
|
||||
console.log("ℹ️ Landlock sandbox: NOT AVAILABLE (running unsandboxed)");
|
||||
}
|
||||
|
||||
const clearJobs = () => {
|
||||
const jobs = db
|
||||
.query("SELECT * FROM jobs WHERE date_created < ?")
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <linux/landlock.h>
|
||||
#include <linux/prctl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifndef landlock_create_ruleset
|
||||
static inline int landlock_create_ruleset(
|
||||
const struct landlock_ruleset_attr *const attr,
|
||||
const size_t size, const __u32 flags) {
|
||||
return syscall(__NR_landlock_create_ruleset, attr, size, flags);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef landlock_add_rule
|
||||
static inline int landlock_add_rule(
|
||||
const int ruleset_fd, const enum landlock_rule_type rule_type,
|
||||
const void *const rule_attr, const __u32 flags) {
|
||||
return syscall(__NR_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef landlock_restrict_self
|
||||
static inline int landlock_restrict_self(
|
||||
const int ruleset_fd, const __u32 flags) {
|
||||
return syscall(__NR_landlock_restrict_self, ruleset_fd, flags);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef LANDLOCK_RULE_PATH_BENEATH
|
||||
#define LANDLOCK_RULE_PATH_BENEATH 1
|
||||
#endif
|
||||
|
||||
#ifndef LANDLOCK_ACCESS_FS_EXECUTE
|
||||
#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0)
|
||||
#define LANDLOCK_ACCESS_FS_WRITE_FILE (1ULL << 1)
|
||||
#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2)
|
||||
#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3)
|
||||
#define LANDLOCK_ACCESS_FS_REMOVE_DIR (1ULL << 4)
|
||||
#define LANDLOCK_ACCESS_FS_REMOVE_FILE (1ULL << 5)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_BLOCK (1ULL << 11)
|
||||
#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12)
|
||||
#endif
|
||||
|
||||
#ifndef LANDLOCK_ACCESS_FS_REFER
|
||||
#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13)
|
||||
#endif
|
||||
#ifndef LANDLOCK_ACCESS_FS_TRUNCATE
|
||||
#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14)
|
||||
#endif
|
||||
#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV
|
||||
#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15)
|
||||
#endif
|
||||
|
||||
#ifndef LANDLOCK_ACCESS_NET_BIND_TCP
|
||||
#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0)
|
||||
#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
|
||||
#endif
|
||||
|
||||
struct path_node {
|
||||
char *path;
|
||||
struct path_node *next;
|
||||
};
|
||||
|
||||
static void add_node(struct path_node **head, const char *path) {
|
||||
if (!path || path[0] == '\0') return;
|
||||
struct path_node *node = malloc(sizeof(struct path_node));
|
||||
if (!node) return;
|
||||
node->path = strdup(path);
|
||||
node->next = *head;
|
||||
*head = node;
|
||||
}
|
||||
|
||||
static void add_colon_separated(struct path_node **head, const char *path_list) {
|
||||
if (!path_list || path_list[0] == '\0') return;
|
||||
char *copy = strdup(path_list);
|
||||
if (!copy) return;
|
||||
char *saveptr = NULL;
|
||||
char *token = strtok_r(copy, ":", &saveptr);
|
||||
while (token != NULL) {
|
||||
if (strlen(token) > 0) {
|
||||
add_node(head, token);
|
||||
}
|
||||
token = strtok_r(NULL, ":", &saveptr);
|
||||
}
|
||||
free(copy);
|
||||
}
|
||||
|
||||
static void free_nodes(struct path_node *head) {
|
||||
while (head) {
|
||||
struct path_node *next = head->next;
|
||||
free(head->path);
|
||||
free(head);
|
||||
head = next;
|
||||
}
|
||||
}
|
||||
|
||||
static void add_path_rule(int ruleset_fd, const char *path, __u64 allowed_access, int abi) {
|
||||
if (!path || path[0] == '\0') return;
|
||||
|
||||
int fd = open(path, O_PATH | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
// Path does not exist or cannot be opened, ignore non-fatal missing paths
|
||||
return;
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) {
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
|
||||
__u64 access = allowed_access;
|
||||
if (!S_ISDIR(st.st_mode)) {
|
||||
// Character devices, regular files, sockets, etc. cannot take directory-only access flags
|
||||
__u64 file_mask = LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
LANDLOCK_ACCESS_FS_WRITE_FILE;
|
||||
if (abi >= 2) file_mask |= LANDLOCK_ACCESS_FS_REFER;
|
||||
if (abi >= 3) file_mask |= LANDLOCK_ACCESS_FS_TRUNCATE;
|
||||
if (abi >= 5) file_mask |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
|
||||
access &= file_mask;
|
||||
}
|
||||
|
||||
struct landlock_path_beneath_attr path_beneath = {
|
||||
.parent_fd = fd,
|
||||
.allowed_access = access,
|
||||
};
|
||||
|
||||
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static void print_usage(const char *prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [options] -- <command> [args...]\n\n"
|
||||
"Options:\n"
|
||||
" --ro <paths> Colon-separated list of read-only paths (can be repeated)\n"
|
||||
" --rw <paths> Colon-separated list of read-write paths (can be repeated)\n"
|
||||
" --no-net Disable all network operations (TCP connect & bind)\n"
|
||||
" --strict Fail if Landlock is unsupported instead of falling back\n"
|
||||
" --version Print Landlock ABI version and exit\n"
|
||||
" --help Print this help message\n",
|
||||
prog);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
struct path_node *ro_head = NULL;
|
||||
struct path_node *rw_head = NULL;
|
||||
bool no_net = false;
|
||||
bool strict = false;
|
||||
int cmd_idx = -1;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--") == 0) {
|
||||
cmd_idx = i + 1;
|
||||
break;
|
||||
} else if (strcmp(argv[i], "--version") == 0) {
|
||||
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
|
||||
if (abi < 0) {
|
||||
printf("Landlock unsupported (%s)\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
printf("Landlock ABI v%d\n", abi);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "--ro") == 0) {
|
||||
if (++i >= argc) {
|
||||
fprintf(stderr, "landlock-runner: --ro requires an argument\n");
|
||||
return 1;
|
||||
}
|
||||
add_colon_separated(&ro_head, argv[i]);
|
||||
} else if (strcmp(argv[i], "--rw") == 0) {
|
||||
if (++i >= argc) {
|
||||
fprintf(stderr, "landlock-runner: --rw requires an argument\n");
|
||||
return 1;
|
||||
}
|
||||
add_colon_separated(&rw_head, argv[i]);
|
||||
} else if (strcmp(argv[i], "--no-net") == 0) {
|
||||
no_net = true;
|
||||
} else if (strcmp(argv[i], "--strict") == 0) {
|
||||
strict = true;
|
||||
} else {
|
||||
fprintf(stderr, "landlock-runner: unrecognized option '%s'\n", argv[i]);
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd_idx < 0 || cmd_idx >= argc) {
|
||||
fprintf(stderr, "landlock-runner: no command specified after '--'\n");
|
||||
free_nodes(ro_head);
|
||||
free_nodes(rw_head);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Query Landlock ABI version
|
||||
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
|
||||
if (abi < 1) {
|
||||
free_nodes(ro_head);
|
||||
free_nodes(rw_head);
|
||||
if (strict) {
|
||||
fprintf(stderr, "landlock-runner error: Landlock LSM is not supported or disabled on this kernel (errno=%d: %s)\n",
|
||||
errno, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
// Fallback: run command without Landlock
|
||||
execvp(argv[cmd_idx], &argv[cmd_idx]);
|
||||
perror("landlock-runner: execvp");
|
||||
return 127;
|
||||
}
|
||||
|
||||
// Build supported filesystem access rights
|
||||
__u64 fs_ro_access = LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
LANDLOCK_ACCESS_FS_READ_DIR;
|
||||
if (abi >= 2) fs_ro_access |= LANDLOCK_ACCESS_FS_REFER;
|
||||
|
||||
__u64 fs_rw_access = fs_ro_access |
|
||||
LANDLOCK_ACCESS_FS_WRITE_FILE |
|
||||
LANDLOCK_ACCESS_FS_REMOVE_DIR |
|
||||
LANDLOCK_ACCESS_FS_REMOVE_FILE |
|
||||
LANDLOCK_ACCESS_FS_MAKE_CHAR |
|
||||
LANDLOCK_ACCESS_FS_MAKE_DIR |
|
||||
LANDLOCK_ACCESS_FS_MAKE_REG |
|
||||
LANDLOCK_ACCESS_FS_MAKE_SOCK |
|
||||
LANDLOCK_ACCESS_FS_MAKE_FIFO |
|
||||
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
|
||||
LANDLOCK_ACCESS_FS_MAKE_SYM;
|
||||
if (abi >= 3) fs_rw_access |= LANDLOCK_ACCESS_FS_TRUNCATE;
|
||||
if (abi >= 5) fs_rw_access |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
|
||||
|
||||
struct landlock_ruleset_attr attr = {
|
||||
.handled_access_fs = fs_rw_access,
|
||||
};
|
||||
|
||||
if (no_net && abi >= 4) {
|
||||
attr.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP;
|
||||
}
|
||||
|
||||
int ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
|
||||
if (ruleset_fd < 0) {
|
||||
free_nodes(ro_head);
|
||||
free_nodes(rw_head);
|
||||
if (strict) {
|
||||
fprintf(stderr, "landlock-runner error: failed to create ruleset: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
execvp(argv[cmd_idx], &argv[cmd_idx]);
|
||||
perror("landlock-runner: execvp");
|
||||
return 127;
|
||||
}
|
||||
|
||||
// Add read-only paths
|
||||
for (struct path_node *curr = ro_head; curr; curr = curr->next) {
|
||||
add_path_rule(ruleset_fd, curr->path, fs_ro_access, abi);
|
||||
}
|
||||
free_nodes(ro_head);
|
||||
|
||||
// Add read-write paths
|
||||
for (struct path_node *curr = rw_head; curr; curr = curr->next) {
|
||||
add_path_rule(ruleset_fd, curr->path, fs_rw_access, abi);
|
||||
}
|
||||
free_nodes(rw_head);
|
||||
|
||||
// Prevent processes from gaining new privileges
|
||||
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
|
||||
if (strict) {
|
||||
perror("landlock-runner: prctl(PR_SET_NO_NEW_PRIVS)");
|
||||
close(ruleset_fd);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the Landlock sandbox to the current process and all future children
|
||||
if (landlock_restrict_self(ruleset_fd, 0) < 0) {
|
||||
if (strict) {
|
||||
perror("landlock-runner: landlock_restrict_self");
|
||||
close(ruleset_fd);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
close(ruleset_fd);
|
||||
|
||||
// Execute the target program
|
||||
execvp(argv[cmd_idx], &argv[cmd_idx]);
|
||||
perror("landlock-runner: execvp");
|
||||
return 127;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createSandboxedExec,
|
||||
getLandlockRunnerPath,
|
||||
isSandboxAvailable,
|
||||
resetCachedRunnerPath,
|
||||
} from "../../src/helpers/sandbox";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
resetCachedRunnerPath();
|
||||
delete process.env.SANDBOX_DISABLED;
|
||||
delete process.env.SANDBOX_STRICT;
|
||||
delete process.env.LANDLOCK_RUNNER_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCachedRunnerPath();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test("isSandboxAvailable returns false when SANDBOX_DISABLED is true", () => {
|
||||
process.env.SANDBOX_DISABLED = "true";
|
||||
expect(isSandboxAvailable()).toBe(false);
|
||||
expect(getLandlockRunnerPath()).toBeNull();
|
||||
});
|
||||
|
||||
test("getLandlockRunnerPath respects LANDLOCK_RUNNER_PATH if it exists", () => {
|
||||
const runner = getLandlockRunnerPath();
|
||||
if (runner) {
|
||||
process.env.LANDLOCK_RUNNER_PATH = runner;
|
||||
resetCachedRunnerPath();
|
||||
expect(getLandlockRunnerPath()).toBe(runner);
|
||||
}
|
||||
});
|
||||
|
||||
test("createSandboxedExec sets TMPDIR and HOME in environment", async () => {
|
||||
const testDir = join(tmpdir(), `sandbox-test-env-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
const inputDir = join(testDir, "input");
|
||||
const outputDir = join(testDir, "output");
|
||||
const tempDir = join(testDir, "temp");
|
||||
mkdirSync(inputDir);
|
||||
mkdirSync(outputDir);
|
||||
mkdirSync(tempDir);
|
||||
|
||||
const sandboxedExec = createSandboxedExec({
|
||||
inputDir,
|
||||
outputDir,
|
||||
tempDir,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sandboxedExec("/bin/sh", ["-c", "echo $TMPDIR"], (_err, stdout) => {
|
||||
expect(stdout.trim()).toBe(tempDir);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createSandboxedExec handles SANDBOX_STRICT when runner is missing", async () => {
|
||||
process.env.SANDBOX_DISABLED = "true";
|
||||
process.env.SANDBOX_STRICT = "true";
|
||||
|
||||
const sandboxedExec = createSandboxedExec({
|
||||
inputDir: "/tmp/in",
|
||||
outputDir: "/tmp/out",
|
||||
tempDir: "/tmp/temp",
|
||||
});
|
||||
|
||||
let errorCaptured: Error | null = null;
|
||||
sandboxedExec("some-cmd", ["arg"], (err) => {
|
||||
errorCaptured = err;
|
||||
});
|
||||
|
||||
expect(errorCaptured).not.toBeNull();
|
||||
expect(errorCaptured?.message).toContain("SANDBOX_STRICT is enabled");
|
||||
});
|
||||
|
||||
test("createSandboxedExec passes options before callback correctly", async () => {
|
||||
const testDir = join(tmpdir(), `sandbox-test-opts-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
const sandboxedExec = createSandboxedExec({
|
||||
inputDir: testDir,
|
||||
outputDir: testDir,
|
||||
tempDir: testDir,
|
||||
});
|
||||
|
||||
let called = false;
|
||||
sandboxedExec("echo", ["hi"], { maxBuffer: 1024 }, () => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
// Give child process time if real exec was spawned
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(called).toBe(true);
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("landlock-runner enforces real sandboxing when available", async () => {
|
||||
const runnerPath = getLandlockRunnerPath();
|
||||
if (!runnerPath) {
|
||||
// Skip if landlock is not built/available on host
|
||||
return;
|
||||
}
|
||||
|
||||
const testDir = join(tmpdir(), `landlock-real-test-${Date.now()}`);
|
||||
const inputDir = join(testDir, "input");
|
||||
const outputDir = join(testDir, "output");
|
||||
const tempDir = join(testDir, "temp");
|
||||
|
||||
mkdirSync(inputDir, { recursive: true });
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const inputFile = join(inputDir, "sample.txt");
|
||||
const outputFile = join(outputDir, "out.txt");
|
||||
writeFileSync(inputFile, "content from input");
|
||||
|
||||
const sandboxedExec = createSandboxedExec({
|
||||
inputDir,
|
||||
outputDir,
|
||||
tempDir,
|
||||
});
|
||||
|
||||
// 1. Reading from inputDir and writing to outputDir must SUCCEED
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
sandboxedExec("/bin/sh", ["-c", `cat "${inputFile}" > "${outputFile}"`], (err) => {
|
||||
resolve(!err);
|
||||
});
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(existsSync(outputFile)).toBe(true);
|
||||
|
||||
// 2. Writing to unpermitted directory (parent testDir) must FAIL
|
||||
const forbiddenFile = join(testDir, "forbidden.txt");
|
||||
const failure = await new Promise<boolean>((resolve) => {
|
||||
sandboxedExec("/bin/sh", ["-c", `echo "should fail" > "${forbiddenFile}"`], (err) => {
|
||||
resolve(!!err);
|
||||
});
|
||||
});
|
||||
|
||||
expect(failure).toBe(true);
|
||||
expect(existsSync(forbiddenFile)).toBe(false);
|
||||
|
||||
// 3. Network operations must be blocked (Permission denied)
|
||||
const netBlocked = await new Promise<boolean>((resolve) => {
|
||||
sandboxedExec(
|
||||
"/usr/bin/python3",
|
||||
["-c", "import socket; s = socket.socket(); s.connect(('127.0.0.1', 80))"],
|
||||
(err) => {
|
||||
resolve(!!err);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(netBlocked).toBe(true);
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
Reference in New Issue
Block a user