BIG BIG BIG BIG BIG UPDATES
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
Unverified
parent
46f7cd6a5f
commit
93ba8e9ac2
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user