Replace entire codebase with homarr-labs/homarr
This commit is contained in:
4
packages/core/eslint.config.js
Normal file
4
packages/core/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
60
packages/core/package.json
Normal file
60
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
74
packages/core/src/infrastructure/certificates/files/index.ts
Normal file
74
packages/core/src/infrastructure/certificates/files/index.ts
Normal 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);
|
||||
};
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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>;
|
||||
7
packages/core/src/infrastructure/certificates/index.ts
Normal file
7
packages/core/src/infrastructure/certificates/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
|
||||
export {
|
||||
addCustomRootCertificateAsync,
|
||||
removeCustomRootCertificateAsync,
|
||||
getAllTrustedCertificatesAsync,
|
||||
loadCustomRootCertificatesAsync,
|
||||
} from "./files";
|
||||
3
packages/core/src/infrastructure/db/constants.ts
Normal file
3
packages/core/src/infrastructure/db/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Casing } from "drizzle-orm";
|
||||
|
||||
export const DB_CASING: Casing = "snake_case";
|
||||
27
packages/core/src/infrastructure/db/drivers/index.ts
Normal file
27
packages/core/src/infrastructure/db/drivers/index.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
35
packages/core/src/infrastructure/db/drivers/mysql.ts
Normal file
35
packages/core/src/infrastructure/db/drivers/mysql.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
38
packages/core/src/infrastructure/db/drivers/postgresql.ts
Normal file
38
packages/core/src/infrastructure/db/drivers/postgresql.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
15
packages/core/src/infrastructure/db/drivers/shared.ts
Normal file
15
packages/core/src/infrastructure/db/drivers/shared.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
10
packages/core/src/infrastructure/db/drivers/sqlite.ts
Normal file
10
packages/core/src/infrastructure/db/drivers/sqlite.ts
Normal 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);
|
||||
};
|
||||
53
packages/core/src/infrastructure/db/env.ts
Normal file
53
packages/core/src/infrastructure/db/env.ts
Normal 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_"),
|
||||
});
|
||||
9
packages/core/src/infrastructure/db/index.ts
Normal file
9
packages/core/src/infrastructure/db/index.ts
Normal 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";
|
||||
9
packages/core/src/infrastructure/db/mapping.ts
Normal file
9
packages/core/src/infrastructure/db/mapping.ts
Normal 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"]>;
|
||||
};
|
||||
8
packages/core/src/infrastructure/dns/env.ts
Normal file
8
packages/core/src/infrastructure/dns/env.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createBooleanSchema, createEnv } from "../env";
|
||||
|
||||
export const dnsEnv = createEnv({
|
||||
server: {
|
||||
ENABLE_DNS_CACHING: createBooleanSchema(true),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
28
packages/core/src/infrastructure/dns/init.ts
Normal file
28
packages/core/src/infrastructure/dns/init.ts
Normal 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();
|
||||
}
|
||||
12
packages/core/src/infrastructure/env/index.ts
vendored
Normal file
12
packages/core/src/infrastructure/env/index.ts
vendored
Normal 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";
|
||||
13
packages/core/src/infrastructure/env/prefix.ts
vendored
Normal file
13
packages/core/src/infrastructure/env/prefix.ts
vendored
Normal 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>,
|
||||
);
|
||||
36
packages/core/src/infrastructure/env/schemas.ts
vendored
Normal file
36
packages/core/src/infrastructure/env/schemas.ts
vendored
Normal 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;
|
||||
});
|
||||
71
packages/core/src/infrastructure/http/http-agent.ts
Normal file
71
packages/core/src/infrastructure/http/http-agent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/core/src/infrastructure/http/index.ts
Normal file
8
packages/core/src/infrastructure/http/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { UndiciHttpAgent } from "./http-agent";
|
||||
export {
|
||||
createAxiosCertificateInstanceAsync,
|
||||
createCertificateAgentAsync,
|
||||
createCustomCheckServerIdentity,
|
||||
createHttpsAgentAsync,
|
||||
fetchWithTrustedCertificatesAsync,
|
||||
} from "./request";
|
||||
83
packages/core/src/infrastructure/http/request.ts
Normal file
83
packages/core/src/infrastructure/http/request.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
19
packages/core/src/infrastructure/http/timeout.ts
Normal file
19
packages/core/src/infrastructure/http/timeout.ts
Normal 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 }));
|
||||
};
|
||||
17
packages/core/src/infrastructure/logs/constants.ts
Normal file
17
packages/core/src/infrastructure/logs/constants.ts
Normal 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 }>;
|
||||
11
packages/core/src/infrastructure/logs/env.ts
Normal file
11
packages/core/src/infrastructure/logs/env.ts
Normal 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_"),
|
||||
});
|
||||
9
packages/core/src/infrastructure/logs/error.ts
Normal file
9
packages/core/src/infrastructure/logs/error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
packages/core/src/infrastructure/logs/format/error.ts
Normal file
86
packages/core/src/infrastructure/logs/format/error.ts
Normal 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)]));
|
||||
};
|
||||
25
packages/core/src/infrastructure/logs/format/index.ts
Normal file
25
packages/core/src/infrastructure/logs/format/index.ts
Normal 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)}`;
|
||||
}),
|
||||
);
|
||||
12
packages/core/src/infrastructure/logs/format/metadata.ts
Normal file
12
packages/core/src/infrastructure/logs/format/metadata.ts
Normal 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(" ");
|
||||
};
|
||||
26
packages/core/src/infrastructure/logs/index.ts
Normal file
26
packages/core/src/infrastructure/logs/index.ts
Normal 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;
|
||||
}
|
||||
21
packages/core/src/infrastructure/logs/transports/index.ts
Normal file
21
packages/core/src/infrastructure/logs/transports/index.ts
Normal 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();
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
27
packages/core/src/infrastructure/redis/client.ts
Normal file
27
packages/core/src/infrastructure/redis/client.ts
Normal 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);
|
||||
18
packages/core/src/infrastructure/redis/env.ts
Normal file
18
packages/core/src/infrastructure/redis/env.ts
Normal 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_"),
|
||||
});
|
||||
103
packages/core/src/test/infrastructure/http/http-agent.spec.ts
Normal file
103
packages/core/src/test/infrastructure/http/http-agent.spec.ts
Normal 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} `);
|
||||
});
|
||||
});
|
||||
49
packages/core/src/test/infrastructure/logs/index.ts
Normal file
49
packages/core/src/test/infrastructure/logs/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
packages/core/tsconfig.json
Normal file
8
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user