From 39eca49f628aa71e93a66477b68e88eb569964ff Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 21 May 2026 20:18:28 +0300 Subject: [PATCH] feat: add windows profile model --- src/server/windowsProfiles.js | 182 +++++++++++++++++++++++++++ test/server/windows-profiles.test.js | 157 +++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 src/server/windowsProfiles.js create mode 100644 test/server/windows-profiles.test.js diff --git a/src/server/windowsProfiles.js b/src/server/windowsProfiles.js new file mode 100644 index 0000000..e114f66 --- /dev/null +++ b/src/server/windowsProfiles.js @@ -0,0 +1,182 @@ +import fs from "node:fs"; +import path from "node:path"; + +const ITEM_TYPES = new Set(["process", "folder", "exe"]); +const PROTOCOLS = new Set(["TCP", "UDP"]); + +function slug(value, fallback) { + const cleaned = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || fallback; +} + +function cleanString(value) { + return String(value || "").trim(); +} + +function processName(value) { + const base = cleanString(value).split(/[\\/]/).pop() || ""; + return base.replace(/\.exe$/i, "").trim(); +} + +function unique(values) { + return Array.from(new Set(values.filter(Boolean))); +} + +export function normalizeWindowsProfiles(input) { + return (Array.isArray(input) ? input : []) + .map((profile, index) => { + const name = cleanString(profile.name) || `Profile ${index + 1}`; + const items = (Array.isArray(profile.items) ? profile.items : []) + .filter((item) => ITEM_TYPES.has(item?.type)) + .map((item) => ({ + type: item.type, + value: + item.type === "process" + ? processName(item.value) + : cleanString(item.value), + recursive: item.type === "folder" ? item.recursive !== false : false, + })) + .filter((item) => item.value); + + return { + id: slug(profile.id || name, `profile-${index + 1}`), + name, + enabled: profile.enabled !== false, + proxyTargetId: cleanString(profile.proxyTargetId) || "local-singbox", + protocols: unique( + (Array.isArray(profile.protocols) + ? profile.protocols + : ["TCP", "UDP"]) + .map((protocol) => cleanString(protocol).toUpperCase()) + .filter((protocol) => PROTOCOLS.has(protocol)), + ), + items, + }; + }) + .map((profile) => ({ + ...profile, + protocols: profile.protocols.length ? profile.protocols : ["TCP", "UDP"], + })); +} + +export function normalizeProxyTargets(input) { + const local = { + id: "local-singbox", + name: "Local sing-box", + protocol: "socks5", + host: "127.0.0.1", + port: 1080, + managed: true, + }; + const seen = new Set([local.id]); + const custom = (Array.isArray(input) ? input : []) + .map((target, index) => ({ + id: slug(target.id || target.name, `target-${index + 1}`), + name: cleanString(target.name) || `Proxy target ${index + 1}`, + protocol: + cleanString(target.protocol || "socks5").toLowerCase() === "http" + ? "http" + : "socks5", + host: cleanString(target.host), + port: Number.parseInt(target.port, 10), + managed: false, + })) + .filter((target) => { + if (!target.host || !Number.isInteger(target.port)) return false; + if (target.port <= 0 || target.port > 65535) return false; + if (seen.has(target.id)) return false; + seen.add(target.id); + return true; + }); + return [local, ...custom]; +} + +function joinPath(base, name, pathSep) { + return base.endsWith(pathSep) ? `${base}${name}` : `${base}${pathSep}${name}`; +} + +function walkExeFiles(dir, { fsAdapter, recursive, pathSep }) { + const entries = fsAdapter.readdirSync(dir, { withFileTypes: true }); + const results = []; + for (const entry of entries) { + const fullPath = joinPath(dir, entry.name, pathSep); + if (entry.isFile() && /\.exe$/i.test(entry.name)) results.push(fullPath); + if (recursive && entry.isDirectory()) { + results.push(...walkExeFiles(fullPath, { fsAdapter, recursive, pathSep })); + } + } + return results; +} + +export function resolveProfileItems(items, options = {}) { + const fsAdapter = options.fsAdapter || fs; + const pathSep = options.pathSep || path.sep; + const resolved = []; + for (const item of Array.isArray(items) ? items : []) { + if (item.type === "process") { + const appName = processName(item.value); + if (appName) resolved.push({ ...item, appName, source: item.value }); + } + if (item.type === "exe") { + const appName = processName(item.value); + if (appName) resolved.push({ ...item, appName, source: item.value }); + } + if (item.type === "folder" && fsAdapter.existsSync(item.value)) { + const stat = fsAdapter.statSync(item.value); + if (stat.isDirectory()) { + for (const filePath of walkExeFiles(item.value, { + fsAdapter, + recursive: item.recursive !== false, + pathSep, + })) { + resolved.push({ + ...item, + appName: processName(filePath), + source: filePath, + }); + } + } + } + } + const byName = new Map(); + for (const item of resolved) { + if (!byName.has(item.appName)) byName.set(item.appName, item); + } + return Array.from(byName.values()); +} + +export function buildProxiFyreConfig(profiles, targets, options = {}) { + const normalizedTargets = normalizeProxyTargets(targets); + const targetById = new Map( + normalizedTargets.map((target) => [target.id, target]), + ); + const groups = new Map(); + for (const profile of normalizeWindowsProfiles(profiles).filter( + (item) => item.enabled, + )) { + const target = + targetById.get(profile.proxyTargetId) || targetById.get("local-singbox"); + const resolved = resolveProfileItems(profile.items, options); + if (!target || resolved.length === 0) continue; + const key = `${target.id}|${profile.protocols.join(",")}`; + const existing = groups.get(key) || { + appNames: [], + socks5ProxyEndpoint: `${target.host}:${target.port}`, + supportedProtocols: profile.protocols, + }; + existing.appNames.push(...resolved.map((item) => item.appName)); + existing.appNames = unique(existing.appNames).sort((a, b) => + a.localeCompare(b), + ); + groups.set(key, existing); + } + return { + logLevel: "Info", + proxies: Array.from(groups.values()), + excludes: [], + }; +} diff --git a/test/server/windows-profiles.test.js b/test/server/windows-profiles.test.js new file mode 100644 index 0000000..23f86fe --- /dev/null +++ b/test/server/windows-profiles.test.js @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildProxiFyreConfig, + normalizeProxyTargets, + normalizeWindowsProfiles, + resolveProfileItems, +} from "../../src/server/windowsProfiles.js"; + +test("normalizeWindowsProfiles keeps process folder and exe source items", () => { + const profiles = normalizeWindowsProfiles([ + { + id: "Discord + Vesktop", + name: "Discord + Vesktop", + enabled: true, + proxyTargetId: "local-singbox", + protocols: ["TCP", "UDP", "bad"], + items: [ + { type: "process", value: "Discord.exe" }, + { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true }, + { type: "exe", value: "C:\\Games\\SomeGame\\game.exe" }, + { type: "bad", value: "ignored" }, + ], + }, + ]); + + assert.deepEqual(profiles, [ + { + id: "discord-vesktop", + name: "Discord + Vesktop", + enabled: true, + proxyTargetId: "local-singbox", + protocols: ["TCP", "UDP"], + items: [ + { type: "process", value: "Discord", recursive: false }, + { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true }, + { type: "exe", value: "C:\\Games\\SomeGame\\game.exe", recursive: false }, + ], + }, + ]); +}); + +test("normalizeProxyTargets always includes local-singbox", () => { + const targets = normalizeProxyTargets([ + { id: "gateway", name: "Home gateway", host: "192.168.50.111", port: 8080 }, + ]); + + assert.deepEqual(targets, [ + { + id: "local-singbox", + name: "Local sing-box", + protocol: "socks5", + host: "127.0.0.1", + port: 1080, + managed: true, + }, + { + id: "gateway", + name: "Home gateway", + protocol: "socks5", + host: "192.168.50.111", + port: 8080, + managed: false, + }, + ]); +}); + +test("resolveProfileItems expands folders and exe paths into process names", () => { + const files = new Map([ + ["C:\\Users\\me\\App\\a.exe", true], + ["C:\\Users\\me\\App\\nested\\b.exe", true], + ["C:\\Games\\Game\\game.exe", true], + ]); + const dirs = new Map([ + ["C:\\Users\\me\\App", ["a.exe", "nested", "note.txt"]], + ["C:\\Users\\me\\App\\nested", ["b.exe"]], + ]); + const fsAdapter = { + existsSync: (value) => files.has(value) || dirs.has(value), + statSync: (value) => ({ + isDirectory: () => dirs.has(value), + isFile: () => files.has(value), + }), + readdirSync: (value, options) => + dirs.get(value).map((name) => ({ + name, + isDirectory: () => dirs.has(`${value}\\${name}`), + isFile: () => files.has(`${value}\\${name}`), + })), + }; + + const resolved = resolveProfileItems( + [ + { type: "process", value: "Discord", recursive: false }, + { type: "folder", value: "C:\\Users\\me\\App", recursive: true }, + { type: "exe", value: "C:\\Games\\Game\\game.exe", recursive: false }, + ], + { fsAdapter, pathSep: "\\" }, + ); + + assert.deepEqual(resolved.map((item) => item.appName), [ + "Discord", + "a", + "b", + "game", + ]); +}); + +test("buildProxiFyreConfig groups enabled profiles by target", () => { + const profiles = normalizeWindowsProfiles([ + { + id: "discord", + name: "Discord", + enabled: true, + proxyTargetId: "local-singbox", + protocols: ["TCP", "UDP"], + items: [{ type: "process", value: "Discord" }], + }, + { + id: "work", + name: "Work", + enabled: true, + proxyTargetId: "gateway", + protocols: ["TCP"], + items: [{ type: "process", value: "Code" }], + }, + { + id: "off", + name: "Disabled", + enabled: false, + proxyTargetId: "local-singbox", + items: [{ type: "process", value: "Ignored" }], + }, + ]); + const targets = normalizeProxyTargets([ + { id: "gateway", name: "Gateway", host: "192.168.50.111", port: 8080 }, + ]); + + const config = buildProxiFyreConfig(profiles, targets); + + assert.deepEqual(config, { + logLevel: "Info", + proxies: [ + { + appNames: ["Discord"], + socks5ProxyEndpoint: "127.0.0.1:1080", + supportedProtocols: ["TCP", "UDP"], + }, + { + appNames: ["Code"], + socks5ProxyEndpoint: "192.168.50.111:8080", + supportedProtocols: ["TCP"], + }, + ], + excludes: [], + }); +});