@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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 +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# w2m-rabbit.service -- systemd unit for the W2M relay
|
|
3
|
+
# ============================================================================
|
|
4
|
+
#
|
|
5
|
+
# This file is a TEMPLATE. Replace the @PLACEHOLDERS@ before installing.
|
|
6
|
+
#
|
|
7
|
+
# Install (Linux server, systemd):
|
|
8
|
+
# sudo useradd --system --home /var/lib/w2m --shell /usr/sbin/nologin w2m
|
|
9
|
+
# sudo install -d -o w2m -g w2m -m 0750 /var/lib/w2m
|
|
10
|
+
# sudo install -d -o w2m -g w2m -m 0750 /etc/w2m
|
|
11
|
+
# sudo install -m 0644 deploy/systemd/w2m-rabbit.service \
|
|
12
|
+
# /etc/systemd/system/w2m-rabbit.service
|
|
13
|
+
# sudo systemctl daemon-reload
|
|
14
|
+
# sudo systemctl enable --now w2m-rabbit
|
|
15
|
+
# journalctl -u w2m-rabbit -f # watch the pairing code / operator token
|
|
16
|
+
#
|
|
17
|
+
# Validated how: systemd is not available on the authoring machine, so this
|
|
18
|
+
# file was NOT checked with `systemd-analyze verify`. It follows the unit
|
|
19
|
+
# directives' documented semantics; docs/DEPLOY.md names the exact command to
|
|
20
|
+
# run before trusting it.
|
|
21
|
+
#
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# WHAT THIS UNIT DELIBERATELY DOES
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# * Binds 127.0.0.1, never 0.0.0.0. In every supported shape something else
|
|
26
|
+
# terminates TLS (nginx/Caddy/tunnel) or the network is already encrypted
|
|
27
|
+
# (Tailscale/WireGuard), so the relay must not be reachable directly.
|
|
28
|
+
# * Keeps state in /var/lib/w2m via StateDirectory=, so devices and the task
|
|
29
|
+
# ledger survive restarts. Without this, every restart forces every machine
|
|
30
|
+
# to pair again -- see docs/TROUBLESHOOTING.md, "everything needs re-pairing".
|
|
31
|
+
# * Writes nothing to the user's home and needs no writable directory except
|
|
32
|
+
# its state dir and /run for the pairing-code file.
|
|
33
|
+
# * Restarts on failure but not in a tight loop (RestartSec + StartLimit*).
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
[Unit]
|
|
37
|
+
Description=W2M relay (Rabbit) - task relay for cross-machine execution
|
|
38
|
+
Documentation=https://github.com/TwinsEarth/dsh-windows2macos/blob/main/docs/DEPLOY.md
|
|
39
|
+
# Start after the network is up; the relay itself does not need DNS, but
|
|
40
|
+
# ordering here keeps journal timestamps meaningful on a cold boot.
|
|
41
|
+
After=network-online.target
|
|
42
|
+
Wants=network-online.target
|
|
43
|
+
# If the relay sits behind a reverse proxy on the same host, uncomment so the
|
|
44
|
+
# proxy outlives a relay restart without logging connection errors:
|
|
45
|
+
# After=nginx.service
|
|
46
|
+
|
|
47
|
+
[Service]
|
|
48
|
+
Type=simple
|
|
49
|
+
User=w2m
|
|
50
|
+
Group=w2m
|
|
51
|
+
|
|
52
|
+
# --- state -----------------------------------------------------------------
|
|
53
|
+
# systemd creates /var/lib/w2m owned by User/Group.
|
|
54
|
+
# `%S` is the *state root* (/var/lib), so `--state %S/w2m` is /var/lib/w2m --
|
|
55
|
+
# the same directory StateDirectory= names. Using the full `%S/w2m` path rather
|
|
56
|
+
# than $STATE_DIRECTORY keeps the flag self-explanatory in `ps` output.
|
|
57
|
+
StateDirectory=w2m
|
|
58
|
+
StateDirectoryMode=0750
|
|
59
|
+
Environment=DSH_HOME=/var/lib/w2m
|
|
60
|
+
# Files created by the relay (operator-token.txt is written 0600, devices.json
|
|
61
|
+
# and ledger.jsonl hold device tokens) must not be world-readable.
|
|
62
|
+
UMask=0077
|
|
63
|
+
|
|
64
|
+
# --- relay flags -----------------------------------------------------------
|
|
65
|
+
# Keep the relay on loopback and let the proxy/tunnel terminate TLS.
|
|
66
|
+
#
|
|
67
|
+
# Shape A (Tailscale): change the bind address to the tailnet address and read
|
|
68
|
+
# the "Shape A" section of docs/DEPLOY.md instead; the relay must be
|
|
69
|
+
# reachable from the tailnet, and 127.0.0.1 is not.
|
|
70
|
+
#
|
|
71
|
+
# Shape B (VPS + TLS): the relay stays here on loopback; nginx/Caddy owns 443.
|
|
72
|
+
# If you prefer the relay to terminate TLS itself, replace the two flags
|
|
73
|
+
# below with --tls-cert / --tls-key and give it a --port clients can reach,
|
|
74
|
+
# then delete the reverse proxy. Pick one, never both.
|
|
75
|
+
#
|
|
76
|
+
# Shape C (tunnel): the tunnel client runs as its own unit (see docs) and
|
|
77
|
+
# dials 127.0.0.1:8787, so this line does not change. If the tunnel publishes
|
|
78
|
+
# the relay under a prefix, add --base-path here AND keep the two in sync.
|
|
79
|
+
ExecStart=@RELAY_BIN@ --host 127.0.0.1 --port 8787 --state %S/w2m
|
|
80
|
+
# Extra flags (uncomment what the deployment shape needs):
|
|
81
|
+
# --base-path /w2m # only when the tunnel/proxy forwards the prefix
|
|
82
|
+
# --trust-proxy # only behind a proxy you control
|
|
83
|
+
# --pair-rate-limit 5 # default; raise only on a trusted network
|
|
84
|
+
# --operator-token-file %S/w2m/operator-token.txt # if the CLI supports it
|
|
85
|
+
#
|
|
86
|
+
# Multi-line form when the flag list grows (systemd joins lines ending in \):
|
|
87
|
+
# ExecStart=@RELAY_BIN@ \
|
|
88
|
+
# --host 127.0.0.1 \
|
|
89
|
+
# --port 8787 \
|
|
90
|
+
# --state %S/w2m \
|
|
91
|
+
# --base-path /w2m \
|
|
92
|
+
# --trust-proxy
|
|
93
|
+
|
|
94
|
+
# --- secrets ---------------------------------------------------------------
|
|
95
|
+
# Do NOT put the operator token in this file: the unit is world-readable and
|
|
96
|
+
# the token authorises SENDING work to every paired machine.
|
|
97
|
+
# Preferred: let the relay generate one and read /var/lib/w2m/operator-token.txt.
|
|
98
|
+
# If you must inject your own, keep it in an EnvironmentFile with mode 0600
|
|
99
|
+
# owned by root, and let systemd read it:
|
|
100
|
+
# EnvironmentFile=-/etc/w2m/relay.env # W2M_OPERATOR_TOKEN=...
|
|
101
|
+
# (the leading '-' means "start anyway if the file is missing")
|
|
102
|
+
#
|
|
103
|
+
# NOTE: the relay prints the operator token and the pairing code to stdout, so
|
|
104
|
+
# they end up in the journal. For a public deployment, restrict journal access
|
|
105
|
+
# (sudo usermod -aG systemd-journal <user>) or send them elsewhere.
|
|
106
|
+
|
|
107
|
+
# --- restart policy --------------------------------------------------------
|
|
108
|
+
Restart=on-failure
|
|
109
|
+
RestartSec=3
|
|
110
|
+
# Give up only after repeated fast failures rather than flapping forever.
|
|
111
|
+
StartLimitIntervalSec=60
|
|
112
|
+
StartLimitBurst=5
|
|
113
|
+
# The relay handles SIGTERM by closing the server and exiting 0; give it time
|
|
114
|
+
# to let clients see a clean disconnect.
|
|
115
|
+
TimeoutStopSec=15
|
|
116
|
+
KillSignal=SIGTERM
|
|
117
|
+
|
|
118
|
+
# --- hardening -------------------------------------------------------------
|
|
119
|
+
# The relay needs only: loopback sockets, its state dir, and enough of the
|
|
120
|
+
# filesystem to load its own JS. Everything else is denied.
|
|
121
|
+
NoNewPrivileges=yes
|
|
122
|
+
PrivateTmp=yes
|
|
123
|
+
ProtectSystem=strict
|
|
124
|
+
ProtectHome=yes
|
|
125
|
+
ProtectKernelTunables=yes
|
|
126
|
+
ProtectKernelModules=yes
|
|
127
|
+
ProtectControlGroups=yes
|
|
128
|
+
RestrictSUIDSGID=yes
|
|
129
|
+
RestrictNamespaces=yes
|
|
130
|
+
LockPersonality=yes
|
|
131
|
+
MemoryDenyWriteExecute=no
|
|
132
|
+
# Loopback + (for shape A) the tailnet interface. AF_UNIX is needed by the
|
|
133
|
+
# Node runtime for internal sockets on some builds; AF_INET/AF_INET6 carry the
|
|
134
|
+
# relay itself.
|
|
135
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
136
|
+
SystemCallFilter=@system-service
|
|
137
|
+
SystemCallErrorNumber=EPERM
|
|
138
|
+
# ProtectSystem=strict makes everything read-only except paths named here.
|
|
139
|
+
ReadWritePaths=/var/lib/w2m
|
|
140
|
+
|
|
141
|
+
# --- logs ------------------------------------------------------------------
|
|
142
|
+
StandardOutput=journal
|
|
143
|
+
StandardError=journal
|
|
144
|
+
SyslogIdentifier=w2m-rabbit
|
|
145
|
+
# Uncomment to make the startup banner (pairing code, operator token) land in a
|
|
146
|
+
# file an operator can read without journal access. Keep that file 0600.
|
|
147
|
+
# StandardOutput=append:/var/lib/w2m/relay.log
|
|
148
|
+
|
|
149
|
+
[Install]
|
|
150
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# w2m-stun.service -- systemd unit for the W2M STUN responder
|
|
3
|
+
# ============================================================================
|
|
4
|
+
#
|
|
5
|
+
# This file is a TEMPLATE. Replace the @PLACEHOLDERS@ before installing.
|
|
6
|
+
#
|
|
7
|
+
# Install (Linux server, systemd):
|
|
8
|
+
# sudo install -m 0644 deploy/systemd/w2m-stun.service \
|
|
9
|
+
# /etc/systemd/system/w2m-stun.service
|
|
10
|
+
# sudo systemctl daemon-reload
|
|
11
|
+
# sudo systemctl enable --now w2m-stun
|
|
12
|
+
# journalctl -u w2m-stun -f
|
|
13
|
+
#
|
|
14
|
+
# Anything that has to reach it needs UDP :3478 open -- `ufw allow 3478/udp`.
|
|
15
|
+
# There is nothing to open for TCP: STUN is UDP only in this implementation.
|
|
16
|
+
#
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# WHAT THIS UNIT DELIBERATELY DOES
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# * Binds 0.0.0.0. A STUN server that only listened on loopback would discover
|
|
21
|
+
# nothing: the whole answer it gives is "the address and port the NAT gave
|
|
22
|
+
# you", which is only meaningful when the query arrives from outside.
|
|
23
|
+
# * Holds no state and writes nothing. It is the one W2M service that can be
|
|
24
|
+
# restarted at any time without a single client noticing anything except a
|
|
25
|
+
# lost query, so it gets no StateDirectory and no writable path at all.
|
|
26
|
+
# * Runs as the same unprivileged `w2m` user as the relay, because 3478 is
|
|
27
|
+
# above 1024 and no capability is needed.
|
|
28
|
+
# * Restarts on failure but not in a tight loop (RestartSec + StartLimit*).
|
|
29
|
+
#
|
|
30
|
+
# NOT verified: `systemd-analyze verify` was not run (systemd is unavailable on
|
|
31
|
+
# the authoring machine). The unit that shipped is the one in this repository's
|
|
32
|
+
# own deployment script, and docs/DEPLOY.md names the command to check it.
|
|
33
|
+
# ============================================================================
|
|
34
|
+
|
|
35
|
+
[Unit]
|
|
36
|
+
Description=W2M STUN responder - reflexive address discovery for P2P hole punching
|
|
37
|
+
Documentation=https://github.com/TwinsEarth/dsh-windows2macos/blob/main/docs/DEPLOY.md
|
|
38
|
+
After=network-online.target
|
|
39
|
+
Wants=network-online.target
|
|
40
|
+
|
|
41
|
+
[Service]
|
|
42
|
+
Type=simple
|
|
43
|
+
User=w2m
|
|
44
|
+
Group=w2m
|
|
45
|
+
|
|
46
|
+
# --- the listener ----------------------------------------------------------
|
|
47
|
+
# 3478 is the IANA-registered STUN port. Machines are pointed at it by
|
|
48
|
+
# `stunServers`, whose default already lists the shared server first.
|
|
49
|
+
ExecStart=/usr/local/bin/node /opt/w2m/app/bin/w2m-stun.mjs --host 0.0.0.0 --port 3478 --log-level info
|
|
50
|
+
|
|
51
|
+
Restart=always
|
|
52
|
+
RestartSec=2
|
|
53
|
+
StartLimitIntervalSec=60
|
|
54
|
+
StartLimitBurst=5
|
|
55
|
+
|
|
56
|
+
# --- hardening -------------------------------------------------------------
|
|
57
|
+
# No filesystem access is needed at all: no state, no log file (journal only).
|
|
58
|
+
NoNewPrivileges=true
|
|
59
|
+
PrivateTmp=true
|
|
60
|
+
ProtectSystem=strict
|
|
61
|
+
ProtectHome=true
|
|
62
|
+
ReadWritePaths=
|
|
63
|
+
ProtectKernelTunables=true
|
|
64
|
+
ProtectControlGroups=true
|
|
65
|
+
RestrictAddressFamilies=AF_INET AF_INET6
|
|
66
|
+
RestrictNamespaces=true
|
|
67
|
+
|
|
68
|
+
[Install]
|
|
69
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Windows: running the agent as a service
|
|
2
|
+
|
|
3
|
+
The agent must run without a console, start with the machine, and survive sign-out.
|
|
4
|
+
Three shapes do that on Windows, and they differ in one thing that matters: **whether
|
|
5
|
+
something third-party ends up hosting the process.**
|
|
6
|
+
|
|
7
|
+
| | Service-mode task (recommended) | Real SCM service (WinSW / NSSM) | Interactive task |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Starts at boot | yes (S4U, session 0) | yes | no — at sign-in |
|
|
10
|
+
| Survives sign-out | yes | yes | no |
|
|
11
|
+
| Shows up in `services.msc`, `Restart-Service` | no (`Start-ScheduledTask`) | yes | no |
|
|
12
|
+
| Third-party binary required | **no** | **yes** — the service host | no |
|
|
13
|
+
| Needs elevation to install | yes (S4U ⇒ `SeBatchLogonRight`) | yes | **no** |
|
|
14
|
+
| Console-close death (`0xC000013A`) | avoided (session 0 has no console) | avoided | **hit** — measured twice |
|
|
15
|
+
|
|
16
|
+
## 1. Service-mode task — `install-service.ps1`
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
# elevated PowerShell, from the repository root
|
|
20
|
+
powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 `
|
|
21
|
+
-Project 'E:\path\to\your\project' `
|
|
22
|
+
-MachineName win-desktop `
|
|
23
|
+
-AllowedCommands 'node --test, git status --porcelain' `
|
|
24
|
+
-AgentArgs '--p2p-mode auto --p2p-port 41235'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It registers **one** task that runs `w2m-agent-supervisor.mjs`, which owns the agent:
|
|
28
|
+
|
|
29
|
+
* `-AtStartup` **and** `-AtLogOn`, `LogonType S4U` → runs whether the user is signed in or not,
|
|
30
|
+
in session 0, with no console for anything to close;
|
|
31
|
+
* `MultipleInstances IgnoreNew` → two agents must never share one device identity;
|
|
32
|
+
* `RestartCount 999` / 1 min, `ExecutionTimeLimit 0`, `-StartWhenAvailable`.
|
|
33
|
+
|
|
34
|
+
Everything except the task's own command line goes into **`%USERPROFILE%\.dsh\w2m\agent.json`**,
|
|
35
|
+
which the installer writes and the supervisor reads; edit that file to change the allow-list, the
|
|
36
|
+
project, or the agent's flags, then `Restart-ScheduledTask`-equivalent
|
|
37
|
+
(`Stop-ScheduledTask` + `Start-ScheduledTask`). `-AllowedCommands` is **comma-separated plain
|
|
38
|
+
text** — not JSON, and not because JSON is wrong but because three separate layers eat its quotes
|
|
39
|
+
(see below).
|
|
40
|
+
|
|
41
|
+
Inspect and remove:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
|
|
45
|
+
Get-Content "$env:USERPROFILE\.dsh\w2m\agent.json"
|
|
46
|
+
powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### The three things that were measured, not assumed
|
|
50
|
+
|
|
51
|
+
1. **Without elevation, S4U registration is refused** with `Access is denied` (S4U needs
|
|
52
|
+
`SeBatchLogonRight`; an `-AtStartup` trigger needs elevation too). The script says so instead of
|
|
53
|
+
half-installing. Pass `-Interactive` for the no-elevation shape — it works, but the process lives
|
|
54
|
+
in the sign-in session, so signing out stops it.
|
|
55
|
+
2. **A JSON argument does not survive the trip.** Registered through a task,
|
|
56
|
+
`--allowed-commands "[\"node --version\"]"` came back stored as `"[node --version]"`: the escapes
|
|
57
|
+
gone, the agent reading `[node`, exiting 2 with `ALLOWED_COMMANDS_INVALID`. This project has now
|
|
58
|
+
hit that in `cmd`, PowerShell 5.1 and Task Scheduler — hence the config file, and hence
|
|
59
|
+
`-AllowedCommands` taking plain text.
|
|
60
|
+
3. **PowerShell 5.1 writes a UTF-8 BOM** with `Set-Content -Encoding utf8`, and `JSON.parse`
|
|
61
|
+
refuses it: the supervisor exits 78 *before it opens its log*, so the failure leaves no trace
|
|
62
|
+
anywhere. The installer writes without a BOM, and the supervisor strips one if it is there
|
|
63
|
+
(Notepad writes them too, and this file is meant to be hand-edited).
|
|
64
|
+
|
|
65
|
+
## 2. Real SCM service — WinSW
|
|
66
|
+
|
|
67
|
+
Only if you need the entry in `services.msc` (monitoring, `Restart-Service`, a
|
|
68
|
+
configuration-management system that knows about services). This puts a third-party
|
|
69
|
+
binary on the machine as the service host, which is a decision the project will not make
|
|
70
|
+
for you:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
# 1. get the wrapper (verify the release hash yourself; this is the one third-party piece)
|
|
74
|
+
# https://github.com/winsw/winsw/releases
|
|
75
|
+
# 2. put w2m-localside.exe and w2m-localside.xml (below) in C:\ProgramData\w2m\
|
|
76
|
+
# 3. install it -- elevated
|
|
77
|
+
C:\ProgramData\w2m\w2m-localside.exe install
|
|
78
|
+
C:\ProgramData\w2m\w2m-localside.exe start
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`deploy/windows/winsw/w2m-localside.xml` is the template; edit the two paths in it. The
|
|
82
|
+
supervisor still wraps the agent, so a crash loop is visible in the log rather than
|
|
83
|
+
turning into a service that flaps.
|
|
84
|
+
|
|
85
|
+
## 3. The one thing that is not a deployment detail
|
|
86
|
+
|
|
87
|
+
Whichever shape you pick, the machine must be **paired** and the punch port must match its
|
|
88
|
+
firewall rule:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
# the identity this agent uses (create it by pairing once, with a code from the relay)
|
|
92
|
+
Get-Content "$env:USERPROFILE\.dsh\xclient\device.json"
|
|
93
|
+
# if you pinned --p2p-port, open exactly that port once -- elevated
|
|
94
|
+
New-NetFirewallRule -DisplayName 'W2M punch (node UDP)' -Direction Inbound -Protocol UDP `
|
|
95
|
+
-Program 'C:\Program Files\nodejs\node.exe' -LocalPort 41235 -Action Allow
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A pinned port is worth it for a service: an ephemeral one changes on every restart, and a
|
|
99
|
+
stale rule looks exactly like "the punch just does not work" — every task quietly falls
|
|
100
|
+
back to the relay with `offer_path: "relay"` in the ledger.
|
|
101
|
+
|
|
102
|
+
## Verify it, rather than trusting it
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
# the task is running, and in which session
|
|
106
|
+
Get-ScheduledTask -TaskName 'W2M Localside agent' | Select-Object State
|
|
107
|
+
Get-Process node | Where-Object { $_.SessionId -eq 0 } | Select-Object Id, SessionId, StartTime
|
|
108
|
+
|
|
109
|
+
# the agent's own narration: "stream ready" is the line that means the fleet can reach it
|
|
110
|
+
Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 20
|
|
111
|
+
|
|
112
|
+
# and one real dispatch from any machine with the plugin installed
|
|
113
|
+
# w2m_devices -> this machine must be listed as online
|
|
114
|
+
# w2m_run ["git","rev-parse","HEAD"] -> w2m_wait must show transport: p2p (or relay, with a reason)
|
|
115
|
+
```
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# install-service.ps1 -- run the W2M Localside agent as a Windows service
|
|
3
|
+
# ============================================================================
|
|
4
|
+
#
|
|
5
|
+
# WHAT "SERVICE MODE" MEANS HERE, AND WHAT IT DOES NOT
|
|
6
|
+
#
|
|
7
|
+
# A Node process cannot be an SCM service by itself: the Service Control Manager
|
|
8
|
+
# requires the process to answer StartServiceCtrlDispatcher, which needs native
|
|
9
|
+
# bindings this project does not have (and will not add -- zero runtime
|
|
10
|
+
# dependencies is a stated property, not an accident). So there are two honest
|
|
11
|
+
# shapes, and this script implements the first:
|
|
12
|
+
#
|
|
13
|
+
# 1. SERVICE-MODE TASK (this script) -- a scheduled task with LogonType S4U:
|
|
14
|
+
# starts at boot, runs in session 0 with no console, survives sign-out, and
|
|
15
|
+
# is managed with Start-ScheduledTask/Stop-ScheduledTask. No third-party
|
|
16
|
+
# binary. Requires **elevation** (S4U needs SeBatchLogonRight, and the
|
|
17
|
+
# registration is refused with "Access is denied" without it -- measured).
|
|
18
|
+
#
|
|
19
|
+
# 2. REAL SCM SERVICE via WinSW or NSSM -- a genuine entry in services.msc and
|
|
20
|
+
# for `Restart-Service`. It needs a third-party service host binary, which
|
|
21
|
+
# is a dependency decision rather than a deployment detail; see README.md
|
|
22
|
+
# in this directory for the template and the caveat.
|
|
23
|
+
#
|
|
24
|
+
# WHY THE AGENT RUNS UNDER A SUPERVISOR
|
|
25
|
+
#
|
|
26
|
+
# Measured twice: an agent started directly can be gone ~40 s later with
|
|
27
|
+
# 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away) and no log line,
|
|
28
|
+
# because whatever was logging died with it. The task therefore runs
|
|
29
|
+
# w2m-agent-supervisor.mjs, which respawns the agent with a capped backoff and
|
|
30
|
+
# logs one line per run.
|
|
31
|
+
#
|
|
32
|
+
# USAGE
|
|
33
|
+
#
|
|
34
|
+
# # elevated PowerShell, from the repository root:
|
|
35
|
+
# powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 `
|
|
36
|
+
# -Project 'E:\path\to\your\project' `
|
|
37
|
+
# -AllowedCommands '["node --test","git status --porcelain"]'
|
|
38
|
+
#
|
|
39
|
+
# # inspect / remove
|
|
40
|
+
# Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
|
|
41
|
+
# powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
|
|
42
|
+
#
|
|
43
|
+
# Everything after the parameters goes to the agent, so `-AgentArgs
|
|
44
|
+
# '--p2p-port','41235'` pins the punch socket (and then one inbound firewall rule
|
|
45
|
+
# is enough, instead of a rule per restart).
|
|
46
|
+
# ============================================================================
|
|
47
|
+
[CmdletBinding()]
|
|
48
|
+
param(
|
|
49
|
+
[string]$TaskName = 'W2M Localside agent',
|
|
50
|
+
[string]$Rabbit = 'http://202.182.123.154:8787',
|
|
51
|
+
[string]$Project = (Get-Location).Path,
|
|
52
|
+
[string]$MachineName = $env:COMPUTERNAME,
|
|
53
|
+
[string]$AllowedCommands = 'node --version, node --test, git status --porcelain, git rev-parse',
|
|
54
|
+
[string]$StateDir = "$env:USERPROFILE\.dsh\w2m\localside",
|
|
55
|
+
[string]$LogPath = "$env:USERPROFILE\.dsh\w2m\agent.log",
|
|
56
|
+
[string]$Bin = '',
|
|
57
|
+
[string]$AgentArgs = '',
|
|
58
|
+
[int]$RestartBackoffMs = 60000,
|
|
59
|
+
[switch]$Interactive,
|
|
60
|
+
[switch]$Uninstall,
|
|
61
|
+
[switch]$WhatIfOnly
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
$ErrorActionPreference = 'Stop'
|
|
65
|
+
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ...\deploy\windows -> repo root
|
|
66
|
+
$supervisor = Join-Path $PSScriptRoot 'w2m-agent-supervisor.mjs'
|
|
67
|
+
$node = (Get-Command node -ErrorAction SilentlyContinue).Source
|
|
68
|
+
if (-not $node) { throw 'node is not on PATH; install Node 20.19+ first' }
|
|
69
|
+
if (-not (Test-Path $supervisor)) { throw "supervisor missing: $supervisor" }
|
|
70
|
+
|
|
71
|
+
if ($Uninstall) {
|
|
72
|
+
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
|
|
73
|
+
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
74
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
75
|
+
Write-Host "removed scheduled task: $TaskName"
|
|
76
|
+
} else {
|
|
77
|
+
Write-Host "nothing to remove: $TaskName does not exist"
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (-not $Bin) {
|
|
83
|
+
$Bin = Join-Path $env:USERPROFILE '.dsh\profiles\desktop\node_modules\@twinsearth\w2m-dsh-plugin\bin\w2m-localside.mjs'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# THE ALLOW-LIST GOES INTO A FILE, NOT ONTO A COMMAND LINE.
|
|
87
|
+
#
|
|
88
|
+
# Measured three times on this project: `cmd`, PowerShell 5.1 and finally Task Scheduler each
|
|
89
|
+
# mangled a JSON argument. Registered through a task, `--allowed-commands "[\"node --version\"]"`
|
|
90
|
+
# came back stored as `"[node --version]"` -- the escapes gone, the agent reading `[node` and
|
|
91
|
+
# exiting 2 with ALLOWED_COMMANDS_INVALID. A config file has no quoting rules, and it is one more
|
|
92
|
+
# thing this installer can write for whoever has to debug the service later.
|
|
93
|
+
$configPath = Join-Path (Split-Path -Parent $StateDir) 'agent.json'
|
|
94
|
+
$agentArgs = @($AgentArgs -split '[,\s]+' | Where-Object { $_ -ne '' })
|
|
95
|
+
# Comma/semicolon/newline separated, NOT JSON. Measured: `powershell -File` strips the quotes out of
|
|
96
|
+
# a JSON argument (`["node --version"]` arrives as `[node --version]`), which is one mangling more
|
|
97
|
+
# than this installer needs to survive. Nothing in an allow-list entry contains a comma.
|
|
98
|
+
$allowList = @($AllowedCommands -split '[,;\r\n]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
99
|
+
if ($allowList.Count -eq 0) { throw '-AllowedCommands must name at least one command prefix' }
|
|
100
|
+
$config = [ordered]@{
|
|
101
|
+
bin = $Bin
|
|
102
|
+
rabbit = $Rabbit
|
|
103
|
+
project = $Project
|
|
104
|
+
name = $MachineName
|
|
105
|
+
state = $StateDir
|
|
106
|
+
log = $LogPath
|
|
107
|
+
allowedCommands = $allowList
|
|
108
|
+
agentArgs = $agentArgs
|
|
109
|
+
backoffMs = $RestartBackoffMs
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
$arguments = @("`"$supervisor`"", '--config', "`"$configPath`"")
|
|
113
|
+
|
|
114
|
+
Write-Host 'service-mode task plan'
|
|
115
|
+
Write-Host " task : $TaskName"
|
|
116
|
+
Write-Host " runs as : $env:USERDOMAIN\$env:USERNAME ($(if ($Interactive) { 'interactive: signed in only' } else { 'S4U: whether signed in or not, session 0' }))"
|
|
117
|
+
Write-Host " command : `"$node`" $($arguments -join ' ')"
|
|
118
|
+
Write-Host " config : $configPath"
|
|
119
|
+
Write-Host " allow-list : $($config.allowedCommands -join ' | ')"
|
|
120
|
+
Write-Host " log : $LogPath"
|
|
121
|
+
if ($WhatIfOnly) {
|
|
122
|
+
Write-Host 'config that would be written:'
|
|
123
|
+
$config | ConvertTo-Json -Depth 4
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $configPath) | Out-Null
|
|
127
|
+
# Write it WITHOUT a BOM. `Set-Content -Encoding utf8` on PowerShell 5.1 prepends one, `JSON.parse`
|
|
128
|
+
# refuses it, and the supervisor then exits 78 before it has opened its log -- a failure with no
|
|
129
|
+
# trace anywhere. (The supervisor also strips a BOM now; both ends, because the file is meant to be
|
|
130
|
+
# hand-editable and Notepad writes BOMs too.)
|
|
131
|
+
$json = $config | ConvertTo-Json -Depth 4
|
|
132
|
+
[System.IO.File]::WriteAllText($configPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
133
|
+
Write-Host "wrote $configPath"
|
|
134
|
+
|
|
135
|
+
$action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
|
|
136
|
+
# AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
|
|
137
|
+
# signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
|
|
138
|
+
# measured, and the reason this list is conditional rather than always two entries.
|
|
139
|
+
$triggers = if ($Interactive) {
|
|
140
|
+
@(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
|
|
141
|
+
} else {
|
|
142
|
+
@(
|
|
143
|
+
(New-ScheduledTaskTrigger -AtStartup),
|
|
144
|
+
(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
148
|
+
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
|
149
|
+
-MultipleInstances IgnoreNew `
|
|
150
|
+
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
|
151
|
+
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
|
152
|
+
-StartWhenAvailable -Hidden
|
|
153
|
+
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
|
154
|
+
-LogonType $(if ($Interactive) { 'Interactive' } else { 'S4U' }) -RunLevel Limited
|
|
155
|
+
|
|
156
|
+
if ($Interactive) {
|
|
157
|
+
# The no-elevation shape. It works and it is what this project shipped first, but the process
|
|
158
|
+
# lives in the sign-in session: closing that session's console is what produced 0xC000013A, which
|
|
159
|
+
# is the death the supervisor now at least records.
|
|
160
|
+
Write-Warning 'Interactive mode: the agent runs only while this user is signed in, and a console close can kill it (the supervisor will restart it when the session returns).'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Principal $principal -Force | Out-Null
|
|
165
|
+
} catch {
|
|
166
|
+
Write-Error @"
|
|
167
|
+
could not register the task: $($_.Exception.Message)
|
|
168
|
+
|
|
169
|
+
For the default (session-0, S4U) shape that refusal is the expected one when this
|
|
170
|
+
script is not elevated: S4U needs SeBatchLogonRight, and an AtStartup trigger needs
|
|
171
|
+
elevation too -- the registration is denied with "Access is denied". Re-run the same
|
|
172
|
+
command from an **elevated** PowerShell.
|
|
173
|
+
|
|
174
|
+
If you cannot elevate, ask for the interactive shape instead (one trigger, AtLogOn):
|
|
175
|
+
|
|
176
|
+
powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Interactive `
|
|
177
|
+
-Project '$Project' -AllowedCommands '$AllowedCommands'
|
|
178
|
+
|
|
179
|
+
It works, and it is what this project shipped first -- but the process lives in the
|
|
180
|
+
sign-in session, so signing out stops it and a console close can kill it.
|
|
181
|
+
"@
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Start-ScheduledTask -TaskName $TaskName
|
|
186
|
+
Start-Sleep -Seconds 10
|
|
187
|
+
$task = Get-ScheduledTask -TaskName $TaskName
|
|
188
|
+
$info = $task | Get-ScheduledTaskInfo
|
|
189
|
+
Write-Host "state=$($task.State) lastRun=$($info.LastRunTime) lastResult=$($info.LastTaskResult)"
|
|
190
|
+
Write-Host "session of the agent process (0 = service session):"
|
|
191
|
+
Get-Process node -ErrorAction SilentlyContinue |
|
|
192
|
+
Where-Object { $_.StartTime -gt (Get-Date).AddMinutes(-2) } |
|
|
193
|
+
Select-Object Id, SessionId, StartTime | Format-Table -AutoSize
|
|
194
|
+
Write-Host "log tail:"
|
|
195
|
+
Get-Content $LogPath -Tail 5 -ErrorAction SilentlyContinue
|