omniwire 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -112
- package/assets/banner-dark.svg +82 -0
- package/assets/banner-light.svg +75 -0
- package/dist/mcp/server.js +294 -8
- package/dist/mcp/server.js.map +1 -1
- package/package.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -61,19 +61,133 @@ function multiResultJson(results) {
|
|
|
61
61
|
...(r.stderr ? { stderr: r.stderr.slice(0, 500) } : {}),
|
|
62
62
|
}))));
|
|
63
63
|
}
|
|
64
|
+
// -- VPN namespace wrapper -- routes ONLY the command through VPN, mesh stays intact
|
|
65
|
+
// Uses Linux network namespaces: creates isolated netns, moves VPN tunnel into it,
|
|
66
|
+
// runs command inside namespace. Main namespace (WireGuard mesh, SSH) is untouched.
|
|
67
|
+
function buildVpnWrappedCmd(vpnSpec, innerCmd) {
|
|
68
|
+
const ns = `ow-vpn-${Date.now().toString(36)}`;
|
|
69
|
+
const escaped = innerCmd.replace(/'/g, "'\\''");
|
|
70
|
+
// Parse spec: "mullvad", "mullvad:se", "openvpn:/path/to.conf", "wg:wg-vpn"
|
|
71
|
+
const [provider, param] = vpnSpec.includes(':') ? [vpnSpec.split(':')[0], vpnSpec.split(':').slice(1).join(':')] : [vpnSpec, ''];
|
|
72
|
+
if (provider === 'mullvad') {
|
|
73
|
+
// Mullvad supports split tunneling natively via `mullvad-exclude`
|
|
74
|
+
// OR we can use its SOCKS5 proxy (10.64.0.1:1080) when connected
|
|
75
|
+
// Best approach: use mullvad split-tunnel + namespace for full isolation
|
|
76
|
+
const relayCmd = param ? `mullvad relay set location ${param} 2>/dev/null;` : '';
|
|
77
|
+
// Check if mullvad is already connected; if not, connect (split-tunnel safe)
|
|
78
|
+
return `${relayCmd} mullvad status | grep -q Connected || mullvad connect 2>/dev/null; sleep 1; ` +
|
|
79
|
+
// Create netns, veth pair, route traffic through mullvad's tun
|
|
80
|
+
`ip netns add ${ns} 2>/dev/null; ` +
|
|
81
|
+
`ip link add veth-${ns} type veth peer name veth-${ns}-ns; ` +
|
|
82
|
+
`ip link set veth-${ns}-ns netns ${ns}; ` +
|
|
83
|
+
`ip addr add 172.30.${Math.floor(Math.random() * 254) + 1}.1/30 dev veth-${ns}; ` +
|
|
84
|
+
`ip link set veth-${ns} up; ` +
|
|
85
|
+
`ip netns exec ${ns} ip addr add 172.30.${Math.floor(Math.random() * 254) + 1}.2/30 dev veth-${ns}-ns; ` +
|
|
86
|
+
`ip netns exec ${ns} ip link set veth-${ns}-ns up; ` +
|
|
87
|
+
`ip netns exec ${ns} ip link set lo up; ` +
|
|
88
|
+
// Use mullvad SOCKS proxy for the namespace (simpler, no route table manipulation)
|
|
89
|
+
`ip netns exec ${ns} bash -c 'export ALL_PROXY=socks5://10.64.0.1:1080; ${escaped}'; ` +
|
|
90
|
+
`_rc=$?; ip netns del ${ns} 2>/dev/null; ip link del veth-${ns} 2>/dev/null; exit $_rc`;
|
|
91
|
+
}
|
|
92
|
+
if (provider === 'openvpn') {
|
|
93
|
+
const configPath = param || '/etc/openvpn/client.conf';
|
|
94
|
+
// Run OpenVPN inside a network namespace — only the command sees the tunnel
|
|
95
|
+
return `ip netns add ${ns} 2>/dev/null; ` +
|
|
96
|
+
`ip netns exec ${ns} ip link set lo up; ` +
|
|
97
|
+
`ip netns exec ${ns} openvpn --config "${configPath}" --daemon --log /tmp/${ns}.log --writepid /tmp/${ns}.pid; ` +
|
|
98
|
+
`sleep 4; ` + // wait for tunnel
|
|
99
|
+
`ip netns exec ${ns} bash -c '${escaped}'; ` +
|
|
100
|
+
`_rc=$?; kill $(cat /tmp/${ns}.pid 2>/dev/null) 2>/dev/null; ip netns del ${ns} 2>/dev/null; rm -f /tmp/${ns}.log /tmp/${ns}.pid; exit $_rc`;
|
|
101
|
+
}
|
|
102
|
+
if (provider === 'wg' || provider === 'wireguard') {
|
|
103
|
+
const iface = param || 'wg-vpn';
|
|
104
|
+
// Move WireGuard VPN interface into namespace — mesh wg0 stays in main ns
|
|
105
|
+
return `ip netns add ${ns} 2>/dev/null; ` +
|
|
106
|
+
`ip link add ${iface}-${ns} type wireguard; ` +
|
|
107
|
+
`wg setconf ${iface}-${ns} /etc/wireguard/${iface}.conf 2>/dev/null; ` +
|
|
108
|
+
`ip link set ${iface}-${ns} netns ${ns}; ` +
|
|
109
|
+
`ip netns exec ${ns} ip link set lo up; ` +
|
|
110
|
+
`ip netns exec ${ns} ip addr add 10.66.0.2/32 dev ${iface}-${ns}; ` +
|
|
111
|
+
`ip netns exec ${ns} ip link set ${iface}-${ns} up; ` +
|
|
112
|
+
`ip netns exec ${ns} ip route add default dev ${iface}-${ns}; ` +
|
|
113
|
+
`ip netns exec ${ns} bash -c '${escaped}'; ` +
|
|
114
|
+
`_rc=$?; ip netns del ${ns} 2>/dev/null; exit $_rc`;
|
|
115
|
+
}
|
|
116
|
+
// Fallback: just run the command (no VPN wrapping)
|
|
117
|
+
return innerCmd;
|
|
118
|
+
}
|
|
64
119
|
// -- Agentic state -- shared across tool calls in the same MCP session --------
|
|
65
120
|
const resultStore = new Map(); // key -> value store for chaining
|
|
121
|
+
const bgTasks = new Map();
|
|
122
|
+
function bgId() { return `bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; }
|
|
123
|
+
function dispatchBg(node, label, fn) {
|
|
124
|
+
const id = bgId();
|
|
125
|
+
const task = { id, node, label, startedAt: Date.now(), promise: fn() };
|
|
126
|
+
task.promise.then((r) => { task.result = r; });
|
|
127
|
+
bgTasks.set(id, task);
|
|
128
|
+
return okBrief(`BACKGROUND ${id} dispatched on ${node}: ${label}`);
|
|
129
|
+
}
|
|
66
130
|
// -----------------------------------------------------------------------------
|
|
67
131
|
export function createOmniWireServer(manager, transfer) {
|
|
68
132
|
const server = new McpServer({
|
|
69
133
|
name: 'omniwire',
|
|
70
|
-
version: '2.
|
|
134
|
+
version: '2.6.0',
|
|
71
135
|
});
|
|
136
|
+
// -- Auto-inject `background` param into every tool -------------------------
|
|
137
|
+
const origTool = server.tool.bind(server);
|
|
138
|
+
server.tool = (name, desc, schema, handler) => {
|
|
139
|
+
// Skip bg meta-tool itself
|
|
140
|
+
if (name === 'omniwire_bg')
|
|
141
|
+
return origTool(name, desc, schema, handler);
|
|
142
|
+
const augSchema = { ...schema, background: z.boolean().optional().describe('Run in background. Returns task ID immediately — poll with omniwire_bg.') };
|
|
143
|
+
const wrappedHandler = async (args) => {
|
|
144
|
+
if (args.background) {
|
|
145
|
+
const lbl = args.label ?? args.command?.slice(0, 60) ?? name;
|
|
146
|
+
const nd = args.node ?? args.src_node ?? 'omniwire';
|
|
147
|
+
return dispatchBg(nd, lbl, () => handler(args));
|
|
148
|
+
}
|
|
149
|
+
return handler(args);
|
|
150
|
+
};
|
|
151
|
+
return origTool(name, desc, augSchema, wrappedHandler);
|
|
152
|
+
};
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
72
154
|
const shells = new ShellManager(manager);
|
|
73
155
|
const realtime = new RealtimeChannel(manager);
|
|
74
156
|
const tunnels = new TunnelManager(manager);
|
|
157
|
+
// --- Tool 0: omniwire_bg (background task manager) ---
|
|
158
|
+
origTool('omniwire_bg', 'Poll, list, or retrieve results from background tasks dispatched with background=true on any tool.', {
|
|
159
|
+
action: z.enum(['list', 'poll', 'result']).describe('list=show all tasks, poll=check if done, result=get output'),
|
|
160
|
+
task_id: z.string().optional().describe('Task ID (required for poll/result)'),
|
|
161
|
+
}, async ({ action, task_id }) => {
|
|
162
|
+
if (action === 'list') {
|
|
163
|
+
if (bgTasks.size === 0)
|
|
164
|
+
return okBrief('No background tasks.');
|
|
165
|
+
const lines = [...bgTasks.values()].map((bg) => {
|
|
166
|
+
const status = bg.result ? 'DONE' : 'RUNNING';
|
|
167
|
+
const elapsed = Date.now() - bg.startedAt;
|
|
168
|
+
return `${bg.id} ${status} ${bg.node} ${t(elapsed)} ${bg.label}`;
|
|
169
|
+
});
|
|
170
|
+
return okBrief(lines.join('\n'));
|
|
171
|
+
}
|
|
172
|
+
if (!task_id)
|
|
173
|
+
return fail('task_id required for poll/result');
|
|
174
|
+
const task = bgTasks.get(task_id);
|
|
175
|
+
if (!task)
|
|
176
|
+
return fail(`task ${task_id} not found`);
|
|
177
|
+
if (action === 'poll') {
|
|
178
|
+
const status = task.result ? 'DONE' : 'RUNNING';
|
|
179
|
+
const elapsed = Date.now() - task.startedAt;
|
|
180
|
+
return okBrief(`${task_id} ${status} (${t(elapsed)}) ${task.label}`);
|
|
181
|
+
}
|
|
182
|
+
if (action === 'result') {
|
|
183
|
+
if (!task.result)
|
|
184
|
+
return okBrief(`${task_id} still RUNNING (${t(Date.now() - task.startedAt)})`);
|
|
185
|
+
return task.result;
|
|
186
|
+
}
|
|
187
|
+
return fail('invalid action');
|
|
188
|
+
});
|
|
75
189
|
// --- Tool 1: omniwire_exec ---
|
|
76
|
-
server.tool('omniwire_exec', 'Execute a command on a mesh node.
|
|
190
|
+
server.tool('omniwire_exec', 'Execute a command on a mesh node. Set background=true for async. Set via_vpn to route through VPN (Mullvad/OpenVPN/WireGuard) for anonymous scanning. Supports retry, assert, JSON, store_as, {{key}}.', {
|
|
77
191
|
node: z.string().optional().describe('Target node id (windows, contabo, hostinger, thinkpad). Auto-selects if omitted.'),
|
|
78
192
|
command: z.string().optional().describe('Shell command to run. Use {{key}} to interpolate stored results from previous calls.'),
|
|
79
193
|
timeout: z.number().optional().describe('Timeout in seconds (default 30)'),
|
|
@@ -83,7 +197,8 @@ export function createOmniWireServer(manager, transfer) {
|
|
|
83
197
|
retry: z.number().optional().describe('Retry N times on failure (with 1s delay between). Default 0.'),
|
|
84
198
|
assert: z.string().optional().describe('Grep pattern to assert in stdout. If not found, returns error. Use for validation in agentic chains.'),
|
|
85
199
|
store_as: z.string().optional().describe('Store trimmed stdout under this key. Retrieve in subsequent calls via {{key}} in command.'),
|
|
86
|
-
|
|
200
|
+
via_vpn: z.string().optional().describe('Route command through VPN. Values: "mullvad" (auto), "mullvad:se" (country), "openvpn:/path/to.conf", "wg:wg-vpn". Connects VPN before exec, verifies IP changed.'),
|
|
201
|
+
}, async ({ node, command, timeout, script, label, format, retry, assert: assertPattern, store_as, via_vpn }) => {
|
|
87
202
|
if (!command && !script) {
|
|
88
203
|
return fail('either command or script is required');
|
|
89
204
|
}
|
|
@@ -91,7 +206,6 @@ export function createOmniWireServer(manager, transfer) {
|
|
|
91
206
|
const timeoutSec = timeout ?? 30;
|
|
92
207
|
const maxRetries = retry ?? 0;
|
|
93
208
|
const useJson = format === 'json';
|
|
94
|
-
// Interpolate stored results: {{key}} -> value
|
|
95
209
|
let resolvedCmd = command;
|
|
96
210
|
if (resolvedCmd) {
|
|
97
211
|
resolvedCmd = resolvedCmd.replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
|
|
@@ -106,17 +220,19 @@ export function createOmniWireServer(manager, transfer) {
|
|
|
106
220
|
? `timeout ${timeoutSec} bash -c '${resolvedCmd.replace(/'/g, "'\\''")}'`
|
|
107
221
|
: resolvedCmd;
|
|
108
222
|
}
|
|
109
|
-
//
|
|
223
|
+
// VPN routing: wrap command in network namespace so only THIS command goes through VPN.
|
|
224
|
+
// The mesh (WireGuard, SSH) stays on the real interface — zero disruption.
|
|
225
|
+
if (via_vpn) {
|
|
226
|
+
effectiveCmd = buildVpnWrappedCmd(via_vpn, effectiveCmd);
|
|
227
|
+
}
|
|
110
228
|
let result = await manager.exec(nodeId, effectiveCmd);
|
|
111
229
|
for (let attempt = 0; attempt < maxRetries && result.code !== 0; attempt++) {
|
|
112
230
|
await new Promise((r) => setTimeout(r, 1000));
|
|
113
231
|
result = await manager.exec(nodeId, effectiveCmd);
|
|
114
232
|
}
|
|
115
|
-
// Store result if requested
|
|
116
233
|
if (store_as && result.code === 0) {
|
|
117
234
|
resultStore.set(store_as, result.stdout.trim());
|
|
118
235
|
}
|
|
119
|
-
// Assert pattern in output
|
|
120
236
|
if (assertPattern && result.code === 0) {
|
|
121
237
|
const regex = new RegExp(assertPattern);
|
|
122
238
|
if (!regex.test(result.stdout)) {
|
|
@@ -684,7 +800,177 @@ tags: ${meshNode.tags.join(', ')}`;
|
|
|
684
800
|
const result = await manager.exec(node, cmd);
|
|
685
801
|
return ok(node, result.durationMs, result.code === 0 ? result.stdout : result.stderr, `net ${action}`);
|
|
686
802
|
});
|
|
687
|
-
// --- Tool 30:
|
|
803
|
+
// --- Tool 30: omniwire_vpn ---
|
|
804
|
+
server.tool('omniwire_vpn', 'Manage VPN on mesh nodes (Mullvad/OpenVPN/WireGuard). SAFE: uses split-tunneling or network namespaces — mesh connectivity (WireGuard, SSH) is never disrupted. Use via_vpn on omniwire_exec to route individual commands through VPN.', {
|
|
805
|
+
action: z.enum(['connect', 'disconnect', 'status', 'list', 'ip', 'rotate', 'full-on', 'full-off']).describe('connect=start VPN (split-tunnel, mesh safe), disconnect=stop, status=current state, list=servers/relays, ip=public IP, rotate=new exit, full-on=node-wide VPN with mesh exclusions, full-off=restore default routing'),
|
|
806
|
+
node: z.string().optional().describe('Node to manage VPN on (default: contabo)'),
|
|
807
|
+
provider: z.enum(['mullvad', 'openvpn', 'wireguard', 'tailscale']).optional().describe('VPN provider (default: auto-detect)'),
|
|
808
|
+
server: z.string().optional().describe('Server/relay to connect to. Mullvad: country code (us, de, se) or relay name. OpenVPN: config file path. WireGuard: interface name. Tailscale: exit node hostname.'),
|
|
809
|
+
config: z.string().optional().describe('OpenVPN config file path (for openvpn provider)'),
|
|
810
|
+
}, async ({ action, node, provider, server: vpnServer, config }) => {
|
|
811
|
+
const nodeId = node ?? 'contabo';
|
|
812
|
+
// Auto-detect provider (prefer mullvad > tailscale > openvpn > wireguard)
|
|
813
|
+
const detectCmd = 'command -v mullvad >/dev/null && echo mullvad || (command -v tailscale >/dev/null && echo tailscale || (command -v openvpn >/dev/null && echo openvpn || (command -v wg >/dev/null && echo wireguard || echo none)))';
|
|
814
|
+
const detected = provider ?? (await manager.exec(nodeId, detectCmd)).stdout.trim();
|
|
815
|
+
if (action === 'ip') {
|
|
816
|
+
const result = await manager.exec(nodeId, 'curl -s --max-time 5 https://am.i.mullvad.net/json 2>/dev/null || curl -s --max-time 5 https://ipinfo.io/json 2>/dev/null || curl -s --max-time 5 https://ifconfig.me');
|
|
817
|
+
return ok(nodeId, result.durationMs, result.stdout, 'public IP');
|
|
818
|
+
}
|
|
819
|
+
if (detected === 'mullvad') {
|
|
820
|
+
// Mullvad split-tunnel: excludes mesh interfaces (wg0, tailscale0) from VPN routing.
|
|
821
|
+
// SSH connections over WireGuard mesh are preserved — only non-mesh traffic goes through Mullvad.
|
|
822
|
+
const splitSetup = 'mullvad split-tunnel set state on 2>/dev/null; mullvad lan set allow 2>/dev/null;';
|
|
823
|
+
switch (action) {
|
|
824
|
+
case 'connect': {
|
|
825
|
+
const relay = vpnServer ? `mullvad relay set location ${vpnServer} && ` : '';
|
|
826
|
+
const result = await manager.exec(nodeId, `${splitSetup} ${relay}mullvad connect && sleep 2 && mullvad status && echo "--- mesh check ---" && ip route get 10.10.0.1 2>/dev/null | head -1`);
|
|
827
|
+
return ok(nodeId, result.durationMs, result.stdout, `mullvad connect${vpnServer ? ` ${vpnServer}` : ''}`);
|
|
828
|
+
}
|
|
829
|
+
case 'disconnect': {
|
|
830
|
+
const result = await manager.exec(nodeId, 'mullvad disconnect && mullvad status');
|
|
831
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad disconnect');
|
|
832
|
+
}
|
|
833
|
+
case 'status': {
|
|
834
|
+
const result = await manager.exec(nodeId, 'mullvad status && echo "---split-tunnel---" && mullvad split-tunnel get 2>/dev/null && echo "---public-ip---" && curl -s --max-time 5 https://am.i.mullvad.net/json 2>/dev/null | grep -E "ip|country|mullvad"');
|
|
835
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad status');
|
|
836
|
+
}
|
|
837
|
+
case 'list': {
|
|
838
|
+
const result = await manager.exec(nodeId, 'mullvad relay list 2>&1 | head -60');
|
|
839
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad relays');
|
|
840
|
+
}
|
|
841
|
+
case 'rotate': {
|
|
842
|
+
const result = await manager.exec(nodeId, `${splitSetup} mullvad disconnect && sleep 1 && mullvad relay set tunnel-protocol wireguard && mullvad connect && sleep 2 && mullvad status && echo "---ip---" && curl -s --max-time 5 https://am.i.mullvad.net/json | grep -E "ip|country|mullvad_exit"`);
|
|
843
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad rotate');
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (detected === 'openvpn') {
|
|
848
|
+
const configPath = config ?? vpnServer ?? '/etc/openvpn/client.conf';
|
|
849
|
+
switch (action) {
|
|
850
|
+
case 'connect': {
|
|
851
|
+
const result = await manager.exec(nodeId, `openvpn --config "${configPath}" --daemon --log /tmp/openvpn.log && sleep 3 && ip addr show tun0 2>/dev/null | grep inet && curl -s --max-time 5 https://ifconfig.me`);
|
|
852
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn connect');
|
|
853
|
+
}
|
|
854
|
+
case 'disconnect': {
|
|
855
|
+
const result = await manager.exec(nodeId, 'pkill openvpn 2>/dev/null; sleep 1; pgrep openvpn >/dev/null && echo "still running" || echo "disconnected"');
|
|
856
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn disconnect');
|
|
857
|
+
}
|
|
858
|
+
case 'status': {
|
|
859
|
+
const result = await manager.exec(nodeId, 'pgrep -a openvpn 2>/dev/null || echo "not running"; ip addr show tun0 2>/dev/null | grep inet || echo "no tunnel"; tail -5 /tmp/openvpn.log 2>/dev/null');
|
|
860
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn status');
|
|
861
|
+
}
|
|
862
|
+
case 'list': {
|
|
863
|
+
const result = await manager.exec(nodeId, 'ls /etc/openvpn/*.conf /etc/openvpn/*.ovpn /etc/openvpn/client/*.conf /etc/openvpn/client/*.ovpn 2>/dev/null || echo "no configs found"');
|
|
864
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn configs');
|
|
865
|
+
}
|
|
866
|
+
case 'rotate': {
|
|
867
|
+
const result = await manager.exec(nodeId, `pkill openvpn 2>/dev/null; sleep 1; openvpn --config "${configPath}" --daemon --log /tmp/openvpn.log && sleep 3 && curl -s --max-time 5 https://ifconfig.me`);
|
|
868
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn rotate');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (detected === 'wireguard') {
|
|
873
|
+
const iface = vpnServer ?? 'wg-vpn';
|
|
874
|
+
switch (action) {
|
|
875
|
+
case 'connect': {
|
|
876
|
+
const result = await manager.exec(nodeId, `wg-quick up ${iface} 2>&1 && sleep 1 && wg show ${iface} | head -10 && curl -s --max-time 5 https://ifconfig.me`);
|
|
877
|
+
return ok(nodeId, result.durationMs, result.stdout, `wg up ${iface}`);
|
|
878
|
+
}
|
|
879
|
+
case 'disconnect': {
|
|
880
|
+
const result = await manager.exec(nodeId, `wg-quick down ${iface} 2>&1`);
|
|
881
|
+
return ok(nodeId, result.durationMs, result.stdout, `wg down ${iface}`);
|
|
882
|
+
}
|
|
883
|
+
case 'status': {
|
|
884
|
+
const result = await manager.exec(nodeId, 'wg show all 2>/dev/null || echo "no WireGuard interfaces"');
|
|
885
|
+
return ok(nodeId, result.durationMs, result.stdout, 'wg status');
|
|
886
|
+
}
|
|
887
|
+
case 'list': {
|
|
888
|
+
const result = await manager.exec(nodeId, 'ls /etc/wireguard/*.conf 2>/dev/null | sed "s|/etc/wireguard/||;s|\\.conf||" || echo "no configs"');
|
|
889
|
+
return ok(nodeId, result.durationMs, result.stdout, 'wg interfaces');
|
|
890
|
+
}
|
|
891
|
+
case 'rotate': {
|
|
892
|
+
const result = await manager.exec(nodeId, `wg-quick down ${iface} 2>/dev/null; sleep 1; wg-quick up ${iface} 2>&1 && sleep 1 && curl -s --max-time 5 https://ifconfig.me`);
|
|
893
|
+
return ok(nodeId, result.durationMs, result.stdout, `wg rotate ${iface}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (detected === 'tailscale') {
|
|
898
|
+
switch (action) {
|
|
899
|
+
case 'connect': {
|
|
900
|
+
const exitNode = vpnServer ? `--exit-node=${vpnServer}` : '';
|
|
901
|
+
const result = await manager.exec(nodeId, `tailscale up --accept-routes ${exitNode} 2>&1 && sleep 2 && tailscale status | head -15`);
|
|
902
|
+
return ok(nodeId, result.durationMs, result.stdout, `tailscale connect${vpnServer ? ` via ${vpnServer}` : ''}`);
|
|
903
|
+
}
|
|
904
|
+
case 'disconnect': {
|
|
905
|
+
const result = await manager.exec(nodeId, 'tailscale up --exit-node= 2>&1 && tailscale status | head -5');
|
|
906
|
+
return ok(nodeId, result.durationMs, result.stdout, 'tailscale clear exit-node');
|
|
907
|
+
}
|
|
908
|
+
case 'status': {
|
|
909
|
+
const result = await manager.exec(nodeId, 'tailscale status 2>&1 && echo "---ip---" && tailscale ip -4 2>/dev/null && echo "---exit---" && tailscale exit-node status 2>/dev/null');
|
|
910
|
+
return ok(nodeId, result.durationMs, result.stdout, 'tailscale status');
|
|
911
|
+
}
|
|
912
|
+
case 'list': {
|
|
913
|
+
const result = await manager.exec(nodeId, 'tailscale exit-node list 2>&1 | head -40');
|
|
914
|
+
return ok(nodeId, result.durationMs, result.stdout, 'tailscale exit nodes');
|
|
915
|
+
}
|
|
916
|
+
case 'rotate': {
|
|
917
|
+
const result = await manager.exec(nodeId, `tailscale up --exit-node= 2>/dev/null; sleep 1; ${vpnServer ? `tailscale up --exit-node=${vpnServer}` : 'tailscale up'} 2>&1 && sleep 2 && curl -s --max-time 5 https://ifconfig.me`);
|
|
918
|
+
return ok(nodeId, result.durationMs, result.stdout, 'tailscale rotate');
|
|
919
|
+
}
|
|
920
|
+
case 'full-on': {
|
|
921
|
+
const exitNode = vpnServer ?? '';
|
|
922
|
+
if (!exitNode)
|
|
923
|
+
return fail('server param required for full-on (tailscale exit node hostname)');
|
|
924
|
+
const result = await manager.exec(nodeId, `tailscale up --exit-node=${exitNode} --exit-node-allow-lan-access 2>&1 && sleep 2 && tailscale status | head -10 && echo "---ip---" && curl -s --max-time 5 https://ifconfig.me`);
|
|
925
|
+
return ok(nodeId, result.durationMs, result.stdout, `tailscale full-on via ${exitNode}`);
|
|
926
|
+
}
|
|
927
|
+
case 'full-off': {
|
|
928
|
+
const result = await manager.exec(nodeId, 'tailscale up --exit-node= 2>&1 && tailscale status | head -5');
|
|
929
|
+
return ok(nodeId, result.durationMs, result.stdout, 'tailscale full-off');
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// full-on / full-off: node-wide VPN with mesh route exclusions
|
|
934
|
+
if (action === 'full-on') {
|
|
935
|
+
if (detected === 'mullvad') {
|
|
936
|
+
const relay = vpnServer ? `mullvad relay set location ${vpnServer};` : '';
|
|
937
|
+
// Mullvad full mode: enable, but add route exclusions for mesh IPs
|
|
938
|
+
const result = await manager.exec(nodeId, `${relay} mullvad lan set allow; mullvad split-tunnel set state on; mullvad connect && sleep 2 && ` +
|
|
939
|
+
// Preserve ALL mesh routes: wg0 (main mesh), wg1 (B2B), tailscale0 (TS networks)
|
|
940
|
+
`ip route add 10.10.0.0/24 dev wg0 2>/dev/null; ` + // WG mesh
|
|
941
|
+
`ip route add 10.20.0.0/24 dev wg1 2>/dev/null; ` + // WG B2B
|
|
942
|
+
`ip route add 100.64.0.0/10 dev tailscale0 2>/dev/null; ` + // Tailscale CGNAT range
|
|
943
|
+
`mullvad status && echo "---mesh-check---" && ping -c1 -W2 10.10.0.1 2>/dev/null && echo "mesh: OK" || echo "mesh: WARN"`);
|
|
944
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad full-on (mesh preserved)');
|
|
945
|
+
}
|
|
946
|
+
if (detected === 'openvpn') {
|
|
947
|
+
const configPath = config ?? vpnServer ?? '/etc/openvpn/client.conf';
|
|
948
|
+
// OpenVPN with route-nopull + specific routes — keeps mesh alive
|
|
949
|
+
const result = await manager.exec(nodeId, `openvpn --config "${configPath}" --daemon --log /tmp/openvpn-full.log ` +
|
|
950
|
+
`--route-nopull --route 0.0.0.0 0.0.0.0 vpn_gateway ` +
|
|
951
|
+
`--route 10.10.0.0 255.255.255.0 net_gateway ` + // wg0 mesh
|
|
952
|
+
`--route 10.20.0.0 255.255.255.0 net_gateway ` + // wg1 B2B
|
|
953
|
+
`--route 100.64.0.0 255.192.0.0 net_gateway ` + // tailscale CGNAT
|
|
954
|
+
`&& sleep 4 && ip addr show tun0 2>/dev/null | grep inet && curl -s --max-time 5 https://ifconfig.me && ` +
|
|
955
|
+
`echo "---mesh---" && ping -c1 -W2 10.10.0.1 2>/dev/null && echo "mesh: OK" || echo "mesh: WARN"`);
|
|
956
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn full-on (mesh preserved)');
|
|
957
|
+
}
|
|
958
|
+
return fail(`full-on not supported for ${detected}. Use mullvad, openvpn, or tailscale.`);
|
|
959
|
+
}
|
|
960
|
+
if (action === 'full-off') {
|
|
961
|
+
if (detected === 'mullvad') {
|
|
962
|
+
const result = await manager.exec(nodeId, 'mullvad disconnect && mullvad status');
|
|
963
|
+
return ok(nodeId, result.durationMs, result.stdout, 'mullvad full-off');
|
|
964
|
+
}
|
|
965
|
+
if (detected === 'openvpn') {
|
|
966
|
+
const result = await manager.exec(nodeId, 'pkill openvpn 2>/dev/null; sleep 1; echo "disconnected" && curl -s --max-time 5 https://ifconfig.me');
|
|
967
|
+
return ok(nodeId, result.durationMs, result.stdout, 'openvpn full-off');
|
|
968
|
+
}
|
|
969
|
+
return fail(`full-off not supported for ${detected}`);
|
|
970
|
+
}
|
|
971
|
+
return fail(`No VPN provider found on ${nodeId}. Install mullvad, tailscale, openvpn, or wireguard.`);
|
|
972
|
+
});
|
|
973
|
+
// --- Tool 31: omniwire_clipboard ---
|
|
688
974
|
server.tool('omniwire_clipboard', 'Copy text between nodes via a shared clipboard buffer.', {
|
|
689
975
|
action: z.enum(['copy', 'paste', 'clear']).describe('Action'),
|
|
690
976
|
content: z.string().optional().describe('Text to copy (for copy action)'),
|