feat: add windows profile model
This commit is contained in:
182
src/server/windowsProfiles.js
Normal file
182
src/server/windowsProfiles.js
Normal file
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
157
test/server/windows-profiles.test.js
Normal file
157
test/server/windows-profiles.test.js
Normal file
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user