mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-12 15:15:58 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5873f8e7d5 | |||
| 6099837373 | |||
| f0bd255adb | |||
| 21bf7ed8e2 | |||
| 728c6fbe18 | |||
| 6a96289717 | |||
| fa47a2d86c | |||
| cc22d04429 | |||
| a414f376b9 | |||
| bdd77342d8 | |||
| 63d2172ef7 | |||
| 85e4e439bc | |||
| 36aa241689 | |||
| 0933ba015a | |||
| c57117daae |
@@ -49,6 +49,23 @@ environment:
|
|||||||
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Connecting Claude Desktop
|
||||||
|
|
||||||
|
Claude Desktop can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to communicate with the homepage MCP endpoint in `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homepage": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header", "X-Homepage-MCP-Token: your-token"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The **Add custom connector** option will not work: remote connectors authenticate with OAuth and cannot supply `HOMEPAGE_MCP_TOKEN`.
|
||||||
|
|
||||||
## Read-only by default
|
## Read-only by default
|
||||||
|
|
||||||
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ export default defineConfig([
|
|||||||
allowElseIf: true,
|
allowElseIf: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Keep the pre-eslint-plugin-react-hooks v6 lint policy until rules are adopted incrementally
|
||||||
|
"react-hooks/config": "off",
|
||||||
|
"react-hooks/error-boundaries": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react-hooks/gating": "off",
|
||||||
|
"react-hooks/globals": "off",
|
||||||
|
"react-hooks/immutability": "off",
|
||||||
|
"react-hooks/incompatible-library": "off",
|
||||||
|
"react-hooks/preserve-manual-memoization": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/refs": "off",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"react-hooks/set-state-in-render": "off",
|
||||||
|
"react-hooks/static-components": "off",
|
||||||
|
"react-hooks/unsupported-syntax": "off",
|
||||||
|
"react-hooks/use-memo": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "2.1.0",
|
"version": "2.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.10",
|
"@headlessui/react": "^2.2.10",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^2.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dockerode": "^5.0.0",
|
"dockerode": "^5.0.0",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"gamedig": "^5.3.3",
|
"gamedig": "^5.3.3",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"ical.js": "^2.2.1",
|
"ical.js": "^2.2.1",
|
||||||
"js-yaml": "^4.3.1",
|
"js-yaml": "^5.3.0",
|
||||||
"json-rpc-2.0": "^1.7.1",
|
"json-rpc-2.0": "^1.7.1",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"swr": "^2.4.2",
|
"swr": "^2.5.1",
|
||||||
"systeminformation": "^5.33.1",
|
"systeminformation": "^5.33.1",
|
||||||
"tough-cookie": "^6.0.2",
|
"tough-cookie": "^6.0.2",
|
||||||
"urbackup-server-api": "^0.92.2",
|
"urbackup-server-api": "^0.92.2",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.8.4",
|
||||||
|
|||||||
Generated
+504
-294
File diff suppressed because it is too large
Load Diff
@@ -472,7 +472,7 @@
|
|||||||
"users": "Usuarios",
|
"users": "Usuarios",
|
||||||
"loginsLast24H": "Inicios de sesión (24h)",
|
"loginsLast24H": "Inicios de sesión (24h)",
|
||||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
||||||
"authorizationsLast24H": "Auths (24h)"
|
"authorizationsLast24H": "Autenticaciones (24 h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export default async function handler(req, res) {
|
|||||||
return res.status(404).end("Not Found");
|
return res.status(404).end("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method check precedes auth so CORS preflights aren't answered with a 401.
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
return res.status(405).end("Method Not Allowed");
|
||||||
|
}
|
||||||
|
|
||||||
const tokenError = mcpTokenConfigError();
|
const tokenError = mcpTokenConfigError();
|
||||||
if (tokenError) {
|
if (tokenError) {
|
||||||
createLogger("mcp").error(tokenError);
|
createLogger("mcp").error(tokenError);
|
||||||
@@ -25,11 +31,6 @@ export default async function handler(req, res) {
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
return res.status(405).end("Method Not Allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = handleMcpRequest(req.body);
|
const response = handleMcpRequest(req.body);
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return res.status(202).end();
|
return res.status(202).end();
|
||||||
|
|||||||
@@ -214,4 +214,38 @@ describe("pages/api/mcp", () => {
|
|||||||
expect(res.status).toHaveBeenCalledWith(405);
|
expect(res.status).toHaveBeenCalledWith(405);
|
||||||
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers unauthenticated CORS preflights with 405 rather than 401", async () => {
|
||||||
|
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_MCP_TOKEN = "mcp-tok-0123456789abcdefghijklmnopqrstuv";
|
||||||
|
const handler = await loadHandler();
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
// a preflight never carries the Authorization header the browser strips
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: "OPTIONS",
|
||||||
|
headers: {
|
||||||
|
origin: "https://claude.ai",
|
||||||
|
"access-control-request-method": "POST",
|
||||||
|
"access-control-request-headers": "authorization,content-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(405);
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||||
|
expect(getServerSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns 404 for non-POST requests while disabled", async () => {
|
||||||
|
delete process.env.HOMEPAGE_MCP_ENABLED;
|
||||||
|
const handler = await loadHandler();
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
await handler({ method: "OPTIONS", headers: {} }, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||||
import {
|
import {
|
||||||
cleanServiceGroups,
|
cleanServiceGroups,
|
||||||
@@ -12,6 +10,7 @@ import {
|
|||||||
servicesFromKubernetes,
|
servicesFromKubernetes,
|
||||||
} from "utils/config/service-helpers";
|
} from "utils/config/service-helpers";
|
||||||
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares services by weight then by name.
|
* Compares services by weight then by name.
|
||||||
@@ -30,7 +29,7 @@ export async function bookmarksResponse() {
|
|||||||
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
||||||
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
|
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const bookmarks = yaml.load(fileContents);
|
const bookmarks = loadYaml(fileContents);
|
||||||
|
|
||||||
if (!bookmarks) return [];
|
if (!bookmarks) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
|
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { fs, yaml } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("fs", () => fs);
|
vi.mock("fs", () => fs);
|
||||||
vi.mock("js-yaml", () => ({ default: yaml, ...yaml }));
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
|
|
||||||
describe("utils/config/config checkAndCopyConfig", () => {
|
describe("utils/config/config checkAndCopyConfig", () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
import cache from "memory-cache";
|
import cache from "memory-cache";
|
||||||
|
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
const cacheKey = "homepageEnvironmentVariables";
|
const cacheKey = "homepageEnvironmentVariables";
|
||||||
const homepageVarPrefix = "HOMEPAGE_VAR_";
|
const homepageVarPrefix = "HOMEPAGE_VAR_";
|
||||||
const homepageFilePrefix = "HOMEPAGE_FILE_";
|
const homepageFilePrefix = "HOMEPAGE_FILE_";
|
||||||
@@ -42,7 +43,7 @@ export default function checkAndCopyConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yaml.load(readFileSync(configYaml, "utf8"));
|
loadYaml(readFileSync(configYaml, "utf8"));
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ...e, config };
|
return { ...e, config };
|
||||||
@@ -85,7 +86,7 @@ export function getSettings() {
|
|||||||
const settingsYaml = join(CONF_DIR, "settings.yaml");
|
const settingsYaml = join(CONF_DIR, "settings.yaml");
|
||||||
const rawFileContents = readFileSync(settingsYaml, "utf8");
|
const rawFileContents = readFileSync(settingsYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const initialSettings = yaml.load(fileContents) ?? {};
|
const initialSettings = loadYaml(fileContents) ?? {};
|
||||||
|
|
||||||
if (initialSettings.layout) {
|
if (initialSettings.layout) {
|
||||||
// support yaml list but old spec was object so convert to that
|
// support yaml list but old spec was object so convert to that
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getDefaultDockerArgs(platform = process.platform) {
|
export function getDefaultDockerArgs(platform = process.platform) {
|
||||||
if (platform !== "win32" && platform !== "darwin") {
|
if (platform !== "win32" && platform !== "darwin") {
|
||||||
@@ -19,7 +18,7 @@ export default function getDockerArguments(server) {
|
|||||||
const configFile = path.join(CONF_DIR, "docker.yaml");
|
const configFile = path.join(CONF_DIR, "docker.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
const servers = yaml.load(configData);
|
const servers = loadYaml(configData);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return getDefaultDockerArgs();
|
return getDefaultDockerArgs();
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
|
import { isIPv6 } from "net";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
|
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getKubernetes() {
|
export function getKubernetes() {
|
||||||
checkAndCopyConfig("kubernetes.yaml");
|
checkAndCopyConfig("kubernetes.yaml");
|
||||||
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
|
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
return yaml.load(configData);
|
return loadYaml(configData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getKubeConfig = () => {
|
export const getKubeConfig = () => {
|
||||||
@@ -21,6 +22,10 @@ export const getKubeConfig = () => {
|
|||||||
switch (config?.mode) {
|
switch (config?.mode) {
|
||||||
case "cluster":
|
case "cluster":
|
||||||
kc.loadFromCluster();
|
kc.loadFromCluster();
|
||||||
|
// node < 26 can't match an IPv6 host against an IP SAN, so verify against the DNS SAN instead (nodejs/node#64032)
|
||||||
|
if (isIPv6(process.env.KUBERNETES_SERVICE_HOST ?? "")) {
|
||||||
|
kc.clusters = kc.clusters.map((cluster) => ({ ...cluster, tlsServerName: "kubernetes.default.svc" }));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "default":
|
case "default":
|
||||||
kc.loadFromDefault();
|
kc.loadFromDefault();
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
@@ -46,6 +43,7 @@ vi.mock("@kubernetes/client-node", () => ({
|
|||||||
ApiextensionsV1Api: class ApiextensionsV1Api {},
|
ApiextensionsV1Api: class ApiextensionsV1Api {},
|
||||||
KubeConfig: class KubeConfig {
|
KubeConfig: class KubeConfig {
|
||||||
loadFromCluster() {
|
loadFromCluster() {
|
||||||
|
this.clusters = [{ name: "inCluster", server: "https://host:443", skipTLSVerify: false }];
|
||||||
return kube.loadFromCluster();
|
return kube.loadFromCluster();
|
||||||
}
|
}
|
||||||
loadFromDefault() {
|
loadFromDefault() {
|
||||||
@@ -88,6 +86,27 @@ describe("utils/config/kubernetes", () => {
|
|||||||
expect(kc2).not.toBeNull();
|
expect(kc2).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getKubeConfig sets tlsServerName when the in-cluster host is IPv6", () => {
|
||||||
|
vi.stubEnv("KUBERNETES_SERVICE_HOST", "fd00:1018:2000::1");
|
||||||
|
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||||
|
|
||||||
|
expect(getKubeConfig().clusters[0]).toMatchObject({
|
||||||
|
name: "inCluster",
|
||||||
|
tlsServerName: "kubernetes.default.svc",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getKubeConfig leaves tlsServerName unset for non-IPv6 in-cluster hosts", () => {
|
||||||
|
vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.43.0.1");
|
||||||
|
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||||
|
|
||||||
|
expect(getKubeConfig().clusters[0].tlsServerName).toBeUndefined();
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it("checkCRD returns true when the CRD exists", async () => {
|
it("checkCRD returns true when the CRD exists", async () => {
|
||||||
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
|
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
|
||||||
const logger = { error: vi.fn() };
|
const logger = { error: vi.fn() };
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getProxmoxConfig() {
|
export function getProxmoxConfig() {
|
||||||
checkAndCopyConfig("proxmox.yaml");
|
checkAndCopyConfig("proxmox.yaml");
|
||||||
const configFile = path.join(CONF_DIR, "proxmox.yaml");
|
const configFile = path.join(CONF_DIR, "proxmox.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
return yaml.load(configData);
|
return loadYaml(configData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { promises as fs } from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||||
import getDockerArguments from "utils/config/docker";
|
import getDockerArguments from "utils/config/docker";
|
||||||
import { getKubeConfig } from "utils/config/kubernetes";
|
import { getKubeConfig } from "utils/config/kubernetes";
|
||||||
import * as shvl from "utils/config/shvl";
|
import * as shvl from "utils/config/shvl";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
import kubernetes from "utils/kubernetes/export";
|
import kubernetes from "utils/kubernetes/export";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
@@ -56,7 +56,7 @@ export async function servicesFromConfig() {
|
|||||||
const servicesYaml = path.join(CONF_DIR, "services.yaml");
|
const servicesYaml = path.join(CONF_DIR, "services.yaml");
|
||||||
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
|
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const services = yaml.load(fileContents);
|
const services = loadYaml(fileContents);
|
||||||
return parseServicesToGroups(services);
|
return parseServicesToGroups(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function servicesFromDocker() {
|
|||||||
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
|
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
|
||||||
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
|
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
|
||||||
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
|
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
|
||||||
const servers = yaml.load(dockerFileContents);
|
const servers = loadYaml(dockerFileContents);
|
||||||
|
|
||||||
if (!servers) {
|
if (!servers) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -70,10 +70,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
vi.mock("dockerode", () => ({ default: Docker }));
|
vi.mock("dockerode", () => ({ default: Docker }));
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export async function widgetsFromConfig() {
|
export async function widgetsFromConfig() {
|
||||||
checkAndCopyConfig("widgets.yaml");
|
checkAndCopyConfig("widgets.yaml");
|
||||||
@@ -11,7 +10,7 @@ export async function widgetsFromConfig() {
|
|||||||
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
|
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
|
||||||
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
|
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const widgets = yaml.load(fileContents);
|
const widgets = loadYaml(fileContents);
|
||||||
|
|
||||||
if (!widgets) return [];
|
if (!widgets) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as yaml from "js-yaml";
|
||||||
|
|
||||||
|
const EMPTY_DOCUMENT_ERROR = "expected a document, but the input is empty";
|
||||||
|
const DEFAULT_SCHEMA = yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag, yaml.legacyMapTag);
|
||||||
|
|
||||||
|
export function loadYaml(input, options) {
|
||||||
|
try {
|
||||||
|
return yaml.load(input, { schema: DEFAULT_SCHEMA, ...options });
|
||||||
|
} catch (error) {
|
||||||
|
// js-yaml v4 returned undefined for empty and comment-only documents.
|
||||||
|
if (error?.reason === EMPTY_DOCUMENT_ERROR) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
|
||||||
|
describe("utils/config/yaml", () => {
|
||||||
|
it.each(["", " \n", "# comment only\n"])("loads an empty document from %j as undefined", (input) => {
|
||||||
|
expect(loadYaml(input)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a populated document", () => {
|
||||||
|
expect(loadYaml("title: Homepage\n")).toEqual({ title: "Homepage" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves v4 merge key behavior", () => {
|
||||||
|
expect(loadYaml("defaults: &defaults\n href: https://example.com\nservice:\n <<: *defaults\n")).toEqual({
|
||||||
|
defaults: { href: "https://example.com" },
|
||||||
|
service: { href: "https://example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves v4 timestamp behavior without enabling YAML 1.1 booleans", () => {
|
||||||
|
expect(loadYaml("date: 2026-08-21\nenabled: yes\n")).toEqual({
|
||||||
|
date: new Date("2026-08-21T00:00:00.000Z"),
|
||||||
|
enabled: "yes",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves v4 handling of non-string keys", () => {
|
||||||
|
// an unsubstituted {{HOMEPAGE_VAR_*}} parses as a flow mapping key
|
||||||
|
expect(loadYaml("- Plex:\n widget:\n key: {{HOMEPAGE_VAR_PLEX_KEY}}\n")).toEqual([
|
||||||
|
{ Plex: { widget: { key: { "[object Object]": null } } } },
|
||||||
|
]);
|
||||||
|
expect(loadYaml("- Backups:\n - 2024-01-01:\n href: http://x\n")).toEqual([
|
||||||
|
{ Backups: [{ [String(new Date("2024-01-01T00:00:00.000Z"))]: { href: "http://x" } }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects invalid YAML", () => {
|
||||||
|
expect(() => loadYaml("value: [\n")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-13
@@ -18,23 +18,16 @@ function combineMessageAndSplat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function messageFormatter(logInfo) {
|
function messageFormatter(logInfo) {
|
||||||
if (logInfo.label) {
|
const label = logInfo.label ? `<${logInfo.label}> ` : "";
|
||||||
if (logInfo.stack) {
|
// e.g. fetch errors say nothing useful without the cause
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
|
const cause = logInfo.cause ? `\ncaused by: ${logInfo.cause.stack ?? logInfo.cause}` : "";
|
||||||
}
|
return `[${logInfo.timestamp}] ${logInfo.level}: ${label}${logInfo.stack || logInfo.message}${cause}`;
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logInfo.stack) {
|
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
|
|
||||||
}
|
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConsoleLogger() {
|
function getConsoleLogger() {
|
||||||
return new winston.transports.Console({
|
return new winston.transports.Console({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true, cause: true }),
|
||||||
combineMessageAndSplat(),
|
combineMessageAndSplat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
winston.format.colorize(),
|
winston.format.colorize(),
|
||||||
@@ -51,7 +44,7 @@ function getFileLogger() {
|
|||||||
|
|
||||||
return new winston.transports.File({
|
return new winston.transports.File({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true, cause: true }),
|
||||||
combineMessageAndSplat(),
|
combineMessageAndSplat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
winston.format.printf(messageFormatter),
|
winston.format.printf(messageFormatter),
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ describe("utils/logger", () => {
|
|||||||
});
|
});
|
||||||
expect(plainMsg).toBe("[t] info: hello");
|
expect(plainMsg).toBe("[t] info: hello");
|
||||||
|
|
||||||
|
const causeMsg = formatter({
|
||||||
|
timestamp: "t",
|
||||||
|
level: "error",
|
||||||
|
label: "x",
|
||||||
|
stack: "STACK",
|
||||||
|
message: "fetch failed",
|
||||||
|
cause: { stack: "CAUSE STACK" },
|
||||||
|
});
|
||||||
|
expect(causeMsg).toBe("[t] error: <x> STACK\ncaused by: CAUSE STACK");
|
||||||
|
|
||||||
const out = splat.transform(
|
const out = splat.transform(
|
||||||
{
|
{
|
||||||
message: "Hello %s",
|
message: "Hello %s",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
|
||||||
import { CONF_DIR } from "utils/config/config";
|
import { CONF_DIR } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
const PROTOCOL_VERSION = "2025-11-25";
|
const PROTOCOL_VERSION = "2025-11-25";
|
||||||
const SERVER_INFO = {
|
const SERVER_INFO = {
|
||||||
@@ -118,7 +119,7 @@ function readConfig(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseYamlConfig(file) {
|
function parseYamlConfig(file) {
|
||||||
const parsed = yaml.load(readConfig(file) || "");
|
const parsed = loadYaml(readConfig(file) || "");
|
||||||
return parsed ?? [];
|
return parsed ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ function validateYaml(file, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yaml.load(content || "");
|
loadYaml(content || "");
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user