@clawops/cli 0.2.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/LICENSE +373 -0
- package/README.md +335 -0
- package/dist/apply-GTVALBAY.js +12 -0
- package/dist/automation-JG2YBEL7.js +30 -0
- package/dist/aws-A3323GNM.js +285 -0
- package/dist/azure-LNWBK2ZI.js +295 -0
- package/dist/bootstrap-NRIDVS5F.js +91 -0
- package/dist/chunk-5XEZAU7V.js +254 -0
- package/dist/chunk-ALSUDYA7.js +89 -0
- package/dist/chunk-ENQY5OW2.js +113 -0
- package/dist/chunk-LT24GUUO.js +39 -0
- package/dist/chunk-PERSDQMT.js +114 -0
- package/dist/chunk-PRYLTCS4.js +37 -0
- package/dist/chunk-RZE35FQF.js +56 -0
- package/dist/chunk-YTH4L2GN.js +41 -0
- package/dist/chunk-ZSE4QRKE.js +44 -0
- package/dist/cli.js +1752 -0
- package/dist/context-T52JWL3P.js +10 -0
- package/dist/firewall-YYDOWDDP.js +36 -0
- package/dist/gcp-OHWCTFLL.js +198 -0
- package/dist/generate-Y6O465VB.js +14 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +0 -0
- package/dist/local-DXBEVZ5C.js +47 -0
- package/dist/outputs-6DAVEEAZ.js +8 -0
- package/dist/package-YZFYSSQM.js +109 -0
- package/dist/pool-D4JCUA2V.js +10 -0
- package/dist/providers-2OABPW2E.js +24 -0
- package/dist/server-TKDURITQ.js +1349 -0
- package/dist/store-ARJ2EO6L.js +16 -0
- package/dist/validate-T5M5EHSJ.js +9 -0
- package/package.json +77 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/providers/azure/index.ts
|
|
4
|
+
import process2 from "process";
|
|
5
|
+
|
|
6
|
+
// src/providers/azure/program.ts
|
|
7
|
+
var GATEWAY_PORT = 18789;
|
|
8
|
+
var SSH_PORT = 22;
|
|
9
|
+
var azureProgram = async () => {
|
|
10
|
+
const [pulumi, azure, { resolveIngressCidrs, detectEgressIp }] = await Promise.all([
|
|
11
|
+
import("@pulumi/pulumi"),
|
|
12
|
+
import("@pulumi/azure-native"),
|
|
13
|
+
import("./firewall-YYDOWDDP.js")
|
|
14
|
+
]);
|
|
15
|
+
const cfg = new pulumi.Config();
|
|
16
|
+
const stackName = pulumi.getStack();
|
|
17
|
+
const instanceType = cfg.get("instanceType") ?? "Standard_B2s";
|
|
18
|
+
const location = cfg.get("region") ?? "eastus";
|
|
19
|
+
const openclawVersion = cfg.get("openclawVersion") ?? "stable";
|
|
20
|
+
const accessMode = cfg.get("accessMode") ?? "restricted";
|
|
21
|
+
const allowedCidrs = cfg.get("allowedCidrs") ?? "";
|
|
22
|
+
const sshCidrs = cfg.get("sshCidrs") ?? "";
|
|
23
|
+
const gatewayCidrs = cfg.get("gatewayCidrs") ?? "";
|
|
24
|
+
const keyVaultEnabled = cfg.get("keyVaultEnabled") === "true";
|
|
25
|
+
const resourceGroupName = cfg.get("resourceGroupName") ?? `clawops-${stackName}`;
|
|
26
|
+
const sshPublicKey = cfg.get("sshPublicKey");
|
|
27
|
+
if (!sshPublicKey) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Stack config "sshPublicKey" is required for the Azure adapter. Set it with: pulumi config set --stack <name> sshPublicKey "ssh-ed25519 ..."'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const detectedIp = accessMode === "auto" ? await detectEgressIp("https://ifconfig.me") : "";
|
|
33
|
+
if (accessMode === "open") {
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
"[clawops] WARNING: accessMode=open allows 0.0.0.0/0 on SSH and gateway ports. Only use this for development/sandbox stacks.\n"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const sshIngressCidrs = resolveIngressCidrs(accessMode, allowedCidrs, sshCidrs, detectedIp);
|
|
39
|
+
const gatewayIngressCidrs = resolveIngressCidrs(accessMode, allowedCidrs, gatewayCidrs, detectedIp);
|
|
40
|
+
const rg = new azure.resources.ResourceGroup("clawops-rg", {
|
|
41
|
+
resourceGroupName,
|
|
42
|
+
location
|
|
43
|
+
});
|
|
44
|
+
const vnet = new azure.network.VirtualNetwork("clawops-vnet", {
|
|
45
|
+
resourceGroupName: rg.name,
|
|
46
|
+
location: rg.location,
|
|
47
|
+
addressSpace: { addressPrefixes: ["10.0.0.0/16"] }
|
|
48
|
+
});
|
|
49
|
+
const subnet = new azure.network.Subnet("clawops-subnet", {
|
|
50
|
+
resourceGroupName: rg.name,
|
|
51
|
+
virtualNetworkName: vnet.name,
|
|
52
|
+
addressPrefix: "10.0.1.0/24"
|
|
53
|
+
});
|
|
54
|
+
const securityRules = [];
|
|
55
|
+
let priority = 100;
|
|
56
|
+
for (const cidr of sshIngressCidrs) {
|
|
57
|
+
securityRules.push({
|
|
58
|
+
name: `allow-ssh-${priority}`,
|
|
59
|
+
priority: priority++,
|
|
60
|
+
direction: "Inbound",
|
|
61
|
+
access: "Allow",
|
|
62
|
+
protocol: "Tcp",
|
|
63
|
+
sourceAddressPrefix: cidr,
|
|
64
|
+
sourcePortRange: "*",
|
|
65
|
+
destinationAddressPrefix: "*",
|
|
66
|
+
destinationPortRange: String(SSH_PORT)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const cidr of gatewayIngressCidrs) {
|
|
70
|
+
securityRules.push({
|
|
71
|
+
name: `allow-gateway-${priority}`,
|
|
72
|
+
priority: priority++,
|
|
73
|
+
direction: "Inbound",
|
|
74
|
+
access: "Allow",
|
|
75
|
+
protocol: "Tcp",
|
|
76
|
+
sourceAddressPrefix: cidr,
|
|
77
|
+
sourcePortRange: "*",
|
|
78
|
+
destinationAddressPrefix: "*",
|
|
79
|
+
destinationPortRange: String(GATEWAY_PORT)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const nsg = new azure.network.NetworkSecurityGroup("clawops-nsg", {
|
|
83
|
+
resourceGroupName: rg.name,
|
|
84
|
+
location: rg.location,
|
|
85
|
+
securityRules
|
|
86
|
+
});
|
|
87
|
+
const publicIp = new azure.network.PublicIPAddress("clawops-pip", {
|
|
88
|
+
resourceGroupName: rg.name,
|
|
89
|
+
location: rg.location,
|
|
90
|
+
publicIPAllocationMethod: "Static",
|
|
91
|
+
sku: { name: "Standard" }
|
|
92
|
+
});
|
|
93
|
+
const nic = new azure.network.NetworkInterface("clawops-nic", {
|
|
94
|
+
resourceGroupName: rg.name,
|
|
95
|
+
location: rg.location,
|
|
96
|
+
networkSecurityGroup: { id: nsg.id },
|
|
97
|
+
ipConfigurations: [{
|
|
98
|
+
name: "clawops-ipconfig",
|
|
99
|
+
subnet: { id: subnet.id },
|
|
100
|
+
publicIPAddress: { id: publicIp.id },
|
|
101
|
+
privateIPAllocationMethod: "Dynamic"
|
|
102
|
+
}]
|
|
103
|
+
});
|
|
104
|
+
const vm = new azure.compute.VirtualMachine("clawops-vm", {
|
|
105
|
+
resourceGroupName: rg.name,
|
|
106
|
+
location: rg.location,
|
|
107
|
+
hardwareProfile: { vmSize: instanceType },
|
|
108
|
+
identity: { type: "SystemAssigned" },
|
|
109
|
+
osProfile: {
|
|
110
|
+
adminUsername: "clawops",
|
|
111
|
+
computerName: "clawops",
|
|
112
|
+
customData: Buffer.from(makeStartupScript(openclawVersion)).toString("base64"),
|
|
113
|
+
linuxConfiguration: {
|
|
114
|
+
disablePasswordAuthentication: true,
|
|
115
|
+
ssh: {
|
|
116
|
+
publicKeys: [{
|
|
117
|
+
keyData: sshPublicKey,
|
|
118
|
+
path: "/home/clawops/.ssh/authorized_keys"
|
|
119
|
+
}]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
storageProfile: {
|
|
124
|
+
imageReference: {
|
|
125
|
+
publisher: "Canonical",
|
|
126
|
+
offer: "UbuntuServer",
|
|
127
|
+
sku: "22.04-LTS",
|
|
128
|
+
version: "latest"
|
|
129
|
+
},
|
|
130
|
+
osDisk: {
|
|
131
|
+
createOption: "FromImage",
|
|
132
|
+
managedDisk: { storageAccountType: "Premium_LRS" },
|
|
133
|
+
diskSizeGB: 30
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
networkProfile: {
|
|
137
|
+
networkInterfaces: [{ id: nic.id, primary: true }]
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
if (keyVaultEnabled) {
|
|
141
|
+
const rawKvName = `clawops-${stackName}-kv`;
|
|
142
|
+
const kvName = rawKvName.length > 24 ? rawKvName.slice(0, 24) : rawKvName;
|
|
143
|
+
const kv = new azure.keyvault.Vault("clawops-kv", {
|
|
144
|
+
resourceGroupName: rg.name,
|
|
145
|
+
location: rg.location,
|
|
146
|
+
vaultName: kvName,
|
|
147
|
+
properties: {
|
|
148
|
+
sku: { family: "A", name: "standard" },
|
|
149
|
+
tenantId: pulumi.output(vm.identity).apply((i) => i?.tenantId ?? ""),
|
|
150
|
+
enableRbacAuthorization: true
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
new azure.authorization.RoleAssignment("clawops-kv-role", {
|
|
154
|
+
scope: kv.id,
|
|
155
|
+
roleDefinitionId: "/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
|
|
156
|
+
// Key Vault Secrets User
|
|
157
|
+
principalId: pulumi.output(vm.identity).apply((i) => i?.principalId ?? ""),
|
|
158
|
+
principalType: "ServicePrincipal"
|
|
159
|
+
});
|
|
160
|
+
new azure.keyvault.Secret("clawops-gateway-token", {
|
|
161
|
+
resourceGroupName: rg.name,
|
|
162
|
+
vaultName: kv.name,
|
|
163
|
+
secretName: "gateway-token",
|
|
164
|
+
properties: { value: "CHANGEME" }
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const resolvedIp = pulumi.output(publicIp.ipAddress).apply((ip) => ip ?? "");
|
|
168
|
+
return {
|
|
169
|
+
instanceId: vm.id,
|
|
170
|
+
publicIp: resolvedIp,
|
|
171
|
+
gatewayUrl: pulumi.interpolate`https://${resolvedIp}:${GATEWAY_PORT}`,
|
|
172
|
+
sshHost: resolvedIp,
|
|
173
|
+
sshPort: SSH_PORT,
|
|
174
|
+
sshUser: "clawops",
|
|
175
|
+
region: location,
|
|
176
|
+
provisionedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
function makeStartupScript(openclawVersion) {
|
|
180
|
+
return `#!/bin/bash
|
|
181
|
+
set -euo pipefail
|
|
182
|
+
|
|
183
|
+
# Install Docker if not present
|
|
184
|
+
if ! command -v docker &>/dev/null; then
|
|
185
|
+
apt-get update -q
|
|
186
|
+
apt-get install -y -q ca-certificates curl gnupg lsb-release
|
|
187
|
+
install -m 0755 -d /etc/apt/keyrings
|
|
188
|
+
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \\
|
|
189
|
+
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
|
190
|
+
chmod a+r /etc/apt/keyrings/docker.gpg
|
|
191
|
+
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \\
|
|
192
|
+
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \\
|
|
193
|
+
> /etc/apt/sources.list.d/docker.list
|
|
194
|
+
apt-get update -q
|
|
195
|
+
apt-get install -y -q docker-ce docker-ce-cli containerd.io
|
|
196
|
+
systemctl enable --now docker
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
usermod -aG docker clawops
|
|
200
|
+
|
|
201
|
+
# Pull OpenClaw image
|
|
202
|
+
OPENCLAW_VERSION="${openclawVersion}"
|
|
203
|
+
docker pull ghcr.io/openclaw/openclaw:\${OPENCLAW_VERSION}
|
|
204
|
+
|
|
205
|
+
# Create default openclaw.json if not present
|
|
206
|
+
OPENCLAW_CONFIG=/home/clawops/openclaw.json
|
|
207
|
+
if [ ! -f "\${OPENCLAW_CONFIG}" ]; then
|
|
208
|
+
cat > "\${OPENCLAW_CONFIG}" <<'OPENCLAWJSON'
|
|
209
|
+
{"version":"2026.4","gateway":{"port":18789,"auth":{"mode":"token"}},"models":{},"channels":[]}
|
|
210
|
+
OPENCLAWJSON
|
|
211
|
+
chown clawops:clawops "\${OPENCLAW_CONFIG}"
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
# Start OpenClaw container
|
|
215
|
+
docker stop openclaw 2>/dev/null || true
|
|
216
|
+
docker rm openclaw 2>/dev/null || true
|
|
217
|
+
docker run -d \\
|
|
218
|
+
--name openclaw \\
|
|
219
|
+
--restart unless-stopped \\
|
|
220
|
+
-p 18789:18789 \\
|
|
221
|
+
-v "\${OPENCLAW_CONFIG}":/app/config.json:ro \\
|
|
222
|
+
ghcr.io/openclaw/openclaw:\${OPENCLAW_VERSION}
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/providers/azure/index.ts
|
|
227
|
+
var INSTANCE_TYPE_MAP = {
|
|
228
|
+
micro: "Standard_B1s",
|
|
229
|
+
small: "Standard_B2s",
|
|
230
|
+
medium: "Standard_B4ms",
|
|
231
|
+
large: "Standard_B8ms",
|
|
232
|
+
gpu: "Standard_NC6s_v3"
|
|
233
|
+
};
|
|
234
|
+
var azureAdapter = {
|
|
235
|
+
name: "azure",
|
|
236
|
+
get program() {
|
|
237
|
+
return azureProgram;
|
|
238
|
+
},
|
|
239
|
+
getConnectionInfo(outputs) {
|
|
240
|
+
return {
|
|
241
|
+
host: String(outputs["sshHost"] ?? ""),
|
|
242
|
+
port: Number(outputs["sshPort"] ?? 22),
|
|
243
|
+
user: String(outputs["sshUser"] ?? "clawops"),
|
|
244
|
+
privateKeyPath: String(outputs["privateKeyPath"] ?? ""),
|
|
245
|
+
knownHostsPath: String(outputs["knownHostsPath"] ?? "")
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
normalizeInstanceType(alias) {
|
|
249
|
+
const mapped = INSTANCE_TYPE_MAP[alias];
|
|
250
|
+
if (!mapped) throw new Error(`Unknown instance alias: ${alias}`);
|
|
251
|
+
return mapped;
|
|
252
|
+
},
|
|
253
|
+
defaultRegion() {
|
|
254
|
+
return "eastus";
|
|
255
|
+
},
|
|
256
|
+
stateBackendUrl(bucket) {
|
|
257
|
+
return `azblob://${bucket}`;
|
|
258
|
+
},
|
|
259
|
+
async validateConfig() {
|
|
260
|
+
const errors = [];
|
|
261
|
+
const clientId = process2.env["AZURE_CLIENT_ID"];
|
|
262
|
+
const tenantId = process2.env["AZURE_TENANT_ID"];
|
|
263
|
+
const clientSecret = process2.env["AZURE_CLIENT_SECRET"];
|
|
264
|
+
const federatedToken = process2.env["AZURE_FEDERATED_TOKEN_FILE"];
|
|
265
|
+
const hasServicePrincipal = Boolean(clientId && tenantId && clientSecret);
|
|
266
|
+
const hasOidc = Boolean(clientId && tenantId && federatedToken);
|
|
267
|
+
if (!hasServicePrincipal && !hasOidc) {
|
|
268
|
+
const onAzure = await checkImds();
|
|
269
|
+
if (!onAzure) {
|
|
270
|
+
errors.push(
|
|
271
|
+
"No Azure credentials found. Set AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_CLIENT_SECRET (service principal), or AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_FEDERATED_TOKEN_FILE (OIDC), or run on an Azure VM with a managed identity."
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { ok: errors.length === 0, errors };
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
async function checkImds() {
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch(
|
|
281
|
+
"http://169.254.169.254/metadata/instance?api-version=2021-02-01",
|
|
282
|
+
{
|
|
283
|
+
headers: { Metadata: "true" },
|
|
284
|
+
signal: AbortSignal.timeout(1e3)
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
return res.ok;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
var azure_default = azureAdapter;
|
|
293
|
+
export {
|
|
294
|
+
azure_default as default
|
|
295
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
acquireSession
|
|
4
|
+
} from "./chunk-5XEZAU7V.js";
|
|
5
|
+
import {
|
|
6
|
+
writeLocalState
|
|
7
|
+
} from "./chunk-PRYLTCS4.js";
|
|
8
|
+
import "./chunk-ALSUDYA7.js";
|
|
9
|
+
import {
|
|
10
|
+
ProviderError
|
|
11
|
+
} from "./chunk-ZSE4QRKE.js";
|
|
12
|
+
|
|
13
|
+
// src/providers/local/bootstrap.ts
|
|
14
|
+
import { readFileSync } from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
var GATEWAY_PORT = 18789;
|
|
18
|
+
var HEALTH_POLL_INTERVAL_MS = 3e3;
|
|
19
|
+
var HEALTH_TIMEOUT_MS = 12e4;
|
|
20
|
+
function loadTemplate() {
|
|
21
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
return readFileSync(path.join(dir, "bootstrap.sh.tmpl"), "utf-8");
|
|
23
|
+
}
|
|
24
|
+
function renderScript(openclawVersion) {
|
|
25
|
+
return loadTemplate().replace(/\{\{OPENCLAW_VERSION\}\}/g, openclawVersion);
|
|
26
|
+
}
|
|
27
|
+
async function localBootstrap(opts) {
|
|
28
|
+
const script = renderScript(opts.openclawVersion);
|
|
29
|
+
const b64 = Buffer.from(script, "utf-8").toString("base64");
|
|
30
|
+
const command = `echo '${b64}' | base64 -d | sudo bash`;
|
|
31
|
+
const { session, release } = await acquireSession({
|
|
32
|
+
host: opts.host,
|
|
33
|
+
port: opts.port,
|
|
34
|
+
user: opts.user,
|
|
35
|
+
privateKeyPath: opts.privateKeyPath,
|
|
36
|
+
knownHostsPath: opts.knownHostsPath,
|
|
37
|
+
signal: opts.signal
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
const result = await session.exec(command, opts.signal);
|
|
41
|
+
if (result.code !== 0) {
|
|
42
|
+
throw new ProviderError(
|
|
43
|
+
`Bootstrap script failed (exit ${result.code}):
|
|
44
|
+
${result.stderr || result.stdout}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
release();
|
|
49
|
+
}
|
|
50
|
+
const state = {
|
|
51
|
+
instanceId: `local:${opts.host}`,
|
|
52
|
+
publicIp: opts.host,
|
|
53
|
+
gatewayUrl: `http://${opts.host}:${GATEWAY_PORT}`,
|
|
54
|
+
sshHost: opts.host,
|
|
55
|
+
sshPort: opts.port,
|
|
56
|
+
sshUser: opts.user,
|
|
57
|
+
region: "local",
|
|
58
|
+
provisionedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
59
|
+
privateKeyPath: opts.privateKeyPath,
|
|
60
|
+
knownHostsPath: opts.knownHostsPath
|
|
61
|
+
};
|
|
62
|
+
writeLocalState(opts.stackName, state);
|
|
63
|
+
if (!opts.noWait) {
|
|
64
|
+
await waitForGateway(opts.host, GATEWAY_PORT, opts.signal);
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
async function waitForGateway(host, port, signal) {
|
|
69
|
+
const url = `http://${host}:${port}/health`;
|
|
70
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (signal?.aborted) {
|
|
73
|
+
throw new ProviderError("Bootstrap aborted while waiting for gateway");
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url, { signal });
|
|
77
|
+
if (res.ok) return;
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
await sleep(HEALTH_POLL_INTERVAL_MS);
|
|
81
|
+
}
|
|
82
|
+
throw new ProviderError(
|
|
83
|
+
`Gateway at ${url} did not become healthy within ${HEALTH_TIMEOUT_MS / 1e3}s`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
function sleep(ms) {
|
|
87
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
}
|
|
89
|
+
export {
|
|
90
|
+
localBootstrap
|
|
91
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
NetworkError
|
|
4
|
+
} from "./chunk-ZSE4QRKE.js";
|
|
5
|
+
|
|
6
|
+
// src/transport/ssh.ts
|
|
7
|
+
import { readFileSync, appendFileSync, mkdirSync } from "fs";
|
|
8
|
+
import { createServer } from "net";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { Client } from "ssh2";
|
|
11
|
+
var Ssh2Session = class {
|
|
12
|
+
constructor(client) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
}
|
|
15
|
+
client;
|
|
16
|
+
exec(command, signal) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
if (signal?.aborted) {
|
|
19
|
+
reject(new NetworkError("Operation aborted"));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.client.exec(command, (err, channel) => {
|
|
23
|
+
if (err) {
|
|
24
|
+
reject(new NetworkError(`SSH exec failed: ${err.message}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
channel.destroy();
|
|
31
|
+
reject(new NetworkError("Operation aborted"));
|
|
32
|
+
};
|
|
33
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
34
|
+
channel.on("data", (data) => {
|
|
35
|
+
stdout += data.toString("utf-8");
|
|
36
|
+
});
|
|
37
|
+
channel.stderr.on("data", (data) => {
|
|
38
|
+
stderr += data.toString("utf-8");
|
|
39
|
+
});
|
|
40
|
+
channel.on("close", (code) => {
|
|
41
|
+
signal?.removeEventListener("abort", onAbort);
|
|
42
|
+
resolve({ stdout, stderr, code: code ?? 0 });
|
|
43
|
+
});
|
|
44
|
+
channel.on("error", (chanErr) => {
|
|
45
|
+
signal?.removeEventListener("abort", onAbort);
|
|
46
|
+
reject(new NetworkError(`SSH channel error: ${chanErr.message}`));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
stream(command, signal) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
if (signal?.aborted) {
|
|
54
|
+
reject(new NetworkError("Operation aborted"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.client.exec(command, (err, channel) => {
|
|
58
|
+
if (err) {
|
|
59
|
+
reject(new NetworkError(`SSH exec failed: ${err.message}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (signal) {
|
|
63
|
+
signal.addEventListener("abort", () => channel.destroy(), { once: true });
|
|
64
|
+
}
|
|
65
|
+
resolve(channel);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
tunnel(localPort, remoteHost, remotePort, signal) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
reject(new NetworkError("Operation aborted"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
76
|
+
const server = createServer((socket) => {
|
|
77
|
+
sockets.add(socket);
|
|
78
|
+
socket.on("close", () => sockets.delete(socket));
|
|
79
|
+
this.client.forwardOut("127.0.0.1", localPort, remoteHost, remotePort, (err, channel) => {
|
|
80
|
+
if (err) {
|
|
81
|
+
socket.destroy();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
socket.pipe(channel);
|
|
85
|
+
channel.pipe(socket);
|
|
86
|
+
channel.on("close", () => socket.destroy());
|
|
87
|
+
socket.on("close", () => channel.destroy());
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const closeAll = () => {
|
|
91
|
+
for (const s of sockets) s.destroy();
|
|
92
|
+
server.close();
|
|
93
|
+
};
|
|
94
|
+
server.on("error", (err) => {
|
|
95
|
+
const msg = err.code === "EADDRINUSE" ? `Port ${localPort} is already in use` : `Tunnel server error: ${err.message}`;
|
|
96
|
+
reject(new NetworkError(msg));
|
|
97
|
+
});
|
|
98
|
+
server.listen(localPort, "127.0.0.1", () => {
|
|
99
|
+
const handle = { localPort, close: closeAll };
|
|
100
|
+
signal?.addEventListener("abort", closeAll, { once: true });
|
|
101
|
+
resolve(handle);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
close() {
|
|
106
|
+
this.client.end();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
async function connect(opts) {
|
|
110
|
+
let privateKey;
|
|
111
|
+
try {
|
|
112
|
+
privateKey = readFileSync(opts.privateKeyPath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new NetworkError(
|
|
115
|
+
`Cannot read SSH private key at ${opts.privateKeyPath}: ${err.message}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const client = new Client();
|
|
120
|
+
const onAbort = () => {
|
|
121
|
+
client.destroy();
|
|
122
|
+
reject(new NetworkError("Connection aborted"));
|
|
123
|
+
};
|
|
124
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
125
|
+
client.on("ready", () => {
|
|
126
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
127
|
+
resolve(new Ssh2Session(client));
|
|
128
|
+
});
|
|
129
|
+
client.on("error", (err) => {
|
|
130
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
131
|
+
reject(new NetworkError(`SSH connection failed: ${err.message}`));
|
|
132
|
+
});
|
|
133
|
+
const config = {
|
|
134
|
+
host: opts.host,
|
|
135
|
+
port: opts.port,
|
|
136
|
+
username: opts.user,
|
|
137
|
+
privateKey,
|
|
138
|
+
readyTimeout: 3e4,
|
|
139
|
+
hostVerifier: (keyHash) => verifyHostKey(opts.host, opts.port, keyHash, opts.knownHostsPath)
|
|
140
|
+
};
|
|
141
|
+
client.connect(config);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function verifyHostKey(host, port, keyHash, knownHostsPath) {
|
|
145
|
+
const keyHex = keyHash.toString("hex");
|
|
146
|
+
const hostEntry = port === 22 ? host : `[${host}]:${port}`;
|
|
147
|
+
let existing = null;
|
|
148
|
+
try {
|
|
149
|
+
const content = readFileSync(knownHostsPath, "utf-8");
|
|
150
|
+
for (const line of content.split("\n")) {
|
|
151
|
+
const parts = line.trim().split(/\s+/);
|
|
152
|
+
if (parts[0] === hostEntry && parts.length >= 2) {
|
|
153
|
+
existing = parts[1] ?? null;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
if (existing !== null) {
|
|
160
|
+
return existing === keyHex;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(path.dirname(knownHostsPath), { recursive: true });
|
|
164
|
+
appendFileSync(knownHostsPath, `${hostEntry} ${keyHex}
|
|
165
|
+
`, "utf-8");
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/transport/pool.ts
|
|
172
|
+
var IDLE_TTL_MS = 5 * 60 * 1e3;
|
|
173
|
+
var MAX_PER_HOST = 4;
|
|
174
|
+
var CLEANUP_INTERVAL_MS = 3e4;
|
|
175
|
+
var pool = /* @__PURE__ */ new Map();
|
|
176
|
+
var cleanupTimer = null;
|
|
177
|
+
function poolKey(opts) {
|
|
178
|
+
return `${opts.user}@${opts.host}:${opts.port}`;
|
|
179
|
+
}
|
|
180
|
+
function startCleanupTimer() {
|
|
181
|
+
if (cleanupTimer) return;
|
|
182
|
+
cleanupTimer = setInterval(() => {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
for (const [key, entries] of pool) {
|
|
185
|
+
const active = entries.filter((e) => {
|
|
186
|
+
if (!e.inUse && now - e.lastUsed > IDLE_TTL_MS) {
|
|
187
|
+
e.session.close();
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
if (active.length === 0) {
|
|
193
|
+
pool.delete(key);
|
|
194
|
+
} else {
|
|
195
|
+
pool.set(key, active);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (pool.size === 0 && cleanupTimer) {
|
|
199
|
+
clearInterval(cleanupTimer);
|
|
200
|
+
cleanupTimer = null;
|
|
201
|
+
}
|
|
202
|
+
}, CLEANUP_INTERVAL_MS);
|
|
203
|
+
cleanupTimer.unref();
|
|
204
|
+
}
|
|
205
|
+
async function acquireSession(opts) {
|
|
206
|
+
const key = poolKey(opts);
|
|
207
|
+
const entries = pool.get(key) ?? [];
|
|
208
|
+
const idle = entries.find((e) => !e.inUse);
|
|
209
|
+
if (idle) {
|
|
210
|
+
idle.inUse = true;
|
|
211
|
+
idle.lastUsed = Date.now();
|
|
212
|
+
return {
|
|
213
|
+
session: idle.session,
|
|
214
|
+
release() {
|
|
215
|
+
idle.inUse = false;
|
|
216
|
+
idle.lastUsed = Date.now();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (entries.length >= MAX_PER_HOST) {
|
|
221
|
+
throw new NetworkError(
|
|
222
|
+
`SSH connection pool exhausted for ${key} (max ${MAX_PER_HOST} concurrent connections).`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const session = await connect(opts);
|
|
226
|
+
const entry = { session, hostKey: key, lastUsed: Date.now(), inUse: true };
|
|
227
|
+
entries.push(entry);
|
|
228
|
+
pool.set(key, entries);
|
|
229
|
+
startCleanupTimer();
|
|
230
|
+
return {
|
|
231
|
+
session,
|
|
232
|
+
release() {
|
|
233
|
+
entry.inUse = false;
|
|
234
|
+
entry.lastUsed = Date.now();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function drainPool() {
|
|
239
|
+
for (const entries of pool.values()) {
|
|
240
|
+
for (const e of entries) {
|
|
241
|
+
e.session.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
pool.clear();
|
|
245
|
+
if (cleanupTimer) {
|
|
246
|
+
clearInterval(cleanupTimer);
|
|
247
|
+
cleanupTimer = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export {
|
|
252
|
+
acquireSession,
|
|
253
|
+
drainPool
|
|
254
|
+
};
|