BIG BIG BIG BIG BIG UPDATES

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
gabdevele
2026-05-04 23:59:48 +02:00
co-authored by Copilot
Unverified
parent 46f7cd6a5f
commit 93ba8e9ac2
88 changed files with 7591 additions and 447 deletions
+571
View File
@@ -0,0 +1,571 @@
import {
computed,
inject,
proxyRefs,
reactive,
ref,
watch,
type InjectionKey,
} from "vue";
import { api, getToken, login, logout } from "../api";
import type {
AddonExample,
DockerService,
HealthResult,
MitmOption,
ProxyDocument,
ProxyDraft,
} from "../types";
const editorStorageKey = "theplug.editor.open";
const toolStorageKey = "theplug.tool.view";
export const modes = [
"reverse",
"regular",
"socks5",
"upstream",
"wireguard",
"transparent",
"local",
"tun",
"dns",
];
export const protocols = [
"http",
"https",
"tcp",
"tls",
"udp",
"dtls",
"dns",
"http3",
"quic",
];
export type RuntimeAction = "start" | "stop" | "restart";
export type ToolView = "options" | "examples" | "logs";
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return {
proxy_port: port,
name: "new-proxy",
code:
code ??
`from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
pass
`,
mode: "reverse",
mode_spec: null,
protocol: "http",
listen_host: "0.0.0.0",
mitm_options: {
flow_detail: 1,
termlog_verbosity: "info",
},
healthcheck: {
enabled: true,
protocol: null,
path: "/",
method: "GET",
expected_status: null,
udp_payload: "",
expect_response: true,
verify_tls: false,
timeout_seconds: 2,
},
service: {
host: "127.0.0.1",
port: 9000,
name: null,
folder: null,
upstream_port: null,
},
};
}
function readSession(key: string, fallback: string) {
if (typeof window === "undefined") return fallback;
return window.sessionStorage.getItem(key) ?? fallback;
}
function writeSession(key: string, value: string) {
if (typeof window === "undefined") return;
window.sessionStorage.setItem(key, value);
}
export function useControlPlane() {
const token = ref(getToken());
const username = ref("admin");
const password = ref("");
const error = ref("");
const notice = ref("");
const loading = ref(false);
const proxies = ref<ProxyDocument[]>([]);
const services = ref<DockerService[]>([]);
const options = ref<MitmOption[]>([]);
const examples = ref<AddonExample[]>([]);
const selectedPort = ref<number | null>(null);
const health = ref<HealthResult | null>(null);
const logs = ref("");
const optionSearch = ref("");
const exampleSearch = ref("");
const customExampleName = ref("");
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
const toolView = ref<ToolView>(
readSession(toolStorageKey, "options") as ToolView,
);
const selectedOptionName = ref("");
const selectedExampleId = ref("");
const draft = reactive<ProxyDraft>(createDefaultDraft());
const selectedProxy = computed(() =>
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
);
const routeState = computed(() => {
const livePort =
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
const dockerManaged = Boolean(draft.service.name && draft.service.folder);
const reverseManaged = draft.mode === "reverse" && !draft.mode_spec;
const remapped = livePort !== null && livePort !== draft.service.port;
const configuredTarget = `${draft.service.host}:${draft.service.port}`;
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
let startNotice = "Save stores configuration only. Start applies runtime routing.";
if (reverseManaged && dockerManaged) {
startNotice =
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
} else if (draft.mode_spec) {
startNotice =
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
} else if (draft.mode !== "reverse") {
startNotice =
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
}
let liveNotice = "No runtime remap is active.";
if (remapped) {
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
} else if (reverseManaged && dockerManaged) {
liveNotice = "The original service port stays unchanged until Start is executed.";
}
return {
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
configuredTarget,
liveTarget,
modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`,
serviceLabel: draft.service.name ?? "manual upstream",
dockerManaged,
reverseManaged,
remapped,
livePort,
startNotice,
liveNotice,
};
});
const filteredOptions = computed(() => {
const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved);
const filtered = available
.filter((option) => {
if (!query) return true;
return `${option.name} ${option.description}`.toLowerCase().includes(query);
})
.sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, query ? 80 : 36);
});
const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length,
);
const selectedOption = computed(
() =>
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
filteredOptions.value[0] ??
null,
);
const filteredExamples = computed(() => {
const query = exampleSearch.value.toLowerCase().trim();
const filtered = examples.value.filter((example) => {
if (!query) return true;
return `${example.name} ${example.category} ${example.description}`
.toLowerCase()
.includes(query);
});
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
return query ? sorted : sorted.slice(0, 24);
});
const selectedExample = computed(
() =>
filteredExamples.value.find((example) => example.id === selectedExampleId.value) ??
filteredExamples.value[0] ??
null,
);
const activeOptions = computed(() =>
Object.entries(draft.mitm_options)
.map(([name, value]) => ({
name,
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
option: options.value.find((item) => item.name === name),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
);
const codeLineCount = computed(() => draft.code.split("\n").length);
watch(selectedProxy, (proxy) => {
if (!proxy) return;
Object.assign(draft, structuredClone(proxy));
health.value = null;
logs.value = "";
});
watch(editorOpen, (value) => {
writeSession(editorStorageKey, value ? "1" : "0");
});
watch(toolView, (value) => {
writeSession(toolStorageKey, value);
});
watch(filteredOptions, (items) => {
if (!items.some((item) => item.name === selectedOptionName.value)) {
selectedOptionName.value = items[0]?.name ?? "";
}
}, { immediate: true });
watch(filteredExamples, (items) => {
if (!items.some((item) => item.id === selectedExampleId.value)) {
selectedExampleId.value = items[0]?.id ?? "";
}
}, { immediate: true });
async function signIn() {
await run(async () => {
await login(username.value, password.value);
token.value = getToken();
password.value = "";
await refreshAll();
}, "Logged in");
}
async function signOut() {
await logout();
token.value = null;
proxies.value = [];
}
async function refreshAll() {
await run(async () => {
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
api.proxies(),
api.services(),
api.options(),
api.examples(),
]);
proxies.value = proxyList;
services.value = serviceList;
options.value = optionList;
examples.value = exampleList;
if (!selectedPort.value && proxyList[0]) {
selectedPort.value = proxyList[0].proxy_port;
}
});
}
function newProxy() {
selectedPort.value = null;
health.value = null;
logs.value = "";
Object.assign(
draft,
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
);
notice.value = "New proxy draft created. Save stores configuration only.";
toolView.value = "options";
}
async function saveProxy() {
await run(async () => {
const payload = cleanDraft();
const saved = selectedProxy.value
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
: await api.createProxy(payload);
await refreshAll();
selectedPort.value = saved.proxy_port;
}, "Proxy saved");
}
async function removeProxy() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
await api.deleteProxy(proxy.proxy_port);
selectedPort.value = null;
await refreshAll();
}, "Proxy deleted");
}
async function runtimeAction(kind: RuntimeAction) {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
const port = proxy.proxy_port;
const updated =
kind === "start"
? await api.start(port)
: kind === "stop"
? await api.stop(port)
: await api.restart(port);
replaceProxy(updated);
selectedPort.value = updated.proxy_port;
}, `Proxy ${kind} requested`);
}
async function checkHealth() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
health.value = await api.health(proxy.proxy_port);
});
}
async function loadLogs() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
logs.value = (await api.logs(proxy.proxy_port)).log;
});
}
async function saveCurrentAsExample() {
const name = customExampleName.value.trim();
if (!name) {
error.value = "Custom example name is required";
return;
}
const id =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") ||
`example-${Date.now()}`;
await run(async () => {
await api.createExample({
id,
name,
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
category: "custom",
code: draft.code,
source_url: null,
built_in: false,
});
examples.value = await api.examples();
customExampleName.value = "";
selectedExampleId.value = id;
}, "Custom example saved");
}
async function removeExample(id: string) {
await run(async () => {
await api.deleteExample(id);
examples.value = await api.examples();
}, "Custom example deleted");
}
function applyService(service: DockerService, port: number) {
draft.service.name = service.name;
draft.service.folder = service.folder;
draft.service.port = port;
draft.service.host = "127.0.0.1";
}
function applyExample(example: AddonExample) {
selectedExampleId.value = example.id;
draft.code = example.code;
notice.value = editorOpen.value
? `Loaded example: ${example.name}`
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
}
function selectExample(exampleId: string) {
selectedExampleId.value = exampleId;
}
function setOption(option: MitmOption) {
selectedOptionName.value = option.name;
if (!(option.name in draft.mitm_options)) {
draft.mitm_options[option.name] = defaultFor(option);
}
}
function selectOption(optionName: string) {
selectedOptionName.value = optionName;
}
function removeOption(name: string) {
delete draft.mitm_options[name];
}
function updateOption(name: string, value: string, option?: MitmOption) {
if (option?.type === "bool") {
draft.mitm_options[name] = value === "true";
return;
}
if (option?.type === "int" || option?.type === "optional int") {
draft.mitm_options[name] = value === "" ? null : Number(value);
return;
}
if (option?.type === "sequence of str") {
draft.mitm_options[name] = value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return;
}
draft.mitm_options[name] = value;
}
function toggleEditor(force?: boolean) {
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
}
function setToolView(view: ToolView) {
toolView.value = view;
}
function cleanDraft(): ProxyDraft {
return JSON.parse(
JSON.stringify({
proxy_port: draft.proxy_port,
name: draft.name,
code: draft.code,
mode: draft.mode,
mode_spec: draft.mode_spec || null,
protocol: draft.protocol,
listen_host: draft.listen_host,
mitm_options: draft.mitm_options,
healthcheck: {
...draft.healthcheck,
expected_status: draft.healthcheck.expected_status || null,
protocol: draft.healthcheck.protocol || null,
},
service: {
host: draft.service.host,
port: draft.service.port,
name: draft.service.name || null,
folder: draft.service.folder || null,
upstream_port: draft.service.upstream_port || null,
},
}),
) as ProxyDraft;
}
function replaceProxy(proxy: ProxyDocument) {
const index = proxies.value.findIndex(
(item) => item.proxy_port === proxy.proxy_port,
);
if (index === -1) proxies.value.push(proxy);
else proxies.value[index] = proxy;
}
function nextPort() {
const used = new Set(proxies.value.map((proxy) => proxy.proxy_port));
let port = 8080;
while (used.has(port)) port += 1;
return port;
}
function defaultFor(option: MitmOption) {
if (option.default !== null && option.default !== undefined) return option.default;
if (option.type === "bool") return false;
if (option.type === "int" || option.type === "optional int") return 0;
if (option.type === "sequence of str") return [];
return "";
}
async function run(task: () => Promise<void>, success?: string) {
loading.value = true;
error.value = "";
notice.value = "";
try {
await task();
if (success) notice.value = success;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
return proxyRefs({
activeOptions,
applyExample,
applyService,
checkHealth,
codeLineCount,
customExampleName,
draft,
editorOpen,
error,
exampleSearch,
filteredExamples,
filteredOptions,
health,
loadLogs,
loading,
logs,
newProxy,
notice,
optionCount,
optionSearch,
password,
proxies,
refreshAll,
removeExample,
removeOption,
removeProxy,
routeState,
runtimeAction,
saveCurrentAsExample,
saveProxy,
selectExample,
selectedExample,
selectedOption,
selectedPort,
selectedProxy,
selectOption,
services,
setOption,
setToolView,
signIn,
signOut,
toggleEditor,
token,
toolView,
updateOption,
username,
});
}
export type ControlPlane = ReturnType<typeof useControlPlane>;
export const controlPlaneKey: InjectionKey<ControlPlane> = Symbol("control-plane");
export function useControlPlaneContext() {
const context = inject(controlPlaneKey);
if (!context) {
throw new Error("Control plane context is not available");
}
return context;
}