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.
@@ -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.5.0',
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. Supports retry, assertions, JSON output, and result storage for agentic chaining.', {
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
- }, async ({ node, command, timeout, script, label, format, retry, assert: assertPattern, store_as }) => {
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
- // Execute with retry
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: omniwire_clipboard ---
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)'),