Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -0,0 +1,60 @@
{
"name": "@homarr/core",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
"./infrastructure/redis": "./src/infrastructure/redis/client.ts",
"./infrastructure/env": "./src/infrastructure/env/index.ts",
"./infrastructure/logs": "./src/infrastructure/logs/index.ts",
"./infrastructure/logs/constants": "./src/infrastructure/logs/constants.ts",
"./infrastructure/logs/env": "./src/infrastructure/logs/env.ts",
"./infrastructure/logs/error": "./src/infrastructure/logs/error.ts",
"./infrastructure/db": "./src/infrastructure/db/index.ts",
"./infrastructure/db/env": "./src/infrastructure/db/env.ts",
"./infrastructure/db/constants": "./src/infrastructure/db/constants.ts",
"./infrastructure/certificates": "./src/infrastructure/certificates/index.ts",
"./infrastructure/certificates/hostnames/db/sqlite": "./src/infrastructure/certificates/hostnames/db/sqlite.ts",
"./infrastructure/certificates/hostnames/db/mysql": "./src/infrastructure/certificates/hostnames/db/mysql.ts",
"./infrastructure/certificates/hostnames/db/postgresql": "./src/infrastructure/certificates/hostnames/db/postgresql.ts",
"./infrastructure/dns/init": "./src/infrastructure/dns/init.ts",
"./infrastructure/http": "./src/infrastructure/http/index.ts",
"./infrastructure/http/timeout": "./src/infrastructure/http/timeout.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"better-sqlite3": "^12.5.0",
"dns-caching": "^0.2.9",
"drizzle-orm": "^0.45.1",
"ioredis": "5.8.2",
"mysql2": "3.16.0",
"pg": "^8.16.3",
"superjson": "2.2.6",
"winston": "3.19.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.16.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,74 @@
import { X509Certificate } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { rootCertificates } from "node:tls";
const getCertificateFolder = () => {
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
export const loadCustomRootCertificatesAsync = async () => {
const folder = getCertificateFolder();
if (!folder) {
return [];
}
if (!fsSync.existsSync(folder)) {
await fs.mkdir(folder, { recursive: true });
}
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt") || file.endsWith(".pem"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,
})),
);
};
export const getAllTrustedCertificatesAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
};
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return null;
}
const existingFiles = await fs.readdir(folder, { withFileTypes: true });
if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) {
throw new Error(`File ${fileName} does not exist`);
}
const fullPath = path.join(folder, fileName);
const content = await fs.readFile(fullPath, "utf8");
await fs.rm(fullPath);
try {
return new X509Certificate(content);
} catch {
return null;
}
};
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
const folder = getCertificateFolder();
if (!folder) {
throw new Error(
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
);
}
if (fileName.includes("/")) {
throw new Error("Invalid file name");
}
await fs.writeFile(path.join(folder, fileName), content);
};

View File

@@ -0,0 +1,15 @@
import { mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
export const trustedCertificateHostnames = mysqlTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);

View File

@@ -0,0 +1,15 @@
import { pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
export const trustedCertificateHostnames = pgTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);

View File

@@ -0,0 +1,10 @@
import { createSchema } from "../../../db";
import * as mysql from "./mysql";
import * as postgresql from "./postgresql";
import * as sqlite from "./sqlite";
export const schema = createSchema({
"better-sqlite3": () => sqlite,
mysql2: () => mysql,
"node-postgres": () => postgresql,
});

View File

@@ -0,0 +1,15 @@
import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const trustedCertificateHostnames = sqliteTable(
"trusted_certificate_hostname",
{
hostname: text().notNull(),
thumbprint: text().notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);

View File

@@ -0,0 +1,12 @@
import type { InferSelectModel } from "drizzle-orm";
import { createDb } from "../../db";
import { schema } from "./db/schema";
const db = createDb(schema);
export const getTrustedCertificateHostnamesAsync = async () => {
return await db.query.trustedCertificateHostnames.findMany();
};
export type TrustedCertificateHostname = InferSelectModel<typeof schema.trustedCertificateHostnames>;

View File

@@ -0,0 +1,7 @@
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
export {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
getAllTrustedCertificatesAsync,
loadCustomRootCertificatesAsync,
} from "./files";

View File

@@ -0,0 +1,3 @@
import type { Casing } from "drizzle-orm";
export const DB_CASING: Casing = "snake_case";

View File

@@ -0,0 +1,27 @@
import { DB_CASING } from "../constants";
import { createDbMapping } from "../mapping";
import { createMysqlDb } from "./mysql";
import { createPostgresDb } from "./postgresql";
import type { SharedDrizzleConfig } from "./shared";
import { WinstonDrizzleLogger } from "./shared";
import { createSqliteDb } from "./sqlite";
export type Database<TSchema extends Record<string, unknown>> = ReturnType<typeof createSqliteDb<TSchema>>;
export const createSharedConfig = <TSchema extends Record<string, unknown>>(
schema: TSchema,
): SharedDrizzleConfig<TSchema> => ({
logger: new WinstonDrizzleLogger(),
casing: DB_CASING,
schema,
});
export const createDb = <TSchema extends Record<string, unknown>>(schema: TSchema) => {
const config = createSharedConfig(schema);
return createDbMapping({
mysql2: () => createMysqlDb(config),
"node-postgres": () => createPostgresDb(config),
"better-sqlite3": () => createSqliteDb(config),
});
};

View File

@@ -0,0 +1,35 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2";
import type { PoolOptions } from "mysql2";
import { dbEnv } from "../env";
import type { SharedDrizzleConfig } from "./shared";
export const createMysqlDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
const connection = createMysqlDbConnection();
return drizzle<TSchema>(connection, {
...config,
mode: "default",
});
};
const createMysqlDbConnection = () => {
const defaultOptions = {
maxIdle: 0,
idleTimeout: 60000,
enableKeepAlive: true,
} satisfies Partial<PoolOptions>;
if (!dbEnv.HOST) {
return mysql.createPool({ ...defaultOptions, uri: dbEnv.URL });
}
return mysql.createPool({
...defaultOptions,
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
});
};

View File

@@ -0,0 +1,38 @@
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
import type { PoolOptions as PostgresPoolOptions } from "pg";
import { Pool as PostgresPool } from "pg";
import { dbEnv } from "../env";
import type { SharedDrizzleConfig } from "./shared";
export const createPostgresDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
const connection = createPostgresDbConnection();
return drizzlePostgres({
...config,
client: connection,
});
};
const createPostgresDbConnection = () => {
const defaultOptions = {
max: 0,
idleTimeoutMillis: 60000,
allowExitOnIdle: false,
} satisfies Partial<PostgresPoolOptions>;
if (!dbEnv.HOST) {
return new PostgresPool({
...defaultOptions,
connectionString: dbEnv.URL,
});
}
return new PostgresPool({
...defaultOptions,
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
});
};

View File

@@ -0,0 +1,15 @@
import type { DrizzleConfig, Logger } from "drizzle-orm";
import { createLogger } from "../../logs";
export type SharedDrizzleConfig<TSchema extends Record<string, unknown>> = Required<
Pick<DrizzleConfig<TSchema>, "logger" | "casing" | "schema">
>;
const logger = createLogger({ module: "db" });
export class WinstonDrizzleLogger implements Logger {
logQuery(query: string, _: unknown[]): void {
logger.debug("Executed SQL query", { query });
}
}

View File

@@ -0,0 +1,10 @@
import Database from "better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import { dbEnv } from "../env";
import type { SharedDrizzleConfig } from "./shared";
export const createSqliteDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
const connection = new Database(dbEnv.URL);
return drizzleSqlite<TSchema>(connection, config);
};

View File

@@ -0,0 +1,53 @@
import { z } from "zod/v4";
import { createEnv, runtimeEnvWithPrefix } from "@homarr/core/infrastructure/env";
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
nodePostgres: "node-postgres",
} as const;
const isDriver = (driver: (typeof drivers)[keyof typeof drivers]) => process.env.DB_DRIVER === driver;
const isUsingDbHost = Boolean(process.env.DB_HOST);
const onlyAllowUrl = isDriver(drivers.betterSqlite3);
const urlRequired = onlyAllowUrl || !isUsingDbHost;
const hostRequired = isUsingDbHost && !onlyAllowUrl;
export const dbEnv = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
DRIVER: z
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.mysql2), z.literal(drivers.nodePostgres)], {
message: `Invalid database driver, supported are ${Object.keys(drivers).join(", ")}`,
})
.default(drivers.betterSqlite3),
...(urlRequired
? {
URL:
// Fallback to the default sqlite file path in production
process.env.NODE_ENV === "production" && isDriver("better-sqlite3")
? z.string().default("/appdata/db/db.sqlite")
: z.string().nonempty(),
}
: {}),
...(hostRequired
? {
HOST: z.string(),
PORT: z
.string()
.regex(/\d+/)
.transform(Number)
.refine((number) => number >= 1)
.default(isDriver(drivers.mysql2) ? 3306 : 5432),
USER: z.string(),
PASSWORD: z.string(),
NAME: z.string(),
}
: {}),
},
runtimeEnv: runtimeEnvWithPrefix("DB_"),
});

View File

@@ -0,0 +1,9 @@
import { createDbMapping } from "./mapping";
export { createDb } from "./drivers";
export const createSchema = createDbMapping;
export { createMysqlDb } from "./drivers/mysql";
export { createSqliteDb } from "./drivers/sqlite";
export { createPostgresDb } from "./drivers/postgresql";
export { createSharedConfig as createSharedDbConfig } from "./drivers";

View File

@@ -0,0 +1,9 @@
import { dbEnv } from "./env";
type DbMappingInput = Record<typeof dbEnv.DRIVER, () => unknown>;
export const createDbMapping = <TInput extends DbMappingInput>(input: TInput) => {
// The DRIVER can be undefined when validation of env vars is skipped
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return input[dbEnv.DRIVER ?? "better-sqlite3"]() as ReturnType<TInput["better-sqlite3"]>;
};

View File

@@ -0,0 +1,8 @@
import { createBooleanSchema, createEnv } from "../env";
export const dnsEnv = createEnv({
server: {
ENABLE_DNS_CACHING: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});

View File

@@ -0,0 +1,28 @@
import { DnsCacheManager } from "dns-caching";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { dnsEnv } from "./env";
// Add global type augmentation for homarr
declare global {
var homarr: {
dnsCacheManager?: DnsCacheManager;
// add other properties if needed
};
}
const logger = createLogger({ module: "dns" });
// Initialize global.homarr if not present
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
global.homarr ??= {};
global.homarr.dnsCacheManager ??= new DnsCacheManager({
cacheMaxEntries: 1000,
forceMinTtl: 5 * 60 * 1000, // 5 minutes
logger,
});
if (dnsEnv.ENABLE_DNS_CACHING) {
global.homarr.dnsCacheManager.initialize();
}

View File

@@ -0,0 +1,12 @@
import { createEnv as createEnvT3 } from "@t3-oss/env-nextjs";
export const defaultEnvOptions = {
emptyStringAsUndefined: true,
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
export * from "./prefix";
export * from "./schemas";

View File

@@ -0,0 +1,13 @@
export const runtimeEnvWithPrefix = (prefix: `${string}_`) =>
Object.entries(process.env)
.filter(([key]) => key.startsWith(prefix))
.reduce(
(acc, [key, value]) => {
if (value === undefined) return acc;
const newKey = key.replace(prefix, "");
acc[newKey] = value;
return acc;
},
{} as Record<string, string>,
);

View File

@@ -0,0 +1,36 @@
import { z } from "zod/v4";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.stringbool({
truthy: trueStrings,
falsy: falseStrings,
case: "insensitive",
})
.default(defaultValue);
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});

View File

@@ -0,0 +1,71 @@
import type { Dispatcher } from "undici";
import { EnvHttpProxyAgent } from "undici";
import type { ILogger } from "@homarr/core/infrastructure/logs";
import { createLogger } from "@homarr/core/infrastructure/logs";
// The below import statement initializes dns-caching
import "@homarr/core/infrastructure/dns/init";
interface HttpAgentOptions extends EnvHttpProxyAgent.Options {
logger?: ILogger;
}
export class UndiciHttpAgent extends EnvHttpProxyAgent {
private logger: ILogger;
constructor(props?: HttpAgentOptions) {
super(props);
this.logger = props?.logger ?? createLogger({ module: "httpAgent" });
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
this.logRequestDispatch(options);
return super.dispatch(options, handler);
}
private logRequestDispatch(options: Dispatcher.DispatchOptions) {
const path = this.redactPathParams(options.path);
let url = new URL(`${options.origin as string}${path}`);
url = this.redactSearchParams(url);
this.logger.debug(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
}
/**
* Redact path parameters that are longer than 32 characters
* This is to prevent sensitive data from being logged
* @param path path of the request
* @returns redacted path
*/
private redactPathParams(path: string): string {
return path
.split("/")
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
.join("/");
}
/**
* Redact sensitive search parameters from the URL.
* It allows certain patterns to remain unredacted.
* Like small numbers, booleans, short strings, dates, and date-times.
* Some integrations use query parameters for auth.
* @param url URL object of the request
* @returns redacted URL object
*/
private redactSearchParams(url: URL): URL {
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
return url;
}
}

View File

@@ -0,0 +1,8 @@
export { UndiciHttpAgent } from "./http-agent";
export {
createAxiosCertificateInstanceAsync,
createCertificateAgentAsync,
createCustomCheckServerIdentity,
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
} from "./request";

View File

@@ -0,0 +1,83 @@
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
return (hostname, peerCertificate) => {
const matchingTrustedHostnames = trustedHostnames.filter(
(cert) => cert.thumbprint === peerCertificate.fingerprint256,
);
// We trust the certificate if we have a matching hostname
if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined;
return checkServerIdentity(hostname, peerCertificate);
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
return new UndiciHttpAgent({
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
});
};
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
// Override the ca and checkServerIdentity if provided
...override,
proxyEnv: process.env,
});
};
export const createAxiosCertificateInstanceAsync = async (
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
) => {
return axios.create({
httpsAgent: await createHttpsAgentAsync(override),
});
};
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
if (options?.timeout) {
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
signal,
dispatcher: agent,
}),
options.timeout,
);
}
return fetch(url, {
...options,
dispatcher: agent,
});
};

View File

@@ -0,0 +1,19 @@
import type { Response as UndiciResponse } from "undici";
// https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
export const withTimeoutAsync = async <TResponse extends Response | UndiciResponse>(
callback: (signal: AbortSignal) => Promise<TResponse>,
timeout = 10000,
) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return await callback(controller.signal).finally(() => {
clearTimeout(timeoutId);
});
};
export const fetchWithTimeoutAsync = async (...[url, requestInit]: Parameters<typeof fetch>) => {
return await withTimeoutAsync((signal) => fetch(url, { ...requestInit, signal }));
};

View File

@@ -0,0 +1,17 @@
export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number];
export const logLevelConfiguration = {
error: {
prefix: "🔴",
},
warn: {
prefix: "🟡",
},
info: {
prefix: "🟢",
},
debug: {
prefix: "🔵",
},
} satisfies Record<LogLevel, { prefix: string }>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod/v4";
import { createEnv, runtimeEnvWithPrefix } from "../env";
import { logLevels } from "./constants";
export const logsEnv = createEnv({
server: {
LEVEL: z.enum(logLevels).default("info"),
},
runtimeEnv: runtimeEnvWithPrefix("LOG_"),
});

View File

@@ -0,0 +1,9 @@
export class ErrorWithMetadata extends Error {
public metadata: Record<string, unknown>;
constructor(message: string, metadata: Record<string, unknown> = {}, options?: ErrorOptions) {
super(message, options);
this.name = "Error";
this.metadata = metadata;
}
}

View File

@@ -0,0 +1,86 @@
import { logsEnv } from "../env";
import { formatMetadata } from "./metadata";
const ERROR_OBJECT_PRUNE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 3;
const ERROR_STACK_LINE_LIMIT = logsEnv.LEVEL === "debug" ? undefined : 5;
const ERROR_CAUSE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 5;
/**
* Formats the cause of an error in the format
* @example caused by Error: {message}
* {stack-trace}
* @param cause next cause in the chain
* @param iteration current iteration of the function
* @returns formatted and stacked causes
*/
export const formatErrorCause = (cause: unknown, iteration = 0): string => {
// Prevent infinite recursion
if (iteration > ERROR_CAUSE_DEPTH) {
return "";
}
if (cause instanceof Error) {
if (!cause.cause) {
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}`;
}
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}${formatErrorCause(cause.cause, iteration + 1)}`;
}
if (typeof cause === "object" && cause !== null) {
if ("cause" in cause) {
const { cause: innerCause, ...rest } = cause;
return `\ncaused by ${JSON.stringify(prune(rest, ERROR_OBJECT_PRUNE_DEPTH))}${formatErrorCause(innerCause, iteration + 1)}`;
}
return `\ncaused by ${JSON.stringify(prune(cause, ERROR_OBJECT_PRUNE_DEPTH))}`;
}
return `\ncaused by ${cause as string}`;
};
const ignoredErrorProperties = ["stack", "message", "name", "cause"];
/**
* Formats the title of an error
* @example {name}: {message} {metadata}
* @param error error to format title from
* @returns formatted error title
*/
export const formatErrorTitle = (error: Error) => {
const title = error.message.length === 0 ? error.name : `${error.name}: ${error.message}`;
const metadata = formatMetadata(error, ignoredErrorProperties);
return `${title} ${metadata}`;
};
/**
* Formats the stack trance of an error
* We remove the first line as it contains the error name and message
* @param stack stack trace
* @returns formatted stack trace
*/
export const formatErrorStack = (stack: string | undefined) =>
stack
?.split("\n")
.slice(1, ERROR_STACK_LINE_LIMIT ? ERROR_STACK_LINE_LIMIT + 1 : undefined)
.join("\n") ?? "";
/**
* Removes nested properties from an object beyond a certain depth
*/
const prune = (value: unknown, depth: number): unknown => {
if (typeof value !== "object" || value === null) {
return value;
}
if (Array.isArray(value)) {
if (depth === 0) return [];
return value.map((item) => prune(item, depth - 1));
}
if (depth === 0) {
return {};
}
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, prune(val, depth - 1)]));
};

View File

@@ -0,0 +1,25 @@
import { format } from "winston";
import { formatErrorCause, formatErrorStack } from "./error";
import { formatMetadata } from "./metadata";
export const logFormat = format.combine(
format.colorize(),
format.timestamp(),
format.errors({ stack: true, cause: true }),
format.printf(({ level, message, timestamp, cause, stack, ...metadata }) => {
const firstLine = `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}`;
if (!cause && !stack) {
return firstLine;
}
const formatedStack = formatErrorStack(stack as string | undefined);
if (!cause) {
return `${firstLine}\n${formatedStack}`;
}
return `${firstLine}\n${formatedStack}${formatErrorCause(cause)}`;
}),
);

View File

@@ -0,0 +1,12 @@
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
const metadataObject = metadata instanceof ErrorWithMetadata ? metadata.metadata : metadata;
const filteredMetadata = Object.keys(metadataObject)
.filter((key) => !ignoreKeys?.includes(key))
.map((key) => ({ key, value: metadataObject[key as keyof typeof metadataObject] }))
.filter(({ value }) => typeof value !== "object" && typeof value !== "function");
return filteredMetadata.map(({ key, value }) => `${key}="${value as string}"`).join(" ");
};

View File

@@ -0,0 +1,26 @@
import winston from "winston";
import { logsEnv } from "./env";
import { logFormat } from "./format";
import { logTransports } from "./transports";
const logger = winston.createLogger({
format: logFormat,
transports: logTransports,
level: logsEnv.LEVEL,
});
interface DefaultMetadata {
module: string;
}
export const createLogger = (metadata: DefaultMetadata & Record<string, unknown>) => logger.child(metadata);
type LogMethod = ((message: string, metadata?: Record<string, unknown>) => void) | ((error: unknown) => void);
export interface ILogger {
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
}

View File

@@ -0,0 +1,21 @@
import { transports } from "winston";
import type { transport } from "winston";
import { RedisTransport } from "./redis-transport";
const getTransports = () => {
const defaultTransports: transport[] = [new transports.Console()];
// Only add the Redis transport if we are not in CI
if (!(Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS))) {
return defaultTransports.concat(
new RedisTransport({
level: "debug",
}),
);
}
return defaultTransports;
};
export const logTransports = getTransports();

View File

@@ -0,0 +1,44 @@
import superjson from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
//
// Inherit from `winston-transport` so you can take advantage
// of the base functionality and `.exceptions.handle()`.
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Log the info to the Redis channel
*/
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
.then(() => {
callback();
})
.catch(() => {
// Ignore errors
});
}
}

View File

@@ -0,0 +1,27 @@
import type { RedisOptions } from "ioredis";
import { Redis } from "ioredis";
import { redisEnv } from "./env";
const defaultRedisOptions = {
connectionName: "homarr",
} satisfies RedisOptions;
export type { Redis as RedisClient } from "ioredis";
export const createRedisClient = () =>
redisEnv.IS_EXTERNAL
? new Redis({
...defaultRedisOptions,
host: redisEnv.HOST,
port: redisEnv.PORT,
db: redisEnv.DATABASE_INDEX,
tls: redisEnv.TLS_CA
? {
ca: redisEnv.TLS_CA,
}
: undefined,
username: redisEnv.USERNAME,
password: redisEnv.PASSWORD,
})
: new Redis(defaultRedisOptions);

View File

@@ -0,0 +1,18 @@
import { z } from "zod/v4";
import { createEnv } from "../env";
import { runtimeEnvWithPrefix } from "../env/prefix";
import { createBooleanSchema } from "../env/schemas";
export const redisEnv = createEnv({
server: {
IS_EXTERNAL: createBooleanSchema(false),
HOST: z.string().optional(),
PORT: z.coerce.number().default(6379).optional(),
TLS_CA: z.string().optional(),
USERNAME: z.string().optional(),
PASSWORD: z.string().optional(),
DATABASE_INDEX: z.coerce.number().optional(),
},
runtimeEnv: runtimeEnvWithPrefix("REDIS_"),
});

View File

@@ -0,0 +1,103 @@
import type { Dispatcher } from "undici";
import { describe, expect, test, vi } from "vitest";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import { TestLogger } from "../logs";
vi.mock("undici", () => {
return {
EnvHttpProxyAgent: class EnvHttpProxyAgent {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
return true;
}
},
setGlobalDispatcher: () => undefined,
};
});
const REDACTED = "REDACTED";
describe("UndiciHttpAgent should log all requests", () => {
test("should log all requests", () => {
// Arrange
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path: "/", method: "GET" }, {});
// Assert
expect(logger.messages).toContainEqual({
level: "debug",
message: "Dispatching request https://homarr.dev/ (0 headers)",
});
});
test("should show amount of headers", () => {
// Arrange
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch(
{
origin: "https://homarr.dev",
path: "/",
method: "GET",
headers: {
"Content-Type": "text/html",
"User-Agent": "Mozilla/5.0",
},
},
{},
);
// Assert
expect(logger.messages.at(-1)?.message).toContain("(2 headers)");
});
test.each([
["/?hex=a3815e8ada2ef9a31", `/?hex=${REDACTED}`],
["/?uuid=f7c3f65e-c511-4f90-ba9a-3fd31418bd49", `/?uuid=${REDACTED}`],
["/?password=complexPassword123", `/?password=${REDACTED}`],
[
// JWT for John Doe
"/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
`/?jwt=${REDACTED}`,
],
["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`],
["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`],
[`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`],
[`/${"a".repeat(32)}/?param=123`, `/${REDACTED}/?param=123`],
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
// Arrange
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${expected} `);
});
test.each([
["empty", "/?empty"],
["numbers with max 12 chars", "/?number=123456789012"],
["true", "/?true=true"],
["false", "/?false=false"],
["strings with max 12 chars", `/?short=${"a".repeat(12)}`],
["dates", "/?date=2022-01-01"],
["date times", "/?datetime=2022-01-01T00:00:00.000Z"],
])("should not redact values that are %s", (_reason, path) => {
// Arrange
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${path} `);
});
});

View File

@@ -0,0 +1,49 @@
import type { ILogger } from "@homarr/core/infrastructure/logs";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
interface LogMessage {
level: LogLevel;
message: string;
meta?: Record<string, unknown>;
}
interface LogError {
level: LogLevel;
error: unknown;
}
type LogEntry = LogMessage | LogError;
export class TestLogger implements ILogger {
public entries: LogEntry[] = [];
public get messages(): LogMessage[] {
return this.entries.filter((entry) => "message" in entry);
}
public get errors(): LogError[] {
return this.entries.filter((entry) => "error" in entry);
}
private log(level: LogLevel, param1: unknown, param2?: Record<string, unknown>): void {
if (typeof param1 === "string") {
this.entries.push({ level, message: param1, meta: param2 });
} else {
this.entries.push({ level, error: param1 });
}
}
debug(param1: unknown, param2?: Record<string, unknown>): void {
this.log("debug", param1, param2);
}
info(param1: unknown, param2?: Record<string, unknown>): void {
this.log("info", param1, param2);
}
warn(param1: unknown, param2?: Record<string, unknown>): void {
this.log("warn", param1, param2);
}
error(param1: unknown, param2?: Record<string, unknown>): void {
this.log("error", param1, param2);
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}