@twinsearth/w2m-dsh-plugin 0.4.9 → 0.5.1
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/CHANGELOG.md +100 -0
- package/deploy/networking/LESSONS.md +90 -0
- package/deploy/networking/README.md +130 -0
- package/deploy/networking/acceptance.mjs +306 -0
- package/deploy/networking/wireguard/server-setup.sh +106 -0
- package/deploy/windows/install-service.ps1 +25 -1
- package/deploy/windows/w2m-agent-supervisor.mjs +31 -9
- package/package.json +1 -1
- package/src/agent/agent.mjs +474 -17
- package/src/agent/p2p-node.mjs +118 -23
- package/src/agent/p2p.mjs +137 -9
- package/src/plugin/tools.mjs +136 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# WireGuard hub for a W2M fleet -- rung 4 of the ladder in ../README.md
|
|
4
|
+
# ============================================================================
|
|
5
|
+
#
|
|
6
|
+
# WHAT THIS IS FOR, AND WHAT IT IS NOT
|
|
7
|
+
#
|
|
8
|
+
# It makes every machine reachable by a stable address (10.66.0.x) no matter what its NAT does,
|
|
9
|
+
# encrypted, with no third-party coordination service. That is rung 4.
|
|
10
|
+
#
|
|
11
|
+
# It is NOT a direct path between two machines behind NAT. WireGuard has no NAT traversal: with
|
|
12
|
+
# both machines as peers of this hub, their traffic to each other is forwarded through this VPS.
|
|
13
|
+
# Direct paths are rungs 2 and 3 of the ladder -- the simultaneous punch and the reverse dial --
|
|
14
|
+
# which live in the plugin, not here. Stated plainly because a tunnel that reports `p2p` in the
|
|
15
|
+
# ledger while the bytes cross a server is exactly the kind of thing that misleads an operator.
|
|
16
|
+
#
|
|
17
|
+
# RUN IT ON THE ALWAYS-ON HOST (the shared server), as root, once:
|
|
18
|
+
#
|
|
19
|
+
# bash server-setup.sh # keys are generated if absent, config is rewritten, wg0 up
|
|
20
|
+
# bash server-setup.sh --client mac # print one client config to paste into the WireGuard app
|
|
21
|
+
#
|
|
22
|
+
# Idempotent: re-running keeps existing keys (so paired clients keep working) and only rewrites the
|
|
23
|
+
# hub config and its peers.
|
|
24
|
+
# ============================================================================
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
HUB_ADDRESS="${HUB_ADDRESS:-10.66.0.1/24}"
|
|
28
|
+
LISTEN_PORT="${LISTEN_PORT:-51820}"
|
|
29
|
+
KEYS=/etc/wireguard/keys
|
|
30
|
+
WG=/etc/wireguard/wg0.conf
|
|
31
|
+
# Client name -> tunnel address. Add a line here to add a machine.
|
|
32
|
+
CLIENTS=("win-desktop:10.66.0.2" "macmini:10.66.0.3")
|
|
33
|
+
|
|
34
|
+
# 51820 is WireGuard's default and the fleet's own punch port is deliberately somewhere else
|
|
35
|
+
# (41235 on this fleet) so the two never contend.
|
|
36
|
+
if [ "$(id -u)" != "0" ]; then echo "run as root (this writes /etc/wireguard and edits the firewall)" >&2; exit 1; fi
|
|
37
|
+
|
|
38
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
39
|
+
command -v wg >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq wireguard wireguard-tools >/dev/null; }
|
|
40
|
+
|
|
41
|
+
umask 077
|
|
42
|
+
mkdir -p "$KEYS"
|
|
43
|
+
[ -f "$KEYS/server.key" ] || wg genkey > "$KEYS/server.key"
|
|
44
|
+
wg pubkey < "$KEYS/server.key" > "$KEYS/server.pub"
|
|
45
|
+
for entry in "${CLIENTS[@]}"; do
|
|
46
|
+
name="${entry%%:*}"
|
|
47
|
+
[ -f "$KEYS/$name.key" ] || wg genkey > "$KEYS/$name.key"
|
|
48
|
+
wg pubkey < "$KEYS/$name.key" > "$KEYS/$name.pub"
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
# Print one client's config and exit -- the only thing a client needs from this host.
|
|
52
|
+
if [ "${1:-}" = "--client" ]; then
|
|
53
|
+
name="${2:-}"
|
|
54
|
+
address=""
|
|
55
|
+
for entry in "${CLIENTS[@]}"; do [ "${entry%%:*}" = "$name" ] && address="${entry##*:}"; done
|
|
56
|
+
[ -n "$address" ] || { echo "unknown client '$name'; known: ${CLIENTS[*]%%:*}" >&2; exit 1; }
|
|
57
|
+
endpoint="${ENDPOINT:-$(curl -fsS -m 8 https://api.ipify.org)}"
|
|
58
|
+
cat <<EOF
|
|
59
|
+
[Interface]
|
|
60
|
+
# $name -- rung 4 of the W2M delivery ladder (see deploy/networking/README.md)
|
|
61
|
+
PrivateKey = $(cat "$KEYS/$name.key")
|
|
62
|
+
Address = $address/24
|
|
63
|
+
# No DNS line on purpose: only $HUB_ADDRESS is routed, so your resolver stays untouched.
|
|
64
|
+
|
|
65
|
+
[Peer]
|
|
66
|
+
PublicKey = $(cat "$KEYS/server.pub")
|
|
67
|
+
Endpoint = $endpoint:$LISTEN_PORT
|
|
68
|
+
AllowedIPs = $(echo "$HUB_ADDRESS" | cut -d/ -f1 | awk -F. '{print $1"."$2"."$3".0/24"}')
|
|
69
|
+
PersistentKeepalive = 25
|
|
70
|
+
EOF
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
echo "# W2M rung 4: a private hub so the fleet is reachable whatever the NATs do."
|
|
76
|
+
echo "# Traffic between clients is forwarded here -- WireGuard does not punch. See README.md."
|
|
77
|
+
echo "[Interface]"
|
|
78
|
+
echo "Address = $HUB_ADDRESS"
|
|
79
|
+
echo "ListenPort = $LISTEN_PORT"
|
|
80
|
+
echo "PrivateKey = $(cat "$KEYS/server.key")"
|
|
81
|
+
echo "# No PostUp MASQUERADE: clients route only the tunnel subnet here, so nothing needs NATing"
|
|
82
|
+
echo "# and none of their other traffic can leak through this host."
|
|
83
|
+
echo "SaveConfig = false"
|
|
84
|
+
echo
|
|
85
|
+
for entry in "${CLIENTS[@]}"; do
|
|
86
|
+
name="${entry%%:*}"; address="${entry##*:}"
|
|
87
|
+
echo "[Peer]"
|
|
88
|
+
echo "# $name"
|
|
89
|
+
echo "PublicKey = $(cat "$KEYS/$name.pub")"
|
|
90
|
+
echo "AllowedIPs = $address/32"
|
|
91
|
+
echo
|
|
92
|
+
done
|
|
93
|
+
} > "$WG"
|
|
94
|
+
chmod 600 "$WG"
|
|
95
|
+
|
|
96
|
+
sysctl -qw net.ipv4.ip_forward=1
|
|
97
|
+
grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf || echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
|
|
98
|
+
command -v ufw >/dev/null 2>&1 && ufw allow "$LISTEN_PORT/udp" >/dev/null || true
|
|
99
|
+
|
|
100
|
+
systemctl enable wg-quick@wg0 >/dev/null 2>&1 || true
|
|
101
|
+
systemctl restart wg-quick@wg0
|
|
102
|
+
sleep 2
|
|
103
|
+
systemctl is-active wg-quick@wg0
|
|
104
|
+
wg show
|
|
105
|
+
echo
|
|
106
|
+
echo "client configs: bash $0 --client <name> (known: ${CLIENTS[*]%%:*})"
|
|
@@ -56,6 +56,7 @@ param(
|
|
|
56
56
|
[string]$Bin = '',
|
|
57
57
|
[string]$AgentArgs = '',
|
|
58
58
|
[int]$RestartBackoffMs = 60000,
|
|
59
|
+
[int]$WatchdogMinutes = 5,
|
|
59
60
|
[switch]$Interactive,
|
|
60
61
|
[switch]$Uninstall,
|
|
61
62
|
[switch]$WhatIfOnly
|
|
@@ -133,11 +134,34 @@ $json = $config | ConvertTo-Json -Depth 4
|
|
|
133
134
|
Write-Host "wrote $configPath"
|
|
134
135
|
|
|
135
136
|
$action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
|
|
137
|
+
# ---------------------------------------------------------------------------------------------
|
|
138
|
+
# SELF-HEALING WITHOUT ELEVATION
|
|
139
|
+
#
|
|
140
|
+
# Measured twice on this machine: the whole process tree (supervisor + agent) is killed with
|
|
141
|
+
# 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away), the log ends mid-sentence, and Task
|
|
142
|
+
# Scheduler does NOT bring it back even with RestartCount 999: the task simply ends. The fleet then
|
|
143
|
+
# silently loses the machine, which is exactly the failure a supervisor was supposed to prevent --
|
|
144
|
+
# a supervisor that shares the console dies with it.
|
|
145
|
+
#
|
|
146
|
+
# A repeating trigger fixes that without any administrator right: combined with
|
|
147
|
+
# `MultipleInstances IgnoreNew` the task means "be running, or start", so a death from any cause is
|
|
148
|
+
# repaired at the next tick. The window is the repetition interval, and that is the honest trade:
|
|
149
|
+
# up to N minutes offline after a console-close death, versus needing elevation for a session-0
|
|
150
|
+
# service (the real fix, and the default mode above).
|
|
151
|
+
# ---------------------------------------------------------------------------------------------
|
|
152
|
+
$repetition = if ($Interactive) {
|
|
153
|
+
# `[TimeSpan]::MaxValue` is NOT a valid duration here -- Task Scheduler rejects it and the whole
|
|
154
|
+
# registration fails (measured: the script had already stopped the old task, so the machine was
|
|
155
|
+
# left with no agent at all). Ten years is the idiomatic stand-in for "repeat indefinitely".
|
|
156
|
+
New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
|
157
|
+
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
|
|
158
|
+
-RepetitionDuration ([TimeSpan]::FromDays(3650))
|
|
159
|
+
} else { $null }
|
|
136
160
|
# AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
|
|
137
161
|
# signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
|
|
138
162
|
# measured, and the reason this list is conditional rather than always two entries.
|
|
139
163
|
$triggers = if ($Interactive) {
|
|
140
|
-
@(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
|
|
164
|
+
@((New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"), $repetition)
|
|
141
165
|
} else {
|
|
142
166
|
@(
|
|
143
167
|
(New-ScheduledTaskTrigger -AtStartup),
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
//
|
|
36
36
|
// Nothing here is Windows-specific except the defaults; the same supervisor works on POSIX hosts.
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
39
39
|
import { dirname, join } from 'node:path';
|
|
40
40
|
|
|
41
41
|
/** Minimal `--flag value` / `--flag` parser: no dependency, no surprises. */
|
|
@@ -149,6 +149,9 @@ if (!existsSync(bin)) {
|
|
|
149
149
|
|
|
150
150
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
151
151
|
const log = createWriteStream(logPath, { flags: 'a' });
|
|
152
|
+
// A second handle on the same file, as a descriptor: this is what the detached agent writes into,
|
|
153
|
+
// because a descriptor is not connected to this process's console or to any pipe of ours.
|
|
154
|
+
const logFd = openSync(logPath, 'a');
|
|
152
155
|
const stamp = () => new Date().toISOString();
|
|
153
156
|
const say = (line) => {
|
|
154
157
|
console.log(line);
|
|
@@ -177,14 +180,33 @@ let runs = 0;
|
|
|
177
180
|
while (!stopping) {
|
|
178
181
|
runs += 1;
|
|
179
182
|
const startedAt = Date.now();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
// ---------------------------------------------------------------------------------------------
|
|
184
|
+
// THE AGENT MUST NOT SHARE THIS PROCESS'S CONSOLE.
|
|
185
|
+
//
|
|
186
|
+
// Measured twice on the fleet: the whole tree -- supervisor and agent together -- is killed with
|
|
187
|
+
// 0xC000013A (STATUS_CONTROL_C_EXIT, "the console went away"), the log ends mid-sentence, and
|
|
188
|
+
// nothing restarts it. A supervisor that shares the console dies with the thing it supervises,
|
|
189
|
+
// which was its one job. A study of v2rayN (GPL-3.0, read-only; see deploy/networking/LESSONS.md)
|
|
190
|
+
// turned up why its core cannot die this way: it is started with no console window and redirected
|
|
191
|
+
// stdio at all.
|
|
192
|
+
//
|
|
193
|
+
// `detached: true` plus stdio that is NOT connected to this process is the documented way to make
|
|
194
|
+
// a long-running child independent of its parent's console: with `detached`, a long-running child
|
|
195
|
+
// "will not stay running in the background after the parent exits unless it is provided with a
|
|
196
|
+
// `stdio` configuration that is not connected to the parent". So the agent gets file descriptors --
|
|
197
|
+
// never `inherit`, never pipes -- and that is the whole of the console-severing change.
|
|
198
|
+
//
|
|
199
|
+
// NOT `child.unref()`. It was the obvious-looking addition and it is wrong here: unref'ing the
|
|
200
|
+
// child leaves this process with no handle at all, so its event loop drains and **the supervisor
|
|
201
|
+
// exits immediately after spawning the agent** -- measured, and caught by noticing that no
|
|
202
|
+
// supervisor process existed at all. `unref()` is for a parent that intends to leave; this one
|
|
203
|
+
// exists to stay. Pipes would be the other trap: a detached child writing into a pipe whose reader
|
|
204
|
+
// has died blocks instead of failing loudly, which is why the log is a descriptor.
|
|
205
|
+
// ---------------------------------------------------------------------------------------------
|
|
206
|
+
const child = spawn(process.execPath, argv, {
|
|
207
|
+
detached: true,
|
|
208
|
+
stdio: ['ignore', logFd, logFd],
|
|
209
|
+
windowsHide: true,
|
|
188
210
|
});
|
|
189
211
|
|
|
190
212
|
const result = await new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|