diff --git a/README.MD b/README.MD
index 75ba4964..a123752d 100644
--- a/README.MD
+++ b/README.MD
@@ -63,6 +63,18 @@ For the latest news, releases, and project updates, follow our Telegram channel:
---
+## Optional Wrapper Dashboard 🖥️
+
+A local browser dashboard is available in [`tools/wrapper-dashboard`](tools/wrapper-dashboard).
+It can manage a downloaded client release package by editing `client_config.toml`,
+editing `client_resolvers.txt`, starting/stopping the headless client binary,
+parsing logs, and showing optimizer recommendations.
+
+Architecture and safety notes are documented in
+[`docs/wrapper-dashboard.md`](docs/wrapper-dashboard.md).
+
+---
+
### If you like this project, please support it by starring it on GitHub (⭐). It helps the project get discovered.
---
diff --git a/docs/wrapper-dashboard.md b/docs/wrapper-dashboard.md
new file mode 100644
index 00000000..af2233ce
--- /dev/null
+++ b/docs/wrapper-dashboard.md
@@ -0,0 +1,73 @@
+# Wrapper Dashboard Architecture
+
+The wrapper dashboard is a local operational UI for the existing release
+package. It is designed for users who have the prebuilt client binary and want
+to edit configuration, inspect resolver health, and collect optimizer context
+without changing the Go client.
+
+## Design Goals
+
+- Preserve the headless client binary interface: TOML config, resolver file,
+ stdout logs, and process signals.
+- Keep all config edits explicit. Recommendations are previewed before writing.
+- Avoid server-side encryption changes unless the matching server config is
+ coordinated.
+- Keep the UI dense and operational, with Farsi/RTL support possible from the
+ same view model.
+
+## Runtime Model
+
+The dashboard server runs from `tools/wrapper-dashboard` and points at a release
+package root through `MDVPN_DASHBOARD_ROOT`.
+
+```text
+Browser
+ -> dashboard HTTP server
+ -> client_config.toml / client_resolvers.txt
+ -> MasterDnsVPN client child process
+ -> stdout log parser
+ -> telemetry and rule recommendations
+```
+
+The Go client still owns all tunnel behavior. The wrapper only controls files,
+process lifecycle, and derived telemetry.
+
+## Modules
+
+- `server.js` starts the dashboard.
+- `src/server/app.js` owns the current API routes and shared runtime state.
+- `src/server/paths.js` separates the dashboard app root from the release
+ package root.
+- `src/server/static.js` serves dashboard assets from the tool directory.
+- `public/app.js` bootstraps the browser UI.
+- `public/js/views/*` contains one renderer/controller per visible section.
+
+Further extraction can move `app.js` route groups into dedicated services
+without changing public endpoints.
+
+## API Stability
+
+Existing local endpoints are kept stable:
+
+- `GET /api/state`
+- `GET /api/editor`
+- `POST /api/save-editor`
+- `GET /api/telemetry`
+- `GET /api/recommendations/dynamic`
+- `POST /api/recommendations/preview-patch`
+- `POST /api/resolvers/export-good`
+- `GET /api/events`
+
+Consumers should treat these as local-only wrapper APIs, not as a remote
+management API for the Go client.
+
+## Safety Rules
+
+- Never export raw `ENCRYPTION_KEY` in AI context.
+- Never auto-apply `DOMAINS`, `ENCRYPTION_KEY`, or `DATA_ENCRYPTION_METHOD`.
+- Do not commit local logs, resolver exports, or real client configs.
+- Keep generated telemetry in the release package root, not the source repo.
+- Treat filtered and unfiltered network logs as separate optimization contexts.
+ If one log contains harsh-network failures followed by mass resolver
+ reactivation, the dashboard marks it as mixed and defaults recommendations to
+ the filtered-safe view until the user explicitly selects another context.
diff --git a/tools/wrapper-dashboard/README.md b/tools/wrapper-dashboard/README.md
new file mode 100644
index 00000000..5579964a
--- /dev/null
+++ b/tools/wrapper-dashboard/README.md
@@ -0,0 +1,101 @@
+# MasterDnsVPN Wrapper Dashboard
+
+Local web dashboard for managing a release-package client without modifying the
+Go binary. The dashboard runs as a Node.js wrapper, edits the client TOML and
+resolver files, starts/stops the client process, parses stdout logs, and shows
+optimizer recommendations.
+
+## Run Against a Release Package
+
+```bash
+cd tools/wrapper-dashboard
+MDVPN_DASHBOARD_ROOT=/path/to/MasterDnsVPN_Client_Linux_AMD64 npm start
+```
+
+Open:
+
+```text
+http://127.0.0.1:18080
+```
+
+Use a different dashboard port:
+
+```bash
+MDVPN_DASHBOARD_ROOT=/path/to/release MDVPN_DASHBOARD_PORT=18081 npm start
+```
+
+If `MDVPN_DASHBOARD_ROOT` is omitted, the dashboard uses the current working
+directory as the release package root.
+
+## Required Release Package Files
+
+The selected root must contain:
+
+- `client_config.toml`
+- `client_resolvers.txt`
+- `.config-schema.json`
+- `MasterDnsVPN_Client_Linux_AMD64_v...`
+
+Optional files created or read by the dashboard:
+
+- `dashboard-client.log`
+- `optimizer-rules.json`
+- `dashboard-runs.json`
+- `known-good-resolvers.txt`
+
+These runtime files belong in the local release package and should not be
+committed unless intentionally adding fixtures.
+
+## Features
+
+- Config and resolver editor with preview-before-apply behavior.
+- Balanced optimizer recommendations.
+- Dynamic AI optimizer from parsed logs and rules.
+- Filtered/unfiltered/unknown network-context tagging so mixed logs do not
+ accidentally tune the filtered profile from unfiltered resolver recovery.
+- Live patch status for each recommendation, showing whether a TOML change is
+ pending, already applied, or advisory-only.
+- Known-good resolver export.
+- Process start/stop wrapper for the headless client.
+- SSE-driven UI refresh.
+- Tabs for Overview, Optimizer, Editor, Resolvers, and Logs.
+
+The dashboard never silently changes `DOMAINS`, `ENCRYPTION_KEY`, or
+`DATA_ENCRYPTION_METHOD`.
+
+## Structure
+
+```text
+server.js # bootstrap
+src/server/ # wrapper server and services
+src/server/paths.js # release root, app root, file paths
+src/server/static.js # static public file serving
+src/server/utils/http.js # JSON/text response helpers
+public/app.js # browser bootstrap
+public/js/ # browser modules
+public/js/views/ # one view module per dashboard section
+```
+
+The project intentionally uses vanilla Node ESM and browser ESM. There is no
+frontend build step.
+
+## Validation
+
+```bash
+node --check server.js
+node --check src/server/app.js
+node --check src/server/paths.js
+node --check src/server/static.js
+node --check src/server/utils/http.js
+node --check public/app.js
+for f in public/js/*.js public/js/views/*.js; do node --check "$f"; done
+```
+
+Smoke-test the API:
+
+```bash
+curl -s http://127.0.0.1:18080/api/state
+curl -s http://127.0.0.1:18080/api/telemetry
+curl -s http://127.0.0.1:18080/api/recommendations/dynamic
+curl -s 'http://127.0.0.1:18080/api/resolvers?limit=5'
+```
diff --git a/tools/wrapper-dashboard/package.json b/tools/wrapper-dashboard/package.json
new file mode 100644
index 00000000..10bdb9de
--- /dev/null
+++ b/tools/wrapper-dashboard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "masterdnsvpn-wrapper-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Wrapper-first GUI dashboard for the MasterDnsVPN client distribution package.",
+ "type": "module",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/tools/wrapper-dashboard/public/app.js b/tools/wrapper-dashboard/public/app.js
new file mode 100644
index 00000000..35f075c7
--- /dev/null
+++ b/tools/wrapper-dashboard/public/app.js
@@ -0,0 +1,180 @@
+import { get, post, postJson } from "./js/api.js";
+import { $, escapeHtml } from "./js/dom.js";
+import { connectEvents } from "./js/events.js";
+import { dashboardState, setEditorDirty, setResolverFilter, setState } from "./js/state.js";
+import { initTabs } from "./js/tabs.js";
+import { renderAIOptimizer, setAIStatus } from "./js/views/ai-optimizer.js";
+import { renderRecommendations, renderProfileHint } from "./js/views/config-optimizer.js";
+import {
+ parseResolvers,
+ renderEditorFiles,
+ renderSaveResult,
+ setEditorStatus,
+ updateEditorResolverCount
+} from "./js/views/editor.js";
+import { renderLogs } from "./js/views/logs.js";
+import { renderMonitor } from "./js/views/monitor.js";
+import { renderOverview } from "./js/views/overview.js";
+import { renderStages } from "./js/views/plan-board.js";
+import { renderResolvers } from "./js/views/resolvers.js";
+import { renderValidation } from "./js/views/validation.js";
+
+window.addEventListener("DOMContentLoaded", () => {
+ bindControls();
+ initTabs();
+ connectEvents({
+ refresh,
+ loadEditor,
+ isEditorDirty: () => dashboardState.editorDirty
+ });
+ refresh();
+ loadEditor();
+ setInterval(() => {
+ if (!document.hidden) refresh();
+ }, 5000);
+});
+
+function bindControls() {
+ $("refreshBtn").addEventListener("click", refresh);
+ $("startBtn").addEventListener("click", () => post("/api/start").then(refresh));
+ $("stopBtn").addEventListener("click", () => post("/api/stop").then(refresh));
+ $("previewBtn").addEventListener("click", previewBalanced);
+ $("applyBtn").addEventListener("click", () => post("/api/apply-balanced").then(refresh));
+ $("smokeBtn").addEventListener("click", () => post("/api/smoke").then(refresh));
+ $("previewDynamicBtn").addEventListener("click", previewDynamicPatch);
+ $("copyAIContextBtn").addEventListener("click", copyAIContext);
+ $("exportGoodResolversBtn").addEventListener("click", () => exportGoodResolvers());
+ $("exportFilteredResolversBtn").addEventListener("click", () => exportGoodResolvers("filtered"));
+ $("exportUnfilteredResolversBtn").addEventListener("click", () => exportGoodResolvers("unfiltered"));
+ $("networkContextSelect").addEventListener("change", setNetworkContext);
+ $("reloadEditorBtn").addEventListener("click", loadEditor);
+ $("stripCommentsBtn").addEventListener("click", stripConfigComments);
+ $("dedupeResolversBtn").addEventListener("click", dedupeResolverEditor);
+ $("saveEditorBtn").addEventListener("click", saveEditor);
+ $("configEditor").addEventListener("input", () => {
+ setEditorDirty(true);
+ setEditorStatus("Unsaved TOML edits.", "");
+ });
+ $("resolverEditor").addEventListener("input", () => {
+ setEditorDirty(true);
+ updateEditorResolverCount();
+ setEditorStatus("Unsaved resolver edits.", "");
+ });
+ $("resolverFilter").addEventListener("input", (event) => {
+ setResolverFilter(event.target.value);
+ loadResolvers();
+ });
+ document.querySelectorAll(".profile-tabs button").forEach((button) => {
+ button.addEventListener("click", () => {
+ document.querySelectorAll(".profile-tabs button").forEach((item) => item.classList.remove("active"));
+ button.classList.add("active");
+ renderProfileHint(button.dataset.profile);
+ });
+ });
+}
+
+async function refresh() {
+ setState(await get("/api/state"));
+ render();
+ await loadResolvers();
+}
+
+function render() {
+ const state = dashboardState.state;
+ $("subtitle").textContent = `${state.facts.version || "Unknown version"} | ${state.app.root}`;
+ renderOverview(state);
+ renderStages(state);
+ renderRecommendations(state);
+ renderAIOptimizer(state, { previewDynamicPatch, ignoreRecommendation });
+ renderValidation(state);
+ renderLogs(state);
+ renderMonitor(state);
+ updateEditorResolverCount();
+}
+
+async function loadResolvers() {
+ const data = await get(`/api/resolvers?offset=${dashboardState.resolverOffset}&limit=250&q=${encodeURIComponent(dashboardState.resolverFilter)}`);
+ renderResolvers(data);
+}
+
+async function loadEditor() {
+ const data = await get("/api/editor");
+ renderEditorFiles(data);
+ setEditorDirty(false);
+}
+
+async function saveEditor() {
+ const payload = {
+ configText: $("configEditor").value,
+ resolverText: $("resolverEditor").value,
+ restart: $("restartAfterSave").checked
+ };
+ const result = await postJson("/api/save-editor", payload);
+ renderSaveResult(result);
+ if (!result.saved) return;
+ setEditorDirty(false);
+ await refresh();
+ await loadResolvers();
+}
+
+async function stripConfigComments() {
+ const result = await postJson("/api/strip-config-comments", { configText: $("configEditor").value });
+ $("configEditor").value = result.configText;
+ setEditorDirty(true);
+ setEditorStatus("TOML comments removed in the editor. Save to write the file.", "");
+}
+
+function dedupeResolverEditor() {
+ const resolvers = parseResolvers($("resolverEditor").value);
+ $("resolverEditor").value = resolvers.join("\n") + "\n";
+ setEditorDirty(true);
+ updateEditorResolverCount();
+ setEditorStatus("Resolver duplicates removed in the editor. Save to write the file.", "");
+}
+
+async function previewBalanced() {
+ const data = await get("/api/preview-balanced");
+ $("diff").textContent = data.diff.length
+ ? data.diff.map((row) => `${row.type === "add" ? "+" : "-"}${row.line}: ${row.text}`).join("\n")
+ : "Balanced recommendations are already applied.";
+}
+
+async function previewDynamicPatch(ids = null) {
+ const selectedIds = Array.isArray(ids)
+ ? ids
+ : (dashboardState.state.dynamicRecommendations?.recommendations || []).map((item) => item.id);
+ const data = await postJson("/api/recommendations/preview-patch", { ids: selectedIds });
+ $("dynamicDiff").textContent = data.diff.length
+ ? data.diff.map((row) => `${row.type === "add" ? "+" : "-"}${row.line}: ${row.text}`).join("\n")
+ : data.explanation || "Selected dynamic recommendations do not change TOML.";
+ setAIStatus(data.explanation || (data.changed ? "Preview generated. Nothing was saved." : "No TOML change for this selection."), data.changed ? "ok" : "");
+}
+
+async function copyAIContext() {
+ const data = await get("/api/ai-context");
+ const text = JSON.stringify(data, null, 2);
+ try {
+ await navigator.clipboard.writeText(text);
+ setAIStatus("AI context copied with ENCRYPTION_KEY masked.", "ok");
+ } catch {
+ $("dynamicDiff").textContent = text;
+ setAIStatus("Clipboard unavailable. AI context rendered below.", "");
+ }
+}
+
+async function exportGoodResolvers(context = null) {
+ const data = await postJson("/api/resolvers/export-good", { context });
+ setAIStatus(`Exported ${data.count} ${data.context} known-good resolvers to ${data.file}.`, "ok");
+}
+
+async function setNetworkContext(event) {
+ const data = await postJson("/api/network-context", { context: event.target.value });
+ setAIStatus(`Network context set to ${data.selected}.`, data.mixed ? "warn" : "ok");
+ await refresh();
+}
+
+async function ignoreRecommendation(id) {
+ await postJson("/api/recommendations/ignore", { id });
+ setAIStatus(`Ignored ${id}.`, "");
+ await refresh();
+}
diff --git a/tools/wrapper-dashboard/public/index.html b/tools/wrapper-dashboard/public/index.html
new file mode 100644
index 00000000..eb87af42
--- /dev/null
+++ b/tools/wrapper-dashboard/public/index.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+ MasterDnsVPN Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Config Optimizer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI Optimizer
+
+
+ unknown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuration Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI Monitor
+
+
+
Overview
Plan Stages
Config Optimizer
+
Resolver Pool
Run Logs
AI Monitor
+
Profiles
Apply Preview
Smoke Tests
+
+ پشتیبانی راست به چپ برای رابط فارسی آماده است.
+
+
+
+
+
+
diff --git a/tools/wrapper-dashboard/public/js/api.js b/tools/wrapper-dashboard/public/js/api.js
new file mode 100644
index 00000000..994553b6
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/api.js
@@ -0,0 +1,21 @@
+export async function get(path) {
+ const response = await fetch(path);
+ if (!response.ok) throw new Error(await response.text());
+ return response.json();
+}
+
+export async function post(path) {
+ const response = await fetch(path, { method: "POST" });
+ if (!response.ok) throw new Error(await response.text());
+ return response.json();
+}
+
+export async function postJson(path, body) {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ if (!response.ok) throw new Error(await response.text());
+ return response.json();
+}
diff --git a/tools/wrapper-dashboard/public/js/dom.js b/tools/wrapper-dashboard/public/js/dom.js
new file mode 100644
index 00000000..75e0c6db
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/dom.js
@@ -0,0 +1,11 @@
+export const $ = (id) => document.getElementById(id);
+
+export function escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, (char) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'"
+ }[char]));
+}
diff --git a/tools/wrapper-dashboard/public/js/events.js b/tools/wrapper-dashboard/public/js/events.js
new file mode 100644
index 00000000..d1b00a56
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/events.js
@@ -0,0 +1,8 @@
+export function connectEvents({ refresh, loadEditor, isEditorDirty }) {
+ const events = new EventSource("/api/events");
+ events.onmessage = () => {
+ refresh();
+ if (!isEditorDirty()) loadEditor();
+ };
+ return events;
+}
diff --git a/tools/wrapper-dashboard/public/js/state.js b/tools/wrapper-dashboard/public/js/state.js
new file mode 100644
index 00000000..ab44b943
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/state.js
@@ -0,0 +1,19 @@
+export const dashboardState = {
+ state: null,
+ resolverOffset: 0,
+ resolverFilter: "",
+ editorDirty: false
+};
+
+export function setState(nextState) {
+ dashboardState.state = nextState;
+}
+
+export function setResolverFilter(value) {
+ dashboardState.resolverFilter = value;
+ dashboardState.resolverOffset = 0;
+}
+
+export function setEditorDirty(value) {
+ dashboardState.editorDirty = value;
+}
diff --git a/tools/wrapper-dashboard/public/js/tabs.js b/tools/wrapper-dashboard/public/js/tabs.js
new file mode 100644
index 00000000..b0c01073
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/tabs.js
@@ -0,0 +1,32 @@
+import { escapeHtml } from "./dom.js";
+
+const tabs = [
+ ["overview", "Overview"],
+ ["optimizer", "Optimizer"],
+ ["editor", "Editor"],
+ ["resolvers", "Resolvers"],
+ ["logs", "Logs"]
+];
+
+export function initTabs() {
+ const nav = document.querySelector("[data-tabs]");
+ if (!nav) return;
+ nav.innerHTML = tabs.map(([id, label]) => `
+
+ `).join("");
+ nav.querySelectorAll("[data-tab]").forEach((button) => {
+ button.addEventListener("click", () => activateTab(button.dataset.tab));
+ });
+ const initial = location.hash.replace("#", "") || "overview";
+ activateTab(tabs.some(([id]) => id === initial) ? initial : "overview", false);
+}
+
+export function activateTab(tab, updateHash = true) {
+ document.querySelectorAll("[data-tab]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.tab === tab);
+ });
+ document.querySelectorAll("[data-tab-panel]").forEach((panel) => {
+ panel.hidden = panel.dataset.tabPanel !== tab;
+ });
+ if (updateHash) history.replaceState(null, "", `#${tab}`);
+}
diff --git a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js
new file mode 100644
index 00000000..00aec34c
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js
@@ -0,0 +1,108 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderAIOptimizer(state, actions) {
+ const telemetry = state.telemetry || {};
+ const context = telemetry.networkContext || state.dynamicRecommendations?.networkContext || {};
+ const selector = $("networkContextSelect");
+ if (selector) selector.value = context.selected || "unknown";
+ const badge = $("networkContextBadge");
+ if (badge) {
+ badge.textContent = context.mixed ? "mixed log context detected" : (context.detected || "unknown");
+ badge.className = `tag ${context.mixed ? "warn" : context.detected === "filtered" ? "running" : context.detected === "unfiltered" ? "ok" : ""}`;
+ }
+ const notes = context.notes || [];
+ $("networkContextNotes").innerHTML = notes.length
+ ? notes.map((note) => `${context.mixed ? "warn" : "info"} ${escapeHtml(note)}
`).join("")
+ : "";
+ const scores = telemetry.scores || {};
+ const scoreRows = [
+ ["Health", scores.healthScore],
+ ["Active Resolvers", scores.activeResolverScore],
+ ["MTU", scores.mtuScore],
+ ["Compression", scores.compressionScore],
+ ["Stream Pressure", scores.streamPressureScore]
+ ];
+ $("aiScores").innerHTML = scoreRows.map(([label, value]) => {
+ const normalized = Number.isFinite(value) ? value : 0;
+ return `
+
+
${escapeHtml(label)}${normalized}/100
+
+
+ `;
+ }).join("");
+
+ const dynamic = state.dynamicRecommendations?.recommendations || [];
+ $("dynamicRecommendations").innerHTML = dynamic.length ? dynamic.map((item) => {
+ const evidenceId = `evidence-${item.id}`;
+ const patchKeys = Object.keys(item.configPatch || {});
+ return `
+
+
+ ${escapeHtml(item.id)}
+ ${escapeHtml(item.severity)}
+
+ ${escapeHtml(item.recommendation || item.condition || "Recommendation generated from current telemetry.")}
+
+ confidence: ${escapeHtml(item.confidence || "medium")}
+ ${item.requiresRestart ? "restart required" : "live observation"}
+ ${patchLabel(item, patchKeys)}
+
+ ${renderPatchPreview(item.patchPreview || [])}
+
+
+
+
+
+
+ ${(item.evidence || []).map((entry) => `- ${escapeHtml(String(entry))}
`).join("") || "- No evidence captured yet.
"}
+
+
+ `;
+ }).join("") : `ok No dynamic recommendations yet.
`;
+
+ $("dynamicRecommendations").querySelectorAll("[data-preview]").forEach((button) => {
+ button.addEventListener("click", () => actions.previewDynamicPatch([button.dataset.preview]));
+ });
+ $("dynamicRecommendations").querySelectorAll("[data-why]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const target = $(button.dataset.why);
+ if (target) target.hidden = !target.hidden;
+ });
+ });
+ $("dynamicRecommendations").querySelectorAll("[data-ignore]").forEach((button) => {
+ button.addEventListener("click", async () => {
+ await actions.ignoreRecommendation(button.dataset.ignore);
+ });
+ });
+}
+
+export function setAIStatus(message, type) {
+ $("aiOptimizerStatus").textContent = message;
+ $("aiOptimizerStatus").className = `editor-status ${type || ""}`;
+}
+
+function severityTag(severity) {
+ if (severity === "error") return "error";
+ if (severity === "warn") return "warn";
+ return "ok";
+}
+
+function patchLabel(item, patchKeys) {
+ if (!patchKeys.length || item.patchState === "advisory") return "advisory only";
+ if (item.patchState === "already-applied") return `already applied: ${escapeHtml(patchKeys.join(", "))}`;
+ return `will change: ${escapeHtml(patchKeys.join(", "))}`;
+}
+
+function renderPatchPreview(items) {
+ if (!items.length) return "";
+ return `
+
+ ${items.map((item) => `
+
+ ${escapeHtml(item.key)}: ${escapeHtml(JSON.stringify(item.current))} -> ${escapeHtml(JSON.stringify(item.recommended))}
+
+ `).join("")}
+
+ `;
+}
diff --git a/tools/wrapper-dashboard/public/js/views/config-optimizer.js b/tools/wrapper-dashboard/public/js/views/config-optimizer.js
new file mode 100644
index 00000000..124a6efc
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/config-optimizer.js
@@ -0,0 +1,40 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderRecommendations(state) {
+ const pending = state.recommendations.items.filter((item) => item.current !== item.recommended && !item.requiresServerChange).length;
+ const coordinated = state.recommendations.items.filter((item) => item.requiresServerChange).length;
+ $("optimizerSummary").innerHTML = `
+
+ ${pending ? "pending" : "applied"}
+ Balanced direct settings ${pending ? `have ${pending} pending TOML change(s)` : "are already applied"}.
+ ${coordinated ? `${coordinated} security recommendation requires matching server config.` : ""}
+
+ `;
+ $("recommendations").innerHTML = state.recommendations.items.map((item) => {
+ const changed = item.current !== item.recommended;
+ const tag = item.requiresServerChange ? "warn" : changed ? "running" : "ok";
+ const label = item.requiresServerChange ? "server coordinated" : changed ? "pending" : "applied";
+ return `
+
+
+ ${escapeHtml(item.key)}
+ ${label}
+
+ Current -> Recommended
+ ${escapeHtml(JSON.stringify(item.current))} -> ${escapeHtml(JSON.stringify(item.recommended))}
+ ${escapeHtml(item.rationale)}
+
+ `;
+ }).join("");
+}
+
+export function renderProfileHint(profile) {
+ const hints = {
+ balanced: "Balanced is the active direct-apply profile.",
+ speed: "Speed: use lower duplication only after logs show stable resolver behavior.",
+ reliability: "Reliability: increase duplication and retries when retransmits/timeouts dominate.",
+ scanner: "Scanner: use equal MTU bounds and clean resolver export for dedicated resolver testing.",
+ debug: "Debug: temporarily raise LOG_LEVEL to DEBUG while collecting troubleshooting logs."
+ };
+ $("diff").textContent = hints[profile] || "";
+}
diff --git a/tools/wrapper-dashboard/public/js/views/editor.js b/tools/wrapper-dashboard/public/js/views/editor.js
new file mode 100644
index 00000000..e82902e8
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/editor.js
@@ -0,0 +1,41 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function setEditorStatus(message, type) {
+ $("editorStatus").textContent = message;
+ $("editorStatus").className = `editor-status ${type || ""}`;
+}
+
+export function parseResolvers(text) {
+ const seen = new Set();
+ const resolvers = [];
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#")) continue;
+ if (seen.has(line)) continue;
+ seen.add(line);
+ resolvers.push(line);
+ }
+ return resolvers;
+}
+
+export function updateEditorResolverCount() {
+ const el = $("editorResolverCount");
+ if (!el) return;
+ el.textContent = `(${parseResolvers($("resolverEditor").value || "").length})`;
+}
+
+export function renderEditorFiles(data) {
+ $("configEditor").value = data.configText;
+ $("resolverEditor").value = data.resolverText;
+ updateEditorResolverCount();
+ setEditorStatus("Editor loaded from client_config.toml and client_resolvers.txt.", "ok");
+}
+
+export function renderSaveResult(result) {
+ if (!result.saved) {
+ setEditorStatus(result.errors.map((entry) => escapeHtml(entry)).join(" "), "error");
+ return;
+ }
+ const warningText = result.warnings?.length ? ` Warnings: ${result.warnings.join(" ")}` : "";
+ setEditorStatus(`${result.message} Resolvers saved: ${result.resolverCount}.${warningText}`, "ok");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/logs.js b/tools/wrapper-dashboard/public/js/views/logs.js
new file mode 100644
index 00000000..0f3baa8f
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/logs.js
@@ -0,0 +1,8 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderLogs(state) {
+ $("events").innerHTML = state.parsedEvents.slice(-8).reverse().map((event) => `
+ ${escapeHtml(event.type)} ${escapeHtml(event.line)}
+ `).join("") || `No parsed run events yet.
`;
+ $("logs").textContent = state.logs.map((entry) => `${entry.time} ${entry.source} ${entry.line}`).join("\n");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/monitor.js b/tools/wrapper-dashboard/public/js/views/monitor.js
new file mode 100644
index 00000000..c5688a65
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/monitor.js
@@ -0,0 +1,7 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderMonitor(state) {
+ $("monitor").innerHTML = state.recommendations.monitor.map((item) => `
+ ${escapeHtml(item)}
+ `).join("");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/overview.js b/tools/wrapper-dashboard/public/js/views/overview.js
new file mode 100644
index 00000000..f9812b14
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/overview.js
@@ -0,0 +1,15 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderOverview(state) {
+ const rows = [
+ ["Status", state.runtime.status],
+ ["PID", state.runtime.pid || "-"],
+ ["Uptime", `${state.runtime.uptimeSeconds}s`],
+ ["Proxy", state.facts.proxy],
+ ["Resolvers", state.facts.resolverCount],
+ ["Local DNS", state.facts.localDnsEnabled ? "enabled" : "disabled"]
+ ];
+ $("overview").innerHTML = rows.map(([label, value]) => `
+ ${escapeHtml(label)}${escapeHtml(String(value))}
+ `).join("");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/plan-board.js b/tools/wrapper-dashboard/public/js/views/plan-board.js
new file mode 100644
index 00000000..4383c648
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/plan-board.js
@@ -0,0 +1,14 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderStages(state) {
+ $("stages").innerHTML = state.stages.map((stage) => `
+
+
+ ${escapeHtml(stage.title)}
+ ${escapeHtml(stage.status)}
+
+ ${escapeHtml(stage.target)}
+
+
+ `).join("");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/resolvers.js b/tools/wrapper-dashboard/public/js/views/resolvers.js
new file mode 100644
index 00000000..cf8142da
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/resolvers.js
@@ -0,0 +1,8 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderResolvers(data) {
+ $("resolverCount").textContent = `${data.filtered} shown from ${data.total}`;
+ $("resolvers").innerHTML = data.items.map((item) => `
+ #${item.index}${escapeHtml(item.value)}
+ `).join("");
+}
diff --git a/tools/wrapper-dashboard/public/js/views/validation.js b/tools/wrapper-dashboard/public/js/views/validation.js
new file mode 100644
index 00000000..c7010271
--- /dev/null
+++ b/tools/wrapper-dashboard/public/js/views/validation.js
@@ -0,0 +1,11 @@
+import { $, escapeHtml } from "../dom.js";
+
+export function renderValidation(state) {
+ const entries = [
+ ...state.validations.errors.map((text) => ({ type: "error", text })),
+ ...state.validations.warnings.map((text) => ({ type: "warn", text }))
+ ];
+ $("validation").innerHTML = entries.length ? entries.map((entry) => `
+ ${entry.type} ${escapeHtml(entry.text)}
+ `).join("") : `ok No validation errors.
`;
+}
diff --git a/tools/wrapper-dashboard/public/styles.css b/tools/wrapper-dashboard/public/styles.css
new file mode 100644
index 00000000..3b1b6ef1
--- /dev/null
+++ b/tools/wrapper-dashboard/public/styles.css
@@ -0,0 +1,528 @@
+:root {
+ color-scheme: light;
+ --bg: #f4f6f8;
+ --panel: #ffffff;
+ --text: #15202b;
+ --muted: #65717f;
+ --line: #d9e0e7;
+ --accent: #0f766e;
+ --accent-2: #2457a7;
+ --danger: #b42318;
+ --warn: #b54708;
+ --ok: #0b7a38;
+ --shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 14px;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 76px;
+ padding: 16px 24px;
+ background: #ffffff;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+h2 {
+ font-size: 15px;
+ margin-bottom: 12px;
+}
+
+#subtitle {
+ margin-top: 4px;
+ color: var(--muted);
+}
+
+button {
+ border: 1px solid var(--line);
+ background: #fff;
+ color: var(--text);
+ min-height: 34px;
+ padding: 0 12px;
+ border-radius: 6px;
+ font: inherit;
+ cursor: pointer;
+ transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, transform 140ms ease;
+}
+
+button:hover {
+ border-color: var(--accent);
+ transform: translateY(-1px);
+}
+
+button.active,
+#startBtn {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+#stopBtn {
+ color: var(--danger);
+}
+
+#saveEditorBtn {
+ background: var(--accent-2);
+ border-color: var(--accent-2);
+ color: #fff;
+}
+
+.top-actions,
+.optimizer-actions,
+.profile-tabs,
+.resolver-tools,
+.editor-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.tabbar {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px 0;
+ background: var(--bg);
+ position: sticky;
+ top: 76px;
+ z-index: 9;
+}
+
+.tabbar button.active {
+ background: var(--accent-2);
+ border-color: var(--accent-2);
+ color: #fff;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ padding: 16px;
+}
+
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+ padding: 16px;
+ min-width: 0;
+ animation: panel-in 160ms ease;
+}
+
+@keyframes panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.wide {
+ grid-column: span 2;
+}
+
+.full {
+ grid-column: 1 / -1;
+}
+
+.overview {
+ grid-column: span 1;
+}
+
+.metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.metric,
+.recommendation,
+.validation-item,
+.event,
+.stage {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 10px;
+ background: #fbfcfd;
+}
+
+.metric strong,
+.recommendation strong {
+ display: block;
+ font-size: 12px;
+ color: var(--muted);
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.stage-list,
+.recommendations,
+.event-list,
+#monitor,
+#validation {
+ display: grid;
+ gap: 8px;
+}
+
+.score-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(120px, 1fr));
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.score {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 10px;
+ background: #fbfcfd;
+}
+
+.score header,
+.rec-actions,
+.rec-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.score header {
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.score strong {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.ai-actions {
+ margin-bottom: 10px;
+}
+
+.context-tools {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+
+.context-tools label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.context-tools select {
+ min-height: 34px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fff;
+ color: var(--text);
+ padding: 0 8px;
+ font: inherit;
+}
+
+.context-notes {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.dynamic-rec p {
+ margin-top: 8px;
+ color: var(--text);
+}
+
+.rec-meta {
+ margin-top: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.rec-actions {
+ margin-top: 10px;
+}
+
+.patch-preview {
+ display: grid;
+ gap: 6px;
+ margin-top: 10px;
+}
+
+.patch-preview code {
+ display: block;
+ width: fit-content;
+ max-width: 100%;
+ white-space: normal;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 5px 8px;
+ background: #eef6f5;
+ color: var(--ok);
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.patch-preview code.pending {
+ background: #fff7ed;
+ color: var(--warn);
+}
+
+.evidence {
+ margin: 10px 0 0;
+ padding-left: 20px;
+ color: var(--muted);
+}
+
+.stage header,
+.recommendation header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.bar {
+ height: 8px;
+ background: #e8edf2;
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 8px;
+}
+
+.bar span {
+ display: block;
+ height: 100%;
+ background: var(--accent-2);
+}
+
+.tag {
+ border-radius: 999px;
+ padding: 2px 8px;
+ font-size: 12px;
+ background: #e8edf2;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.tag.done,
+.tag.ok {
+ color: var(--ok);
+ background: #e8f5ee;
+}
+
+.tag.blocked,
+.tag.error {
+ color: var(--danger);
+ background: #fdeceb;
+}
+
+.tag.running,
+.tag.warn {
+ color: var(--warn);
+ background: #fff3e5;
+}
+
+.diff,
+.logs {
+ max-height: 280px;
+ overflow: auto;
+ background: #111827;
+ color: #f9fafb;
+ border-radius: 6px;
+ padding: 12px;
+ white-space: pre-wrap;
+ line-height: 1.45;
+}
+
+.diff:empty {
+ display: none;
+}
+
+.editor-toolbar {
+ margin-bottom: 10px;
+}
+
+.checkline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 34px;
+ color: var(--muted);
+}
+
+.editor-status {
+ min-height: 24px;
+ margin-bottom: 10px;
+ color: var(--muted);
+}
+
+.editor-status.ok {
+ color: var(--ok);
+}
+
+.editor-status.error {
+ color: var(--danger);
+}
+
+.editor-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
+ gap: 12px;
+}
+
+.editor-pane {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.editor-pane span {
+ font-weight: 600;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.editor-pane textarea {
+ width: 100%;
+ min-height: 440px;
+ resize: vertical;
+ border: 1px solid #1f2937;
+ border-radius: 6px;
+ background: #111827;
+ color: #f9fafb;
+ padding: 12px;
+ font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+input[type="search"] {
+ min-height: 34px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0 10px;
+ font: inherit;
+ min-width: 240px;
+}
+
+.resolver-list {
+ height: 360px;
+ overflow: auto;
+ margin-top: 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+}
+
+.resolver-row {
+ display: grid;
+ grid-template-columns: 80px 1fr;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--line);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.resolver-row:nth-child(odd) {
+ background: #fbfcfd;
+}
+
+.schematic {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.schematic div {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ min-height: 44px;
+ display: grid;
+ place-items: center;
+ background: #f7fafc;
+ text-align: center;
+}
+
+.rtl-note {
+ margin-top: 12px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fbfcfd;
+ font-family: Tahoma, "Segoe UI", sans-serif;
+}
+
+@media (max-width: 1100px) {
+ .layout {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .editor-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .score-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 760px) {
+ .topbar,
+ .layout {
+ display: block;
+ }
+
+ .panel {
+ margin: 12px;
+ }
+
+ .top-actions {
+ margin-top: 12px;
+ }
+
+ .score-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/tools/wrapper-dashboard/server.js b/tools/wrapper-dashboard/server.js
new file mode 100644
index 00000000..179a065d
--- /dev/null
+++ b/tools/wrapper-dashboard/server.js
@@ -0,0 +1,6 @@
+import { shutdownDashboard, startDashboard } from "./src/server/app.js";
+
+startDashboard();
+
+process.on("SIGINT", shutdownDashboard);
+process.on("SIGTERM", shutdownDashboard);
diff --git a/tools/wrapper-dashboard/src/server/app.js b/tools/wrapper-dashboard/src/server/app.js
new file mode 100644
index 00000000..bab109d6
--- /dev/null
+++ b/tools/wrapper-dashboard/src/server/app.js
@@ -0,0 +1,1459 @@
+import { createServer } from "node:http";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { readdir } from "node:fs/promises";
+import { basename, join } from "node:path";
+import { spawn } from "node:child_process";
+import { createInterface } from "node:readline";
+import net from "node:net";
+import {
+ BINARY,
+ CONFIG_FILE,
+ GOOD_RESOLVERS_FILE,
+ LOG_FILE,
+ PORT,
+ RESOLVERS_FILE,
+ ROOT,
+ RULES_FILE,
+ RUNS_FILE,
+ SCHEMA_FILE
+} from "./paths.js";
+import { serveStatic } from "./static.js";
+import { sendJson } from "./utils/http.js";
+
+let clientProcess = null;
+let startedAt = null;
+let runStatus = "disconnected";
+let logLines = [];
+let parsedEvents = [];
+let metrics = defaultMetrics();
+let telemetry = defaultTelemetry();
+const ignoredRecommendationIds = new Set();
+const sseClients = new Set();
+
+const directRecommendations = [
+ {
+ key: "RESOLVER_BALANCING_STRATEGY",
+ recommended: 6,
+ profile: "balanced",
+ impact: "throughput + resolver distribution",
+ risk: "low",
+ requiresServerChange: false,
+ applyMode: "direct",
+ rationale: "Loss Then Latency balances quality and distribution better than pure least-loss on large pools."
+ },
+ {
+ key: "SAVE_MTU_SERVERS_TO_FILE",
+ recommended: true,
+ profile: "balanced",
+ impact: "maintainability + resolver reuse",
+ risk: "low",
+ requiresServerChange: false,
+ applyMode: "direct",
+ rationale: "Exports successful MTU-tested resolvers after startup."
+ },
+ {
+ key: "MTU_SERVERS_FILE_FORMAT",
+ recommended: "{IP}",
+ profile: "balanced",
+ impact: "clean resolver export",
+ risk: "low",
+ requiresServerChange: false,
+ applyMode: "direct",
+ rationale: "Produces a directly reusable one-IP-per-line resolver file."
+ },
+ {
+ key: "DATA_ENCRYPTION_METHOD",
+ recommended: 2,
+ profile: "security",
+ impact: "stronger tunnel encryption",
+ risk: "medium",
+ requiresServerChange: true,
+ applyMode: "manual-server-coordinated",
+ rationale: "ChaCha20 is a better security/performance compromise than XOR, but the server must match."
+ }
+];
+
+const stages = [
+ { id: "resource-load", title: "Resource Load", dependencies: [], target: "Load markdown, schema, TOML, resolvers, and binary version." },
+ { id: "config-validation", title: "Config Validation", dependencies: ["resource-load"], target: "Check required keys, ranges, cross-field constraints, and port conflicts." },
+ { id: "optimization-review", title: "Optimization Review", dependencies: ["config-validation"], target: "Show direct, optional, and server-coordinated recommendations." },
+ { id: "apply-preview", title: "Apply Preview", dependencies: ["optimization-review"], target: "Render a diff before changing TOML." },
+ { id: "run-observe", title: "Run & Observe", dependencies: ["apply-preview"], target: "Start/stop client and parse lifecycle log events." },
+ { id: "feedback-loop", title: "Feedback Loop", dependencies: ["run-observe"], target: "Adapt recommendations from MTU, loss, latency, and errors." }
+];
+
+export const server = createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ if (url.pathname === "/api/events") return handleEvents(req, res);
+ if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
+ return await serveStatic(req, res, url);
+ } catch (error) {
+ sendJson(res, 500, { error: error.message });
+ }
+});
+
+export function startDashboard() {
+ ensureOptimizerRulesFile();
+ loadExistingLogFile();
+ server.listen(PORT, "127.0.0.1", () => {
+ console.log(`MasterDnsVPN dashboard: http://127.0.0.1:${PORT}`);
+ });
+ return server;
+}
+
+async function handleApi(req, res, url) {
+ if (req.method === "GET" && url.pathname === "/api/state") {
+ return sendJson(res, 200, await buildState());
+ }
+ if (req.method === "GET" && url.pathname === "/api/config") {
+ return sendJson(res, 200, { config: parseToml(readText(CONFIG_FILE)), raw: readText(CONFIG_FILE) });
+ }
+ if (req.method === "GET" && url.pathname === "/api/editor") {
+ return sendJson(res, 200, {
+ configText: readText(CONFIG_FILE),
+ resolverText: readText(RESOLVERS_FILE),
+ running: Boolean(clientProcess)
+ });
+ }
+ if (req.method === "POST" && url.pathname === "/api/save-editor") {
+ const body = await readRequestJson(req);
+ const result = await saveEditor(body);
+ return sendJson(res, 200, result);
+ }
+ if (req.method === "POST" && url.pathname === "/api/strip-config-comments") {
+ const body = await readRequestJson(req);
+ const configText = removeTomlComments(String(body.configText || ""));
+ return sendJson(res, 200, { configText });
+ }
+ if (req.method === "GET" && url.pathname === "/api/recommendations") {
+ return sendJson(res, 200, buildRecommendations(parseToml(readText(CONFIG_FILE))));
+ }
+ if (req.method === "GET" && url.pathname === "/api/telemetry") {
+ return sendJson(res, 200, buildTelemetrySnapshot(parseToml(readText(CONFIG_FILE))));
+ }
+ if (req.method === "GET" && url.pathname === "/api/rules") {
+ const config = parseToml(readText(CONFIG_FILE));
+ return sendJson(res, 200, { rules: loadOptimizerRules(), evaluation: buildDynamicRecommendations(config) });
+ }
+ if (req.method === "GET" && url.pathname === "/api/recommendations/dynamic") {
+ return sendJson(res, 200, buildDynamicRecommendations(parseToml(readText(CONFIG_FILE))));
+ }
+ if (req.method === "POST" && url.pathname === "/api/network-context") {
+ const body = await readRequestJson(req);
+ return sendJson(res, 200, setNetworkContext(body.context));
+ }
+ if (req.method === "GET" && url.pathname === "/api/ai-context") {
+ return sendJson(res, 200, buildAIContext(parseToml(readText(CONFIG_FILE))));
+ }
+ if (req.method === "GET" && url.pathname === "/api/run-summaries") {
+ return sendJson(res, 200, { runs: loadRunSummaries() });
+ }
+ if (req.method === "POST" && url.pathname === "/api/recommendations/preview-patch") {
+ const body = await readRequestJson(req);
+ return sendJson(res, 200, previewRecommendationPatch(body));
+ }
+ if (req.method === "POST" && url.pathname === "/api/recommendations/ignore") {
+ const body = await readRequestJson(req);
+ if (body.id) ignoredRecommendationIds.add(String(body.id));
+ return sendJson(res, 200, { ignored: Array.from(ignoredRecommendationIds) });
+ }
+ if (req.method === "POST" && url.pathname === "/api/resolvers/export-good") {
+ const body = await readRequestJson(req);
+ return sendJson(res, 200, exportGoodResolvers(body.context));
+ }
+ if (req.method === "GET" && url.pathname === "/api/preview-balanced") {
+ return sendJson(res, 200, previewBalanced());
+ }
+ if (req.method === "POST" && url.pathname === "/api/apply-balanced") {
+ const applied = applyBalanced();
+ return sendJson(res, 200, { applied, state: await buildState() });
+ }
+ if (req.method === "POST" && url.pathname === "/api/start") {
+ return sendJson(res, 200, startClient());
+ }
+ if (req.method === "POST" && url.pathname === "/api/stop") {
+ return sendJson(res, 200, stopClient());
+ }
+ if (req.method === "POST" && url.pathname === "/api/smoke") {
+ return sendJson(res, 200, await runSmokeTest());
+ }
+ if (req.method === "GET" && url.pathname === "/api/resolvers") {
+ return sendJson(res, 200, getResolvers(url));
+ }
+ sendJson(res, 404, { error: "Not found" });
+}
+
+async function buildState() {
+ const config = parseToml(readText(CONFIG_FILE));
+ const schema = JSON.parse(readText(SCHEMA_FILE));
+ const docs = await markdownInventory();
+ const resolverCount = lineCount(readText(RESOLVERS_FILE));
+ const version = await binaryVersion();
+ const validations = await validateConfig(config, schema);
+ const recommendations = buildRecommendations(config);
+ const dynamicRecommendations = buildDynamicRecommendations(config);
+ const telemetrySnapshot = buildTelemetrySnapshot(config);
+ return {
+ app: { root: ROOT, port: PORT, binary: BINARY ? basename(BINARY) : null },
+ runtime: {
+ status: runStatus,
+ pid: clientProcess?.pid || null,
+ startedAt,
+ uptimeSeconds: startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0,
+ logFile: LOG_FILE
+ },
+ facts: {
+ version,
+ resolverCount,
+ proxy: `${config.LISTEN_IP || "127.0.0.1"}:${config.LISTEN_PORT || 18000}`,
+ localDnsEnabled: Boolean(config.LOCAL_DNS_ENABLED)
+ },
+ stages: buildStages(validations, recommendations),
+ config,
+ docs,
+ validations,
+ recommendations,
+ dynamicRecommendations,
+ telemetry: telemetrySnapshot,
+ metrics,
+ logs: logLines.slice(-200),
+ parsedEvents: parsedEvents.slice(-100),
+ schematic: [
+ ["Overview", "Plan Stages", "Config Optimizer"],
+ ["Resolver Pool", "Run Logs", "AI Monitor"],
+ ["Profiles", "Apply Preview", "Smoke Tests"]
+ ]
+ };
+}
+
+function buildStages(validations, recommendations) {
+ const directRemaining = recommendations.items.filter((item) => item.applyMode === "direct" && item.current !== item.recommended);
+ return stages.map((stage) => {
+ if (stage.id === "resource-load") return stageState(stage, "done", 100);
+ if (stage.id === "config-validation") return stageState(stage, validations.errors.length ? "blocked" : "done", validations.errors.length ? 55 : 100);
+ if (stage.id === "optimization-review") return stageState(stage, "done", 100);
+ if (stage.id === "apply-preview") return stageState(stage, directRemaining.length ? "running" : "done", directRemaining.length ? 65 : 100);
+ if (stage.id === "run-observe") return stageState(stage, clientProcess ? "running" : "pending", clientProcess ? 60 : 0);
+ return stageState(stage, metrics.runs > 0 ? "done" : "pending", metrics.runs > 0 ? 100 : 0);
+ });
+}
+
+function stageState(stage, status, progress) {
+ return { ...stage, status, progress, actions: stageActions(stage.id, status) };
+}
+
+function stageActions(id, status) {
+ if (id === "apply-preview") return ["Preview Balanced", "Apply Balanced"];
+ if (id === "run-observe") return clientProcess ? ["Stop Client"] : ["Start Client"];
+ if (id === "feedback-loop") return ["Run Smoke Test", "Review AI Monitor"];
+ return status === "blocked" ? ["Fix Validation"] : [];
+}
+
+async function validateConfig(config, schema) {
+ const errors = [];
+ const warnings = [];
+ const props = schema.properties || {};
+ for (const [key, spec] of Object.entries(props)) {
+ if ((spec.required || schema.required?.includes(key)) && config[key] === undefined) {
+ errors.push(`${key} is required.`);
+ }
+ if (config[key] === undefined) continue;
+ const value = config[key];
+ if (spec.type === "integer" && !Number.isInteger(value)) errors.push(`${key} must be an integer.`);
+ if (spec.type === "number" && typeof value !== "number") errors.push(`${key} must be a number.`);
+ if (spec.type === "boolean" && typeof value !== "boolean") errors.push(`${key} must be a boolean.`);
+ if (spec.type === "string" && typeof value !== "string") errors.push(`${key} must be a string.`);
+ if (spec.type === "array" && !Array.isArray(value)) errors.push(`${key} must be an array.`);
+ if (typeof value === "number") {
+ if (spec.minimum !== undefined && value < spec.minimum) errors.push(`${key} is below minimum ${spec.minimum}.`);
+ if (spec.maximum !== undefined && value > spec.maximum) errors.push(`${key} is above maximum ${spec.maximum}.`);
+ if (spec.exclusiveMinimum !== undefined && value <= spec.exclusiveMinimum) errors.push(`${key} must be greater than ${spec.exclusiveMinimum}.`);
+ }
+ if (spec.enum && !spec.enum.includes(value)) errors.push(`${key} is not one of ${spec.enum.join(", ")}.`);
+ }
+ if ((config.MIN_UPLOAD_MTU ?? 0) > (config.MAX_UPLOAD_MTU ?? Infinity)) errors.push("MIN_UPLOAD_MTU must be <= MAX_UPLOAD_MTU.");
+ if ((config.MIN_DOWNLOAD_MTU ?? 0) > (config.MAX_DOWNLOAD_MTU ?? Infinity)) errors.push("MIN_DOWNLOAD_MTU must be <= MAX_DOWNLOAD_MTU.");
+ if ((config.PING_AGGRESSIVE_INTERVAL_SECONDS ?? 0) >= (config.PING_LAZY_INTERVAL_SECONDS ?? Infinity)) warnings.push("PING_AGGRESSIVE_INTERVAL_SECONDS should be lower than PING_LAZY_INTERVAL_SECONDS.");
+ if ((config.PING_WARM_THRESHOLD_SECONDS ?? 0) >= (config.PING_COOL_THRESHOLD_SECONDS ?? Infinity)) warnings.push("PING_WARM_THRESHOLD_SECONDS should be lower than PING_COOL_THRESHOLD_SECONDS.");
+ if (config.DATA_ENCRYPTION_METHOD === 1) warnings.push("DATA_ENCRYPTION_METHOD=1 uses XOR. ChaCha20 is recommended only with matching server config.");
+ if (config.LOCAL_DNS_ENABLED && await isPortOpen(config.LOCAL_DNS_IP || "127.0.0.1", config.LOCAL_DNS_PORT || 53)) {
+ errors.push(`LOCAL_DNS ${config.LOCAL_DNS_IP}:${config.LOCAL_DNS_PORT} appears to be in use.`);
+ }
+ return { errors, warnings };
+}
+
+async function validateConfigText(configText) {
+ const schema = JSON.parse(readText(SCHEMA_FILE));
+ const config = parseToml(configText);
+ const validations = await validateConfig(config, schema);
+ if (!configText.trim()) validations.errors.push("Configuration cannot be empty.");
+ if (!Object.hasOwn(config, "DOMAINS")) validations.errors.push("DOMAINS is required.");
+ if (!Object.hasOwn(config, "ENCRYPTION_KEY")) validations.errors.push("ENCRYPTION_KEY is required.");
+ if (Array.isArray(config.DOMAINS) && config.DOMAINS.length === 0) validations.errors.push("DOMAINS must contain at least one domain.");
+ if (typeof config.ENCRYPTION_KEY === "string" && !config.ENCRYPTION_KEY.trim()) validations.errors.push("ENCRYPTION_KEY cannot be empty.");
+ return { config, validations };
+}
+
+async function saveEditor(body) {
+ const configText = String(body.configText ?? "");
+ const resolverText = String(body.resolverText ?? "");
+ const shouldRestart = Boolean(body.restart);
+ const resolvers = normalizeResolvers(resolverText);
+ const { config, validations } = await validateConfigText(configText);
+
+ if (resolvers.length === 0) validations.errors.push("Add at least one resolver before saving.");
+ if (validations.errors.length) {
+ return { saved: false, errors: validations.errors, warnings: validations.warnings };
+ }
+
+ writeFileSync(CONFIG_FILE, ensureTrailingNewline(configText), "utf8");
+ writeFileSync(RESOLVERS_FILE, `${resolvers.join("\n")}\n`, "utf8");
+
+ const restartResult = shouldRestart && clientProcess ? restartClient() : null;
+
+ return {
+ saved: true,
+ config,
+ resolverCount: resolvers.length,
+ warnings: validations.warnings,
+ running: Boolean(clientProcess),
+ restartResult,
+ message: clientProcess && !shouldRestart
+ ? "Saved. Restart the client for changes to affect the running process."
+ : "Saved."
+ };
+}
+
+function normalizeResolvers(text) {
+ const seen = new Set();
+ const resolvers = [];
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#")) continue;
+ if (seen.has(line)) continue;
+ seen.add(line);
+ resolvers.push(line);
+ }
+ return resolvers;
+}
+
+function buildRecommendations(config) {
+ const items = directRecommendations.map((item) => ({ ...item, current: config[item.key] }));
+ const monitor = [];
+ if (metrics.timeoutWarnings > 20) monitor.push("Many resolver timeout warnings detected; consider lowering MTU_TEST_PARALLELISM or increasing MTU_TEST_TIMEOUT.");
+ if (metrics.mtuRejected > 50 && metrics.mtuValid === 0) monitor.push("Last run rejected many resolvers with no valid MTU path. Verify DOMAINS delegation, server reachability on UDP 53, ENCRYPTION_KEY, and DATA_ENCRYPTION_METHOD before tuning performance.");
+ if (metrics.lastRejectReason === "UPLOAD_MTU") monitor.push("Upload MTU rejections dominated the last run; increasing MTU_TEST_TIMEOUT or reducing MTU_TEST_PARALLELISM may help only after server/domain reachability is confirmed.");
+ if (metrics.noValidConnections) monitor.push("The client exited because MTU testing found no valid connections.");
+ if (metrics.arqEvents > 10) monitor.push("ARQ retransmission/NACK activity is high; consider Reliability profile.");
+ if (metrics.syncedUploadMtu && metrics.syncedUploadMtu < 80) monitor.push("Synced upload MTU is low; keep AUTO_REMOVE_LOW_MTU_SERVERS enabled and export healthy resolvers.");
+ if (!monitor.length) monitor.push("No run-derived changes yet. Start the client or run a smoke test to collect feedback.");
+ return { profile: "balanced", items, monitor };
+}
+
+function buildTelemetrySnapshot(config) {
+ const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null;
+ const now = Date.now();
+ const recentStreams = telemetry.streams.timestamps.filter((time) => now - time <= 60_000);
+ const context = contextSummary();
+ const evaluationContext = effectiveNetworkContext(context.selected);
+ const activeResolvers = contextActiveResolvers(evaluationContext);
+ return {
+ generatedAt: new Date().toISOString(),
+ networkContext: context,
+ config: {
+ resolverStrategy: config.RESOLVER_BALANCING_STRATEGY,
+ packetDuplication: config.PACKET_DUPLICATION_COUNT,
+ uploadCompression: config.UPLOAD_COMPRESSION_TYPE,
+ downloadCompression: config.DOWNLOAD_COMPRESSION_TYPE,
+ minUploadMtu: config.MIN_UPLOAD_MTU,
+ maxUploadMtu: config.MAX_UPLOAD_MTU,
+ minDownloadMtu: config.MIN_DOWNLOAD_MTU,
+ maxDownloadMtu: config.MAX_DOWNLOAD_MTU,
+ mtuParallelism: config.MTU_TEST_PARALLELISM,
+ tunnelProcessWorkers: config.TUNNEL_PROCESS_WORKERS,
+ tunnelPacketTimeout: config.TUNNEL_PACKET_TIMEOUT_SECONDS
+ },
+ mtu: {
+ totalResolvers: metrics.mtuTotal,
+ validResolvers: metrics.mtuValid,
+ rejectedResolvers: metrics.mtuRejected,
+ validRatio,
+ syncedUploadMtu: metrics.syncedUploadMtu,
+ syncedDownloadMtu: metrics.syncedDownloadMtu,
+ rejectsByReason: Object.fromEntries(telemetry.mtu.rejectsByReason),
+ validTable: telemetry.mtu.validResolvers.slice(-100)
+ },
+ compression: telemetry.compression,
+ session: telemetry.session,
+ runtime: {
+ status: runStatus,
+ pid: clientProcess?.pid || null,
+ rxTxWorkers: telemetry.runtime.rxTxWorkers,
+ processWorkers: telemetry.runtime.processWorkers,
+ stateDrift: detectStateDrift()
+ },
+ streams: {
+ total: telemetry.streams.total,
+ perMinute: recentStreams.length,
+ topTargets: topEntries(telemetry.streams.targets, 12)
+ },
+ resolvers: {
+ activeTotal: activeResolvers,
+ disabled: Array.from(telemetry.resolvers.disabled.values()).slice(-100),
+ reactivated: Array.from(telemetry.resolvers.reactivated.values()).slice(-100),
+ knownGood: knownGoodResolvers(evaluationContext).slice(0, 200)
+ },
+ scores: buildOptimizerScores(validRatio, activeResolvers, recentStreams.length),
+ recentImportantEvents: importantEvents(80)
+ };
+}
+
+function buildDynamicRecommendations(config) {
+ const rules = loadOptimizerRules();
+ const recs = [];
+ const context = contextSummary();
+ const selectedContext = effectiveNetworkContext(context.selected);
+ const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null;
+ const rejectReasons = Object.fromEntries(telemetry.mtu.rejectsByReason);
+ const uploadRejects = rejectReasons.UPLOAD_MTU || 0;
+ const downloadRejects = rejectReasons.DOWNLOAD_MTU || 0;
+ const recentStreamRate = telemetry.streams.timestamps.filter((time) => Date.now() - time <= 60_000).length;
+ const activeTotal = contextActiveResolvers(selectedContext);
+ const stateDrift = detectStateDrift();
+
+ if (selectedContext !== "unfiltered" && validRatio !== null && validRatio < 0.01 && telemetry.session.initialized) {
+ recs.push(ruleRecommendation(rules, "low-valid-ratio-session-ok", {
+ configPatch: {
+ SAVE_MTU_SERVERS_TO_FILE: true,
+ MTU_SERVERS_FILE_FORMAT: "{IP}"
+ },
+ evidence: [
+ `${metrics.mtuValid || 0}/${metrics.mtuTotal || 0} resolvers passed MTU testing.`,
+ `Session initialized successfully${telemetry.session.id ? ` with ID ${telemetry.session.id}` : ""}.`
+ ]
+ }));
+ }
+
+ if (selectedContext !== "unfiltered" && uploadRejects > downloadRejects && config.MIN_UPLOAD_MTU === config.MAX_UPLOAD_MTU && config.MIN_UPLOAD_MTU <= 64) {
+ recs.push(ruleRecommendation(rules, "keep-fixed-low-upload-mtu", {
+ configPatch: {
+ MIN_UPLOAD_MTU: config.MIN_UPLOAD_MTU,
+ MAX_UPLOAD_MTU: config.MAX_UPLOAD_MTU
+ },
+ evidence: [
+ `UPLOAD_MTU rejects dominate (${uploadRejects} upload vs ${downloadRejects} download).`,
+ `Upload MTU is fixed at ${config.MIN_UPLOAD_MTU}.`
+ ]
+ }));
+ }
+
+ if (selectedContext !== "unfiltered" && downloadRejects > 0 && metrics.syncedDownloadMtu && config.MAX_DOWNLOAD_MTU > metrics.syncedDownloadMtu) {
+ recs.push(ruleRecommendation(rules, "lower-download-mtu-upper-bound", {
+ configPatch: {
+ MAX_DOWNLOAD_MTU: metrics.syncedDownloadMtu
+ },
+ evidence: [
+ `${downloadRejects} DOWNLOAD_MTU rejects were observed.`,
+ `Synced download MTU settled at ${metrics.syncedDownloadMtu}, below MAX_DOWNLOAD_MTU=${config.MAX_DOWNLOAD_MTU}.`
+ ]
+ }));
+ }
+
+ if (selectedContext !== "unfiltered" && telemetry.compression.uploadDisabledReason && telemetry.compression.effectiveUpload === "OFF") {
+ recs.push(ruleRecommendation(rules, "disable-upload-compression-low-mtu", {
+ configPatch: {
+ UPLOAD_COMPRESSION_TYPE: 0,
+ DOWNLOAD_COMPRESSION_TYPE: config.DOWNLOAD_COMPRESSION_TYPE
+ },
+ evidence: [
+ `Upload compression disabled by runtime: ${telemetry.compression.uploadDisabledReason}.`,
+ `Effective compression is Upload=${telemetry.compression.effectiveUpload}, Download=${telemetry.compression.effectiveDownload || "unknown"}.`
+ ]
+ }));
+ }
+
+ if (telemetry.resolvers.reactivated.size > 0 && selectedContext !== "filtered") {
+ recs.push(ruleRecommendation(rules, "wait-and-export-reactivated-resolvers", {
+ configPatch: {},
+ evidence: [
+ `${telemetry.resolvers.reactivated.size} resolvers reactivated after startup.`,
+ `Active resolver total reached ${telemetry.context.reactivationBurst.maxActive || activeTotal}.`
+ ]
+ }));
+ }
+
+ if (telemetry.resolvers.disabled.size > 0) {
+ recs.push(ruleRecommendation(rules, "downgrade-lossy-resolvers", {
+ configPatch: {},
+ evidence: [
+ `${telemetry.resolvers.disabled.size} resolvers disabled during runtime.`,
+ "Disabled resolvers will be excluded from known-good export."
+ ]
+ }));
+ }
+
+ if (telemetry.session.initialized && recentStreamRate > 60 && activeTotal > 0 && activeTotal < 10) {
+ recs.push(ruleRecommendation(rules, "high-stream-rate-low-active-resolvers", {
+ configPatch: {},
+ evidence: [
+ `${recentStreamRate} SOCKS streams opened in the last minute.`,
+ `Only ${activeTotal} active resolvers are currently known.`
+ ]
+ }));
+ }
+
+ if (stateDrift) {
+ recs.push(ruleRecommendation(rules, "wrapper-state-drift", {
+ configPatch: {},
+ evidence: [
+ `Dashboard state is ${runStatus} while PID ${clientProcess?.pid || "unknown"} still appears active.`
+ ]
+ }));
+ }
+
+ return {
+ generatedAt: new Date().toISOString(),
+ networkContext: context,
+ ignored: Array.from(ignoredRecommendationIds),
+ recommendations: recs
+ .map((rec) => enrichRecommendation(rec, config))
+ .filter((rec) => !ignoredRecommendationIds.has(rec.id))
+ };
+}
+
+function ruleRecommendation(rules, id, override) {
+ const rule = rules.find((item) => item.id === id) || {};
+ return {
+ id,
+ severity: rule.severity || "info",
+ condition: rule.condition || "",
+ recommendation: rule.recommendation || "",
+ confidence: rule.confidence || "medium",
+ requiresRestart: Boolean(rule.requiresRestart),
+ requiresServerChange: Boolean(rule.requiresServerChange),
+ configPatch: override.configPatch || rule.configPatch || {},
+ evidence: override.evidence || [],
+ applyMode: Object.keys(override.configPatch || {}).length ? "preview-only" : "advisory"
+ };
+}
+
+function enrichRecommendation(rec, config) {
+ const patchPreview = Object.entries(rec.configPatch || {}).map(([key, recommended]) => {
+ const current = config[key];
+ return {
+ key,
+ current,
+ recommended,
+ changes: JSON.stringify(current) !== JSON.stringify(recommended)
+ };
+ });
+ const changeCount = patchPreview.filter((item) => item.changes).length;
+ return {
+ ...rec,
+ patchPreview,
+ hasConfigChange: changeCount > 0,
+ patchState: patchPreview.length === 0 ? "advisory" : changeCount > 0 ? "pending" : "already-applied"
+ };
+}
+
+function previewRecommendationPatch(body) {
+ const selectedIds = new Set((body.ids || []).map(String));
+ const dynamic = buildDynamicRecommendations(parseToml(readText(CONFIG_FILE))).recommendations;
+ const patch = {};
+ const selectedRecommendations = [];
+ for (const rec of dynamic) {
+ if (selectedIds.size && !selectedIds.has(rec.id)) continue;
+ selectedRecommendations.push(rec);
+ if (rec.requiresServerChange) continue;
+ for (const [key, value] of Object.entries(rec.configPatch || {})) {
+ if (["DOMAINS", "ENCRYPTION_KEY", "DATA_ENCRYPTION_METHOD"].includes(key)) continue;
+ patch[key] = value;
+ }
+ }
+ const original = readText(CONFIG_FILE);
+ const updated = updateToml(original, Object.entries(patch).map(([key, recommended]) => ({ key, recommended })));
+ return {
+ changed: original !== updated,
+ patch,
+ selected: selectedRecommendations.map((rec) => ({
+ id: rec.id,
+ patchState: rec.patchState,
+ patchPreview: rec.patchPreview
+ })),
+ explanation: previewExplanation(selectedRecommendations, original !== updated),
+ diff: simpleDiff(original, updated)
+ };
+}
+
+function previewExplanation(recommendations, changed) {
+ if (changed) return "Preview generated. Nothing was saved.";
+ if (!recommendations.length) return "No selected recommendations were found.";
+ if (recommendations.every((rec) => rec.patchState === "advisory")) return "Selected recommendations are advisory and do not include TOML changes.";
+ if (recommendations.every((rec) => rec.patchState !== "pending")) return "Selected TOML recommendations are already applied.";
+ return "Selected dynamic recommendations do not change TOML.";
+}
+
+function buildAIContext(config) {
+ const redactedConfig = {};
+ const keys = [
+ "DOMAINS",
+ "DATA_ENCRYPTION_METHOD",
+ "ENCRYPTION_KEY",
+ "RESOLVER_BALANCING_STRATEGY",
+ "PACKET_DUPLICATION_COUNT",
+ "SETUP_PACKET_DUPLICATION_COUNT",
+ "UPLOAD_COMPRESSION_TYPE",
+ "DOWNLOAD_COMPRESSION_TYPE",
+ "MIN_UPLOAD_MTU",
+ "MAX_UPLOAD_MTU",
+ "MIN_DOWNLOAD_MTU",
+ "MAX_DOWNLOAD_MTU",
+ "MTU_TEST_RETRIES",
+ "MTU_TEST_TIMEOUT",
+ "MTU_TEST_PARALLELISM",
+ "AUTO_REMOVE_LOW_MTU_SERVERS",
+ "TUNNEL_PROCESS_WORKERS",
+ "RX_TX_WORKERS",
+ "TUNNEL_PACKET_TIMEOUT_SECONDS",
+ "ARQ_WINDOW_SIZE",
+ "LOG_LEVEL"
+ ];
+ for (const key of keys) {
+ if (key in config) redactedConfig[key] = key === "ENCRYPTION_KEY" ? maskSecret(config[key]) : config[key];
+ }
+ return {
+ generatedAt: new Date().toISOString(),
+ version: BINARY ? basename(BINARY) : null,
+ networkContext: contextSummary(),
+ config: redactedConfig,
+ telemetry: buildTelemetrySnapshot(config),
+ recommendations: buildDynamicRecommendations(config).recommendations,
+ topWarnings: importantEvents(50).filter((event) => ["mtu-reject", "mtu-failed", "resolver-disabled", "compression"].includes(event.type)).slice(-20)
+ };
+}
+
+function previewBalanced() {
+ const original = readText(CONFIG_FILE);
+ const updated = updateToml(original, directRecommendations.filter((item) => item.applyMode === "direct"));
+ return { changed: original !== updated, diff: simpleDiff(original, updated) };
+}
+
+function applyBalanced() {
+ const original = readText(CONFIG_FILE);
+ const direct = directRecommendations.filter((item) => item.applyMode === "direct");
+ const updated = updateToml(original, direct);
+ if (updated !== original) writeFileSync(CONFIG_FILE, updated);
+ return direct.map((item) => item.key);
+}
+
+function updateToml(text, recommendations) {
+ let next = text;
+ for (const item of recommendations) {
+ const value = formatTomlValue(item.recommended);
+ const pattern = new RegExp(`^(${escapeRegExp(item.key)}\\s*=\\s*).*$`, "m");
+ if (pattern.test(next)) next = next.replace(pattern, `$1${value}`);
+ }
+ return next;
+}
+
+function startClient() {
+ if (clientProcess) return { status: "already-running", pid: clientProcess.pid };
+ if (!BINARY) return { status: "error", error: "Client binary not found." };
+ logLines = [];
+ parsedEvents = [];
+ metrics = defaultMetrics();
+ telemetry = defaultTelemetry();
+ startedAt = Date.now();
+ runStatus = "connecting";
+ writeFileSync(LOG_FILE, "");
+ clientProcess = spawn(BINARY, ["-config", CONFIG_FILE], { cwd: ROOT });
+ attachProcessStream(clientProcess.stdout, "stdout");
+ attachProcessStream(clientProcess.stderr, "stderr");
+ clientProcess.on("exit", (code, signal) => {
+ appendLog(`[INFO] Client exited code=${code} signal=${signal || "none"}`, "system");
+ persistRunSummary(code, signal);
+ clientProcess = null;
+ runStatus = "disconnected";
+ metrics.runs += 1;
+ broadcast({ type: "state", payload: { status: runStatus } });
+ });
+ return { status: "started", pid: clientProcess.pid };
+}
+
+function stopClient() {
+ if (!clientProcess) return { status: "not-running" };
+ clientProcess.kill("SIGTERM");
+ runStatus = "stopping";
+ return { status: "stopping", pid: clientProcess.pid };
+}
+
+function restartClient() {
+ if (!clientProcess) return startClient();
+ const pid = clientProcess.pid;
+ clientProcess.once("exit", () => {
+ setTimeout(() => startClient(), 300);
+ });
+ stopClient();
+ return { status: "restarting", pid };
+}
+
+async function runSmokeTest() {
+ const config = parseToml(readText(CONFIG_FILE));
+ const proxy = `${config.LISTEN_IP || "127.0.0.1"}:${config.LISTEN_PORT || 18000}`;
+ const result = await runCommand("curl", ["--socks5", proxy, "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "10", "http://httpbin.org/ip"], 15000);
+ metrics.smokeTests += 1;
+ metrics.lastSmoke = { proxy, ok: result.stdout.trim() === "200", output: result.stdout.trim(), error: result.stderr.trim(), code: result.code };
+ broadcast({ type: "metrics", payload: metrics });
+ return metrics.lastSmoke;
+}
+
+function attachProcessStream(stream, source) {
+ const rl = createInterface({ input: stream });
+ rl.on("line", (line) => appendLog(line, source));
+}
+
+function appendLog(line, source) {
+ const item = { time: new Date().toISOString(), source, line };
+ logLines.push(item);
+ if (logLines.length > 1000) logLines.shift();
+ parseLogLine(item);
+ writeFileSync(LOG_FILE, logLines.map((entry) => `${entry.time} ${entry.source} ${entry.line}`).join("\n") + "\n");
+ broadcast({ type: "log", payload: item });
+}
+
+function loadExistingLogFile() {
+ if (!existsSync(LOG_FILE)) return;
+ const lines = readText(LOG_FILE).split(/\r?\n/).filter(Boolean).slice(-1000);
+ for (const line of lines) {
+ const match = line.match(/^(\S+)\s+(\S+)\s+(.+)$/);
+ const item = match
+ ? { time: match[1], source: match[2], line: match[3] }
+ : { time: new Date().toISOString(), source: "file", line };
+ logLines.push(item);
+ parseLogLine(item, false);
+ }
+}
+
+function parseLogLine(item) {
+ const line = item.line;
+ let type = null;
+ let details = {};
+ if (/Async Runtime Initialized:/i.test(line)) {
+ type = "runtime";
+ const match = line.match(/Async Runtime Initialized:\s+(\d+)\s+RX\/TX Workers,\s+(\d+)\s+Processors/i);
+ if (match) {
+ telemetry.runtime.rxTxWorkers = Number(match[1]);
+ telemetry.runtime.processWorkers = Number(match[2]);
+ details = { rxTxWorkers: telemetry.runtime.rxTxWorkers, processWorkers: telemetry.runtime.processWorkers };
+ }
+ } else if (/session.*init|initialized|SESSION_ACCEPT/i.test(line)) {
+ type = "session";
+ const attempt = line.match(/Session init attempt with\s+(\S+)\s+and resolver\s+(\S+)/i);
+ const success = line.match(/Session Initialized Successfully \(ID:\s*(\d+)\)/i);
+ if (attempt) {
+ telemetry.session.attempts += 1;
+ telemetry.session.initDomain = attempt[1];
+ telemetry.session.initResolver = attempt[2];
+ details = { domain: attempt[1], resolver: attempt[2], phase: "attempt", attempts: telemetry.session.attempts };
+ }
+ if (success) {
+ runStatus = "connected";
+ telemetry.session.initialized = true;
+ telemetry.session.id = Number(success[1]);
+ details = { id: telemetry.session.id, phase: "success" };
+ }
+ if (/Session initialization failed/i.test(line)) {
+ telemetry.session.failures += 1;
+ details = { phase: "failure", failures: telemetry.session.failures };
+ }
+ const retry = line.match(/Session init retry backoff:\s+(\S+)/i);
+ if (retry) {
+ telemetry.session.retries += 1;
+ details = { phase: "retry", retries: telemetry.session.retries, backoff: retry[1] };
+ }
+ } else if (/MTU discovery.*start|Starting MTU/i.test(line)) {
+ type = "mtu-start";
+ metrics.mtuStartedAt = item.time;
+ } else if (/MTU discovery.*complete|Synced .*MTU/i.test(line)) {
+ type = "mtu";
+ const upload = line.match(/Upload MTU:?\s*(\d+)/i);
+ const download = line.match(/Download MTU:?\s*(\d+)/i);
+ if (upload) metrics.syncedUploadMtu = Number(upload[1]);
+ if (download) metrics.syncedDownloadMtu = Number(download[1]);
+ details = { syncedUploadMtu: metrics.syncedUploadMtu, syncedDownloadMtu: metrics.syncedDownloadMtu };
+ } else if (/^\d{4}\/\d{2}\/\d{2}.*\s+\d+\s+\d+\s+\S+\s+\S+/.test(line) && /\[INFO\]\s+\S+:\d+\s+\d+\s+\d+/.test(line)) {
+ type = "valid-resolver";
+ const valid = line.match(/\[INFO\]\s+(\S+:\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)/);
+ if (valid) {
+ details = {
+ resolver: valid[1],
+ uploadMtu: Number(valid[2]),
+ downloadMtu: Number(valid[3]),
+ resolveTime: valid[4],
+ domain: valid[5]
+ };
+ upsertByKey(telemetry.mtu.validResolvers, details, "resolver");
+ }
+ } else if (/Session Compression Upload:.*Disabled/i.test(line)) {
+ type = "compression";
+ const match = line.match(/Session Compression Upload:\s+(\S+)\s+\(Disabled due to ([^)]+)\)/i);
+ if (match) {
+ telemetry.compression.requestedUpload = match[1];
+ telemetry.compression.uploadDisabledReason = match[2];
+ details = { requestedUpload: match[1], disabledReason: match[2] };
+ }
+ } else if (/Effective Compression Upload:/i.test(line)) {
+ type = "compression";
+ const match = line.match(/Effective Compression Upload:\s+(\S+)\s+Download:\s+(\S+)/i);
+ if (match) {
+ telemetry.compression.effectiveUpload = match[1];
+ telemetry.compression.effectiveDownload = match[2];
+ details = { effectiveUpload: match[1], effectiveDownload: match[2] };
+ }
+ } else if (/New SOCKS5 TCP CONNECT/i.test(line)) {
+ type = "stream";
+ const match = line.match(/CONNECT to\s+(.+?):(\d+),\s+Stream ID:\s+(\d+)/i);
+ if (match) {
+ const target = `${match[1]}:${match[2]}`;
+ telemetry.streams.total += 1;
+ telemetry.streams.timestamps.push(Date.parse(item.time) || Date.now());
+ if (telemetry.streams.timestamps.length > 2000) telemetry.streams.timestamps = telemetry.streams.timestamps.slice(-2000);
+ telemetry.streams.targets.set(target, (telemetry.streams.targets.get(target) || 0) + 1);
+ details = { host: match[1], port: Number(match[2]), streamId: Number(match[3]), target };
+ }
+ } else if (/DNS Resolver disabled/i.test(line)) {
+ type = "resolver-disabled";
+ const match = line.match(/DNS Resolver disabled \(([^)]+)\):\s+(\S+).*Remaining:\s+(\d+)/i);
+ if (match) {
+ const entry = { resolver: match[2], reason: match[1], remaining: Number(match[3]), time: item.time };
+ telemetry.resolvers.disabled.set(entry.resolver, entry);
+ telemetry.resolvers.activeTotal = entry.remaining;
+ telemetry.context.lowestActiveAfterDisable = telemetry.context.lowestActiveAfterDisable === null
+ ? entry.remaining
+ : Math.min(telemetry.context.lowestActiveAfterDisable, entry.remaining);
+ details = entry;
+ }
+ } else if (/DNS Resolver Reactivated/i.test(line)) {
+ type = "resolver-reactivated";
+ const match = line.match(/DNS Resolver Reactivated:\s+(\S+).*Total Active:\s+(\d+)/i);
+ if (match) {
+ const entry = { resolver: match[1], activeTotal: Number(match[2]), time: item.time };
+ telemetry.resolvers.reactivated.set(entry.resolver, entry);
+ telemetry.resolvers.activeTotal = entry.activeTotal;
+ recordReactivationBurst(entry);
+ details = entry;
+ }
+ } else if (/timeout/i.test(line)) {
+ type = "timeout";
+ metrics.timeoutWarnings += 1;
+ } else if (/Rejected .*reason=/i.test(line)) {
+ type = "mtu-reject";
+ metrics.mtuRejectedEvents += 1;
+ const totals = line.match(/totals:\s*valid=(\d+),\s*rejected=(\d+)/i);
+ const total = line.match(/Rejected\s*\((\d+)\/(\d+)\)/i);
+ const reason = line.match(/reason=([A-Z0-9_]+)/i);
+ if (totals) {
+ metrics.mtuValid = Number(totals[1]);
+ metrics.mtuRejected = Math.max(metrics.mtuRejected, Number(totals[2]));
+ } else {
+ metrics.mtuRejected = Math.max(metrics.mtuRejected, metrics.mtuRejectedEvents);
+ }
+ if (total) metrics.mtuTotal = Number(total[2]);
+ if (reason) metrics.lastRejectReason = reason[1];
+ if (reason) telemetry.mtu.rejectsByReason.set(reason[1], (telemetry.mtu.rejectsByReason.get(reason[1]) || 0) + 1);
+ details = {
+ resolver: line.match(/via\s+(\S+)/i)?.[1] || null,
+ domain: line.match(/Rejected \([^)]*\):\s+(\S+)/i)?.[1] || null,
+ reason: reason?.[1] || null,
+ value: Number(line.match(/value=(\d+)/i)?.[1] || 0),
+ valid: metrics.mtuValid,
+ rejected: metrics.mtuRejected,
+ total: metrics.mtuTotal
+ };
+ } else if (/retransmit|NACK|ARQ/i.test(line)) {
+ type = "arq";
+ metrics.arqEvents += 1;
+ } else if (/No valid connections|MTU tests failed/i.test(line)) {
+ type = "mtu-failed";
+ metrics.errors += 1;
+ metrics.noValidConnections = true;
+ } else if (/\[ERROR\]|error/i.test(line)) {
+ type = "error";
+ metrics.errors += 1;
+ }
+ if (type) {
+ const event = { ...item, type, details };
+ parsedEvents.push(event);
+ if (parsedEvents.length > 300) parsedEvents.shift();
+ telemetry.events.push(event);
+ if (telemetry.events.length > 1000) telemetry.events.shift();
+ updateContextDetection();
+ broadcast({ type: "parsed-event", payload: event });
+ }
+}
+
+function handleEvents(req, res) {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive"
+ });
+ res.write(`data: ${JSON.stringify({ type: "hello", payload: { status: runStatus } })}\n\n`);
+ sseClients.add(res);
+ req.on("close", () => sseClients.delete(res));
+}
+
+function broadcast(message) {
+ for (const client of sseClients) {
+ client.write(`data: ${JSON.stringify(message)}\n\n`);
+ }
+}
+
+function getResolvers(url) {
+ const query = (url.searchParams.get("q") || "").trim().toLowerCase();
+ const offset = Math.max(0, Number(url.searchParams.get("offset") || 0));
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit") || 100)));
+ const all = readText(RESOLVERS_FILE).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ const filtered = query ? all.filter((resolver) => resolver.toLowerCase().includes(query)) : all;
+ return { total: all.length, filtered: filtered.length, offset, limit, items: filtered.slice(offset, offset + limit).map((value, index) => ({ index: offset + index + 1, value })) };
+}
+
+async function markdownInventory() {
+ const files = await listMarkdown(ROOT);
+ return files.map((file) => {
+ const text = readText(file);
+ return {
+ path: file.replace(`${ROOT}/`, ""),
+ title: (text.match(/^#\s+(.+)$/m)?.[1] || basename(file)),
+ lines: lineCount(text)
+ };
+ });
+}
+
+async function listMarkdown(dir) {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries) {
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) files.push(...await listMarkdown(full));
+ if (entry.isFile() && entry.name.endsWith(".md")) files.push(full);
+ }
+ return files.sort();
+}
+
+function parseToml(text) {
+ const config = {};
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
+ const [rawKey, ...rest] = line.split("=");
+ const key = rawKey.trim();
+ const value = stripInlineComment(rest.join("=").trim());
+ config[key] = parseTomlValue(value);
+ }
+ return config;
+}
+
+function removeTomlComments(text) {
+ const lines = text
+ .split(/\r?\n/)
+ .map(stripTomlComment)
+ .filter((line, index, all) => line.trim() || (index > 0 && all[index - 1].trim()));
+ return ensureTrailingNewline(lines.join("\n"));
+}
+
+function stripTomlComment(line) {
+ let result = "";
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+ for (let index = 0; index < line.length; index += 1) {
+ const character = line[index];
+ const previous = index > 0 ? line[index - 1] : "";
+ if (character === '"' && previous !== "\\" && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote;
+ } else if (character === "'" && previous !== "\\" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote;
+ } else if (character === "#" && !inSingleQuote && !inDoubleQuote) {
+ break;
+ }
+ result += character;
+ }
+ return result.trimEnd();
+}
+
+function parseTomlValue(value) {
+ if (value.startsWith("[") && value.endsWith("]")) {
+ const inner = value.slice(1, -1).trim();
+ if (!inner) return [];
+ return inner.split(",").map((part) => parseTomlValue(part.trim()));
+ }
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
+ if (value === "true") return true;
+ if (value === "false") return false;
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
+ return value;
+}
+
+function stripInlineComment(value) {
+ let quoted = false;
+ let quote = "";
+ for (let i = 0; i < value.length; i += 1) {
+ if ((value[i] === '"' || value[i] === "'") && value[i - 1] !== "\\") {
+ quoted = !quoted;
+ quote = quoted ? value[i] : "";
+ }
+ if (!quoted && value[i] === "#") return value.slice(0, i).trim();
+ }
+ return value.trim();
+}
+
+function ensureTrailingNewline(text) {
+ return text.endsWith("\n") ? text : `${text}\n`;
+}
+
+function readRequestJson(req) {
+ return new Promise((resolveRequest, rejectRequest) => {
+ let body = "";
+ req.on("data", (chunk) => {
+ body += chunk.toString();
+ if (body.length > 2_000_000) {
+ req.destroy();
+ rejectRequest(new Error("Request body is too large."));
+ }
+ });
+ req.on("end", () => {
+ if (!body.trim()) return resolveRequest({});
+ try {
+ resolveRequest(JSON.parse(body));
+ } catch (error) {
+ rejectRequest(new Error(`Invalid JSON body: ${error.message}`));
+ }
+ });
+ req.on("error", rejectRequest);
+ });
+}
+
+function formatTomlValue(value) {
+ if (typeof value === "string") return `"${value.replaceAll('"', '\\"')}"`;
+ if (Array.isArray(value)) return `[${value.map(formatTomlValue).join(", ")}]`;
+ return String(value);
+}
+
+function simpleDiff(before, after) {
+ const a = before.split(/\r?\n/);
+ const b = after.split(/\r?\n/);
+ const rows = [];
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
+ if (a[i] !== b[i]) {
+ if (a[i] !== undefined) rows.push({ type: "remove", line: i + 1, text: a[i] });
+ if (b[i] !== undefined) rows.push({ type: "add", line: i + 1, text: b[i] });
+ }
+ }
+ return rows;
+}
+
+async function binaryVersion() {
+ if (!BINARY) return null;
+ const result = await runCommand(BINARY, ["-version"], 5000);
+ return result.stdout.trim() || result.stderr.trim() || null;
+}
+
+function runCommand(command, args, timeoutMs) {
+ return new Promise((resolveResult) => {
+ const child = spawn(command, args, { cwd: ROOT });
+ let stdout = "";
+ let stderr = "";
+ const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ resolveResult({ code, stdout, stderr });
+ });
+ child.on("error", (error) => {
+ clearTimeout(timer);
+ resolveResult({ code: -1, stdout, stderr: error.message });
+ });
+ });
+}
+
+function isPortOpen(host, port) {
+ return new Promise((resolveResult) => {
+ const socket = net.createConnection({ host, port, timeout: 300 });
+ socket.on("connect", () => {
+ socket.destroy();
+ resolveResult(true);
+ });
+ socket.on("timeout", () => {
+ socket.destroy();
+ resolveResult(false);
+ });
+ socket.on("error", () => resolveResult(false));
+ });
+}
+
+function defaultMetrics() {
+ return {
+ runs: 0,
+ smokeTests: 0,
+ timeoutWarnings: 0,
+ mtuRejected: 0,
+ mtuRejectedEvents: 0,
+ mtuValid: null,
+ mtuTotal: null,
+ lastRejectReason: null,
+ noValidConnections: false,
+ arqEvents: 0,
+ errors: 0,
+ syncedUploadMtu: null,
+ syncedDownloadMtu: null,
+ mtuStartedAt: null,
+ lastSmoke: null
+ };
+}
+
+function defaultTelemetry() {
+ return {
+ events: [],
+ mtu: {
+ rejectsByReason: new Map(),
+ validResolvers: []
+ },
+ compression: {
+ requestedUpload: null,
+ requestedDownload: null,
+ effectiveUpload: null,
+ effectiveDownload: null,
+ uploadDisabledReason: null
+ },
+ session: {
+ initialized: false,
+ id: null,
+ initDomain: null,
+ initResolver: null,
+ attempts: 0,
+ failures: 0,
+ retries: 0
+ },
+ runtime: {
+ rxTxWorkers: null,
+ processWorkers: null
+ },
+ streams: {
+ total: 0,
+ timestamps: [],
+ targets: new Map()
+ },
+ resolvers: {
+ activeTotal: null,
+ disabled: new Map(),
+ reactivated: new Map()
+ },
+ context: {
+ selected: "unknown",
+ detected: "unknown",
+ mixed: false,
+ notes: [],
+ lowestActiveAfterDisable: null,
+ reactivationBurst: {
+ count: 0,
+ firstAt: null,
+ lastAt: null,
+ maxActive: null
+ }
+ }
+ };
+}
+
+function ensureOptimizerRulesFile() {
+ if (existsSync(RULES_FILE)) return;
+ writeFileSync(RULES_FILE, JSON.stringify(defaultOptimizerRules(), null, 2) + "\n", "utf8");
+}
+
+function defaultOptimizerRules() {
+ return [
+ {
+ id: "low-valid-ratio-session-ok",
+ severity: "warn",
+ condition: "validResolvers / totalResolvers < 0.01 && session.initialized",
+ recommendation: "Export and reuse working resolvers; reduce resolver-list noise after observing recovery.",
+ configPatch: { SAVE_MTU_SERVERS_TO_FILE: true, MTU_SERVERS_FILE_FORMAT: "{IP}" },
+ confidence: "high",
+ requiresRestart: false,
+ requiresServerChange: false
+ },
+ {
+ id: "keep-fixed-low-upload-mtu",
+ severity: "info",
+ condition: "UPLOAD_MTU dominates rejects && MIN_UPLOAD_MTU == MAX_UPLOAD_MTU <= 64",
+ recommendation: "Keep the fixed low upload MTU profile because this network rejects most upload probes.",
+ configPatch: {},
+ confidence: "medium",
+ requiresRestart: true,
+ requiresServerChange: false
+ },
+ {
+ id: "lower-download-mtu-upper-bound",
+ severity: "warn",
+ condition: "DOWNLOAD_MTU rejects exist && MAX_DOWNLOAD_MTU > syncedDownloadMtu",
+ recommendation: "Lower MAX_DOWNLOAD_MTU to the observed synced download MTU to reduce failed probing.",
+ configPatch: {},
+ confidence: "medium",
+ requiresRestart: true,
+ requiresServerChange: false
+ },
+ {
+ id: "disable-upload-compression-low-mtu",
+ severity: "info",
+ condition: "upload compression disabled due to low MTU",
+ recommendation: "Set upload compression OFF while keeping effective download compression if it works.",
+ configPatch: { UPLOAD_COMPRESSION_TYPE: 0 },
+ confidence: "high",
+ requiresRestart: true,
+ requiresServerChange: false
+ },
+ {
+ id: "wait-and-export-reactivated-resolvers",
+ severity: "info",
+ condition: "resolver reactivation count > 0",
+ recommendation: "Wait for background rechecks, then export active/reactivated resolvers as a cleaner resolver file.",
+ configPatch: {},
+ confidence: "medium",
+ requiresRestart: false,
+ requiresServerChange: false
+ },
+ {
+ id: "downgrade-lossy-resolvers",
+ severity: "warn",
+ condition: "resolver disabled for runtime loss",
+ recommendation: "Exclude 100% loss resolvers from known-good exports and future candidate lists.",
+ configPatch: {},
+ confidence: "high",
+ requiresRestart: false,
+ requiresServerChange: false
+ },
+ {
+ id: "high-stream-rate-low-active-resolvers",
+ severity: "warn",
+ condition: "session.initialized && streamRate > 60/min && activeResolvers < 10",
+ recommendation: "High stream pressure with few active resolvers depends heavily on packet duplication; avoid lowering duplication yet.",
+ configPatch: {},
+ confidence: "medium",
+ requiresRestart: false,
+ requiresServerChange: false
+ },
+ {
+ id: "wrapper-state-drift",
+ severity: "error",
+ condition: "dashboard state is stopping while child process is alive",
+ recommendation: "Reconcile wrapper process state so dashboard status matches the actual client process.",
+ configPatch: {},
+ confidence: "high",
+ requiresRestart: false,
+ requiresServerChange: false
+ }
+ ];
+}
+
+function loadOptimizerRules() {
+ try {
+ const parsed = JSON.parse(readText(RULES_FILE));
+ return Array.isArray(parsed) ? parsed : defaultOptimizerRules();
+ } catch {
+ return defaultOptimizerRules();
+ }
+}
+
+function loadRunSummaries() {
+ try {
+ const parsed = JSON.parse(readText(RUNS_FILE));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function persistRunSummary(code, signal) {
+ const config = parseToml(readText(CONFIG_FILE));
+ const summary = {
+ time: new Date().toISOString(),
+ code,
+ signal: signal || null,
+ runtimeSeconds: startedAt ? Math.floor((Date.now() - startedAt) / 1000) : null,
+ metrics: { ...metrics },
+ networkContext: contextSummary(),
+ telemetry: {
+ mtu: buildTelemetrySnapshot(config).mtu,
+ compression: telemetry.compression,
+ session: telemetry.session,
+ streams: buildTelemetrySnapshot(config).streams,
+ resolvers: {
+ activeTotal: telemetry.resolvers.activeTotal,
+ disabledCount: telemetry.resolvers.disabled.size,
+ reactivatedCount: telemetry.resolvers.reactivated.size
+ }
+ }
+ };
+ const runs = loadRunSummaries();
+ runs.push(summary);
+ writeFileSync(RUNS_FILE, JSON.stringify(runs.slice(-50), null, 2) + "\n", "utf8");
+}
+
+function buildOptimizerScores(validRatio, activeResolvers, streamRate) {
+ const resolverScore = validRatio === null ? 50 : clamp(Math.round(validRatio * 1000), 0, 100);
+ const activeScore = clamp(activeResolvers * 8, 0, 100);
+ const mtuScore = metrics.syncedUploadMtu && metrics.syncedDownloadMtu
+ ? clamp(Math.round((metrics.syncedUploadMtu / 150) * 40 + (metrics.syncedDownloadMtu / 1000) * 60), 0, 100)
+ : 0;
+ const compressionScore = telemetry.compression.effectiveDownload === "LZ4" || telemetry.compression.effectiveDownload === "ZSTD" ? 75 : 45;
+ const pressureScore = streamRate > 60 && activeResolvers < 10 ? 35 : streamRate > 60 ? 60 : 85;
+ const healthScore = Math.round((resolverScore + activeScore + mtuScore + compressionScore + pressureScore) / 5);
+ return { healthScore, activeResolverScore: activeScore, mtuScore, compressionScore, streamPressureScore: pressureScore };
+}
+
+function importantEvents(limit) {
+ return telemetry.events
+ .filter((event) => ["mtu", "mtu-failed", "valid-resolver", "compression", "session", "runtime", "resolver-disabled", "resolver-reactivated", "arq", "error"].includes(event.type))
+ .slice(-limit);
+}
+
+function topEntries(map, limit) {
+ return Array.from(map.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, limit)
+ .map(([key, count]) => ({ key, count }));
+}
+
+function upsertByKey(list, item, key) {
+ const index = list.findIndex((entry) => entry[key] === item[key]);
+ if (index >= 0) list[index] = item;
+ else list.push(item);
+}
+
+function setNetworkContext(context) {
+ const normalized = normalizeNetworkContext(context);
+ telemetry.context.selected = normalized;
+ return contextSummary();
+}
+
+function normalizeNetworkContext(context) {
+ return ["filtered", "unfiltered", "unknown"].includes(context) ? context : "unknown";
+}
+
+function recordReactivationBurst(entry) {
+ const burst = telemetry.context.reactivationBurst;
+ burst.count += 1;
+ burst.firstAt ||= entry.time;
+ burst.lastAt = entry.time;
+ burst.maxActive = Math.max(burst.maxActive || 0, entry.activeTotal);
+}
+
+function updateContextDetection() {
+ const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null;
+ const filteredSignals = [
+ validRatio !== null && validRatio < 0.01,
+ telemetry.session.failures > 0,
+ telemetry.resolvers.disabled.size > 0,
+ telemetry.context.lowestActiveAfterDisable !== null && telemetry.context.lowestActiveAfterDisable <= 10
+ ].filter(Boolean).length;
+ const unfilteredSignals = [
+ telemetry.context.reactivationBurst.count >= 20,
+ (telemetry.context.reactivationBurst.maxActive || 0) >= 50
+ ].filter(Boolean).length;
+ telemetry.context.mixed = filteredSignals >= 2 && unfilteredSignals >= 1;
+ telemetry.context.detected = telemetry.context.mixed
+ ? "mixed"
+ : filteredSignals >= 2
+ ? "filtered"
+ : unfilteredSignals >= 1
+ ? "unfiltered"
+ : "unknown";
+ const notes = [];
+ if (filteredSignals >= 2) notes.push("Filtered-network signals: low valid ratio, session retries/failures, resolver loss, or very low active count.");
+ if (unfilteredSignals >= 1) notes.push(`Unfiltered-network signals: ${telemetry.context.reactivationBurst.count} resolver reactivations and active total up to ${telemetry.context.reactivationBurst.maxActive || 0}.`);
+ if (telemetry.context.mixed) notes.push("Mixed log context detected; do not use unfiltered reactivation bursts to tune the filtered profile.");
+ telemetry.context.notes = notes;
+}
+
+function contextSummary() {
+ updateContextDetection();
+ return {
+ selected: telemetry.context.selected,
+ detected: telemetry.context.detected,
+ mixed: telemetry.context.mixed,
+ notes: telemetry.context.notes,
+ lowestActiveAfterDisable: telemetry.context.lowestActiveAfterDisable,
+ reactivationBurst: telemetry.context.reactivationBurst
+ };
+}
+
+function contextActiveResolvers(context = telemetry.context.selected) {
+ context = effectiveNetworkContext(context);
+ if (context === "filtered") return telemetry.context.lowestActiveAfterDisable ?? metrics.mtuValid ?? 0;
+ if (context === "unfiltered") return telemetry.context.reactivationBurst.maxActive ?? telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0;
+ return telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0;
+}
+
+function knownGoodResolvers(context = telemetry.context.selected) {
+ context = effectiveNetworkContext(context);
+ const disabled = new Set(telemetry.resolvers.disabled.keys());
+ const baseCandidates = telemetry.mtu.validResolvers.map((item) => item.resolver);
+ const candidates = context === "filtered"
+ ? baseCandidates
+ : [...baseCandidates, ...Array.from(telemetry.resolvers.reactivated.keys())];
+ return Array.from(new Set(candidates)).filter((resolver) => resolver && !disabled.has(resolver));
+}
+
+function exportGoodResolvers(context) {
+ const selectedContext = effectiveNetworkContext(normalizeNetworkContext(context || telemetry.context.selected));
+ const resolvers = knownGoodResolvers(selectedContext);
+ const target = selectedContext === "unknown"
+ ? GOOD_RESOLVERS_FILE
+ : GOOD_RESOLVERS_FILE.replace(/\.txt$/i, `-${selectedContext}.txt`);
+ writeFileSync(target, `${resolvers.join("\n")}${resolvers.length ? "\n" : ""}`, "utf8");
+ return { file: target, context: selectedContext, count: resolvers.length, resolvers };
+}
+
+function effectiveNetworkContext(context = telemetry.context.selected) {
+ const normalized = normalizeNetworkContext(context);
+ return normalized === "unknown" && telemetry.context.mixed ? "filtered" : normalized;
+}
+
+function detectStateDrift() {
+ if (!clientProcess || runStatus !== "stopping") return false;
+ try {
+ process.kill(clientProcess.pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function maskSecret(value) {
+ const text = String(value || "");
+ if (text.length <= 8) return "***";
+ return `${text.slice(0, 4)}...${text.slice(-4)}`;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function readText(file) {
+ return readFileSync(file, "utf8");
+}
+
+function lineCount(text) {
+ return text.split(/\r?\n/).filter(Boolean).length;
+}
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export async function shutdownDashboard() {
+ if (clientProcess) clientProcess.kill("SIGTERM");
+ server.close(() => process.exit(0));
+}
diff --git a/tools/wrapper-dashboard/src/server/paths.js b/tools/wrapper-dashboard/src/server/paths.js
new file mode 100644
index 00000000..5b742fb3
--- /dev/null
+++ b/tools/wrapper-dashboard/src/server/paths.js
@@ -0,0 +1,21 @@
+import { readdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+export const ROOT = resolve(process.env.MDVPN_DASHBOARD_ROOT || process.cwd());
+export const PORT = Number(process.env.MDVPN_DASHBOARD_PORT || 18080);
+export const PUBLIC_DIR = join(APP_ROOT, "public");
+export const CONFIG_FILE = join(ROOT, "client_config.toml");
+export const RESOLVERS_FILE = join(ROOT, "client_resolvers.txt");
+export const SCHEMA_FILE = join(ROOT, ".config-schema.json");
+export const LOG_FILE = join(ROOT, "dashboard-client.log");
+export const RULES_FILE = join(ROOT, "optimizer-rules.json");
+export const RUNS_FILE = join(ROOT, "dashboard-runs.json");
+export const GOOD_RESOLVERS_FILE = join(ROOT, "known-good-resolvers.txt");
+export const BINARY = findBinary();
+
+function findBinary() {
+ const found = readdirSync(ROOT).find((file) => file.startsWith("MasterDnsVPN_Client_Linux_AMD64_v"));
+ return found ? join(ROOT, found) : null;
+}
diff --git a/tools/wrapper-dashboard/src/server/static.js b/tools/wrapper-dashboard/src/server/static.js
new file mode 100644
index 00000000..d60d705d
--- /dev/null
+++ b/tools/wrapper-dashboard/src/server/static.js
@@ -0,0 +1,24 @@
+import { createReadStream, existsSync } from "node:fs";
+import { extname, join, resolve } from "node:path";
+import { PUBLIC_DIR } from "./paths.js";
+import { sendText } from "./utils/http.js";
+
+export async function serveStatic(req, res, url) {
+ const requested = url.pathname === "/" ? "/index.html" : url.pathname;
+ const target = resolve(join(PUBLIC_DIR, requested));
+ if (!target.startsWith(PUBLIC_DIR)) return sendText(res, 403, "Forbidden");
+ if (!existsSync(target)) return sendText(res, 404, "Not found");
+ const type = contentType(extname(target));
+ res.writeHead(200, { "Content-Type": type });
+ createReadStream(target).pipe(res);
+}
+
+function contentType(ext) {
+ return {
+ ".html": "text/html; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml"
+ }[ext] || "application/octet-stream";
+}
diff --git a/tools/wrapper-dashboard/src/server/utils/http.js b/tools/wrapper-dashboard/src/server/utils/http.js
new file mode 100644
index 00000000..23e59350
--- /dev/null
+++ b/tools/wrapper-dashboard/src/server/utils/http.js
@@ -0,0 +1,9 @@
+export function sendJson(res, status, body) {
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
+ res.end(JSON.stringify(body, null, 2));
+}
+
+export function sendText(res, status, body) {
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
+ res.end(body);
+}