@botbuddy/cli 1.64.25 → 1.64.26
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 +4 -0
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-callback-gateway.mjs +11 -2
- package/src/stack-requester-origin.mjs +16 -2
- package/src/stack-tunnel.mjs +107 -24
- package/src/stack.mjs +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.64.26
|
|
4
|
+
|
|
5
|
+
- `bb stack run --remote` (and auto runs placed remotely) never binds a local port in the leased serving band 61000–64999 any more. SSH forwards whose remote number falls in that band are remapped to a port outside it, remapped and probe ports are picked from explicit out-of-band candidates instead of the kernel's ephemeral range, and the requester origin and native callback gateway are reserved outside the band too. A `--callback-port` inside the band is refused with `CALLBACK_PORT_RESERVED` before anything is probed or spawned. This stops a remote lease's tunnel from taking ports a co-located stack on the same machine was just given.
|
|
6
|
+
|
|
3
7
|
## 1.64.25
|
|
4
8
|
|
|
5
9
|
- `bb pw` staging-pool takes now durably queue a secret-free, server-attributed telemetry receipt before a browser receives the single-use session. The receipt never includes the state file, path, credential, or caller-selected tenant/session/ticket/PR; a successor session reconciles an undelivered receipt under its verified source-session attribution.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.26",
|
|
3
|
+
"source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
|
|
4
|
+
"source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.26",
|
|
3
|
+
"source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
|
|
4
|
+
"source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.64.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.64.26",
|
|
4
|
+
"source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
|
|
5
|
+
"source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
import {createServer, request} from 'node:http';
|
|
4
4
|
import {createHash,createHmac, timingSafeEqual} from 'node:crypto';
|
|
5
5
|
import {Transform} from 'node:stream';
|
|
6
|
+
import {TUNNEL_PORT_PICKS,admissibleTunnelPort,randomTunnelPort} from './stack-tunnel.mjs';
|
|
6
7
|
export const CALLBACK_CAP_HEADER='x-botbuddy-callback-capability';
|
|
7
8
|
const digest=value=>createHash('sha256').update(value).digest();
|
|
8
9
|
export const CALLBACK_MAX_STREAM_BYTES=32*1024*1024;
|
|
9
10
|
const sameBinding=(a,b)=>b&&typeof b==='object'&&Object.keys(a).length===Object.keys(b).length&&Object.keys(a).every(key=>a[key]===b[key]);
|
|
10
|
-
|
|
11
|
+
// One listen attempt; both one-shot listeners are removed whichever fires, so retries never pile up.
|
|
12
|
+
const listenOnce=(server,port)=>new Promise((resolve,reject)=>{const onError=error=>{server.off('listening',onListening);reject(error)};const onListening=()=>{server.off('error',onError);resolve()};server.once('error',onError);server.once('listening',onListening);server.listen(port,'127.0.0.1');});
|
|
13
|
+
export async function openCallbackGateway({callbackPort,binding,capability,lifetimeMs=30_000,now=Date.now,activate=true,readinessNonce=null,pickPort=randomTunnelPort}){
|
|
11
14
|
if(!Number.isInteger(callbackPort)||callbackPort<1024||callbackPort>65535||!binding||typeof binding!=='object'||!Object.isFrozen(binding)||typeof capability!=='string'||!/^[0-9a-f]{64}$/.test(capability)||lifetimeMs<1||lifetimeMs>30_000)throw Error('callback gateway admission refused');
|
|
12
15
|
if(readinessNonce!==null&&!/^[0-9a-f]{32}$/.test(readinessNonce))throw Error('callback readiness identity refused');
|
|
13
16
|
const bootstrapDeadline=now()+30_000,readinessPath=readinessNonce?'/.botbuddy/callback-ready/'+readinessNonce:null;
|
|
@@ -38,7 +41,13 @@ export async function openCallbackGateway({callbackPort,binding,capability,lifet
|
|
|
38
41
|
const denySocket=(_req,socket)=>socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');server.on('connect',denySocket);server.on('upgrade',denySocket);
|
|
39
42
|
let closePromise;const close=()=>{if(closePromise)return closePromise;closed=true;clearTimeout(timer);for(const controller of active)controller.abort();active.clear();server.closeAllConnections();closePromise=new Promise(resolve=>server.close(resolve));for(const listener of closeListeners)listener();return closePromise;};
|
|
40
43
|
const refresh=(current,validForMs=lifetimeMs)=>{let matches=false;try{matches=sameBinding(binding,current)}catch{}if(closed||!matches||!Number.isFinite(validForMs)||validForMs<=0||validForMs>lifetimeMs){close();throw Error('callback binding changed');}deadline=now()+validForMs;clearTimeout(timer);timer=setTimeout(close,validForMs);timer.unref();};
|
|
41
|
-
|
|
44
|
+
// BOT-2503: an explicit port outside the leased serving band, never listen(0) (its kernel range covers it).
|
|
45
|
+
let bound=false;
|
|
46
|
+
for(let tries=0;!bound&&tries<TUNNEL_PORT_PICKS;tries++){
|
|
47
|
+
const port=pickPort();if(!admissibleTunnelPort(port)||port===callbackPort)continue;
|
|
48
|
+
try{await listenOnce(server,port);bound=true}catch(error){if(error.code!=='EADDRINUSE'){close();throw error}}
|
|
49
|
+
}
|
|
50
|
+
if(!bound){close();throw Error('callback gateway could not bind a port outside the leased serving band');}
|
|
42
51
|
if(activate)refresh(binding);else{timer=setTimeout(close,30_000);timer.unref();}
|
|
43
52
|
return {port:server.address().port,refresh,close,onClose(listener){closeListeners.add(listener);if(closed)listener()},isLive:()=>!closed&&now()<deadline};
|
|
44
53
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// The CLI later binds it to exactly one API port on its owned SSH tunnel. This
|
|
3
3
|
// listener is not itself an SSH forward and never supplies forward attestation.
|
|
4
4
|
import {createServer,connect} from 'node:net';
|
|
5
|
+
import {TUNNEL_PORT_PICKS,admissibleTunnelPort,inServingBand,randomTunnelPort} from './stack-tunnel.mjs';
|
|
5
6
|
|
|
6
7
|
const fail=(code,message)=>{const error=Error(message);error.code=code;throw error};
|
|
7
8
|
const validPort=value=>Number.isInteger(value)&&value>=1024&&value<=65535;
|
|
@@ -16,8 +17,13 @@ function apiPort(value){
|
|
|
16
17
|
if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||!validPort(Number(url.port)))fail('REQUESTER_ORIGIN_FORWARD_REFUSED','API target must be the owned loopback HTTP endpoint');
|
|
17
18
|
return Number(url.port);
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
// BOT-2503: port 0 means "pick one": explicit candidates outside the leased serving band,
|
|
21
|
+
// never listen(0), whose kernel range covers it. An explicit serving-band port is refused
|
|
22
|
+
// with REQUESTER_ORIGIN_PORT_RESERVED before anything binds.
|
|
23
|
+
// avoidPorts: local numbers a caller's own service will bind later (the callback port), never picked.
|
|
24
|
+
export async function reserveRequesterOrigin({context,port=0,pickPort=randomTunnelPort,avoidPorts=[]}={}){
|
|
20
25
|
const snapshot=contextSnapshot(context);if(port!==0&&!validPort(port))fail('REQUESTER_ORIGIN_PORT_REFUSED','requester origin port refused');
|
|
26
|
+
if(port!==0&&inServingBand(port))fail('REQUESTER_ORIGIN_PORT_RESERVED','requester origin port is in the leased serving band');
|
|
21
27
|
const sockets=new Set(),lossListeners=new Set();let route,closed=false,lostCode,closePromise;
|
|
22
28
|
const live=()=>{try{return !closed&&route?.isLive()===true}catch{return false}};
|
|
23
29
|
const close=code=>{
|
|
@@ -41,7 +47,15 @@ export async function reserveRequesterOrigin({context,port=0}={}){
|
|
|
41
47
|
});
|
|
42
48
|
});
|
|
43
49
|
server.maxConnections=64;
|
|
44
|
-
|
|
50
|
+
const listenOn=candidate=>new Promise((resolve,reject)=>{const onError=error=>{server.off('listening',onListening);reject(error)};const onListening=()=>{server.off('error',onError);resolve()};server.once('error',onError);server.once('listening',onListening);server.listen({host:'127.0.0.1',port:candidate,exclusive:true})});
|
|
51
|
+
const avoid=new Set(avoidPorts.map(Number));
|
|
52
|
+
let bound=false;
|
|
53
|
+
for(let tries=0;!bound&&tries<(port!==0?1:TUNNEL_PORT_PICKS);tries++){
|
|
54
|
+
const candidate=port!==0?port:pickPort();
|
|
55
|
+
if(!admissibleTunnelPort(candidate)||!validPort(candidate)||(port===0&&avoid.has(candidate)))continue;
|
|
56
|
+
try{await listenOn(candidate);bound=true}catch(error){if(port!==0||error.code!=='EADDRINUSE'){await close();fail(error.code==='EADDRINUSE'?'REQUESTER_ORIGIN_PORT_BUSY':'REQUESTER_ORIGIN_BIND_FAILED','requester origin could not be reserved')}}
|
|
57
|
+
}
|
|
58
|
+
if(!bound){await close();fail('REQUESTER_ORIGIN_PORT_BUSY','requester origin could not be reserved outside the leased serving band')}
|
|
45
59
|
const boundPort=server.address().port;server.on('error',()=>{void close('REQUESTER_ORIGIN_BIND_FAILED')});
|
|
46
60
|
return{
|
|
47
61
|
origin:'http://127.0.0.1:'+boundPort,
|
package/src/stack-tunnel.mjs
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Contract:
|
|
13
13
|
// * every forward binds 127.0.0.1 on BOTH ends; nothing leaves loopback;
|
|
14
|
-
// * a free local port keeps the remote number, a busy one gets
|
|
15
|
-
//
|
|
14
|
+
// * a free local port keeps the remote number, a busy one gets a fresh port
|
|
15
|
+
// (the remap table is returned and lands on the receipt) — except that no
|
|
16
|
+
// tunnel listener, local or on the host, ever lands in the leased serving band
|
|
17
|
+
// (BOT-2503), so a serving-band remote number is always remapped;
|
|
16
18
|
// * `ExitOnForwardFailure=yes` makes a lost bind race a clean exit, retried on
|
|
17
19
|
// remapped ports; `BatchMode=yes` means ssh never prompts (no agent hang);
|
|
18
20
|
// * `ServerAlive*` turns a dead link (caller asleep, BOT-1980) into an exit the
|
|
@@ -24,6 +26,42 @@
|
|
|
24
26
|
import { spawn, spawnSync } from "node:child_process";
|
|
25
27
|
import { connect, createServer, isIP } from "node:net";
|
|
26
28
|
import { canonicalStackPorts, stackServicesForForwards } from "./stack-service-discovery.mjs";
|
|
29
|
+
import { LEASED_PORT_BASE, LEASED_PORT_SLOTS, LEASED_PORT_STRIDE } from "./stack-isolation.mjs";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* BOT-2503: the band the CLI's local block chooser hands to co-located leased stacks
|
|
33
|
+
* (stack-isolation.mjs). A block is reserved before its containers listen, so a listener this
|
|
34
|
+
* module creates inside it — a forward keeping its remote number, a kernel-picked ephemeral
|
|
35
|
+
* port, a probe port — could take a port the co-located `supabase start` then fails to bind.
|
|
36
|
+
* Nothing the tunnel binds, locally or on the host, may fall in [START, END).
|
|
37
|
+
*/
|
|
38
|
+
export const SERVING_BAND_START = LEASED_PORT_BASE;
|
|
39
|
+
export const SERVING_BAND_END = LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE;
|
|
40
|
+
export const inServingBand = (port) => Number(port) >= SERVING_BAND_START && Number(port) < SERVING_BAND_END;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where the tunnel picks ports itself (remapped forwards, the probe's host port and its held local
|
|
44
|
+
* port): explicit candidates the kernel never chooses for us, so nothing is filtered after a bind.
|
|
45
|
+
* Below the band, above Supply Guard's per-ticket blocks (56321 + n×10, n ≤ 399 → ≤ 60330); above
|
|
46
|
+
* it, the top of the port space. Other bands are listed in stack-isolation.mjs.
|
|
47
|
+
*/
|
|
48
|
+
export const TUNNEL_PORT_RANGES = Object.freeze([Object.freeze([60340, 60999]), Object.freeze([65000, 65535])]);
|
|
49
|
+
/** Candidate selection is bounded: at most this many picks per port the tunnel needs. */
|
|
50
|
+
export const TUNNEL_PORT_PICKS = 20;
|
|
51
|
+
|
|
52
|
+
/** A port the tunnel may bind: a valid integer TCP port outside the serving band. */
|
|
53
|
+
export const admissibleTunnelPort = (port) => Number.isInteger(port) && port > 0 && port <= 65535 && !inServingBand(port);
|
|
54
|
+
|
|
55
|
+
/** A uniformly random port from TUNNEL_PORT_RANGES. */
|
|
56
|
+
export function randomTunnelPort(random = Math.random) {
|
|
57
|
+
const total = TUNNEL_PORT_RANGES.reduce((sum, [lo, hi]) => sum + hi - lo + 1, 0);
|
|
58
|
+
let offset = Math.min(Math.floor(random() * total), total - 1);
|
|
59
|
+
for (const [lo, hi] of TUNNEL_PORT_RANGES) {
|
|
60
|
+
if (offset <= hi - lo) return lo + offset;
|
|
61
|
+
offset -= hi - lo + 1;
|
|
62
|
+
}
|
|
63
|
+
return TUNNEL_PORT_RANGES[0][0];
|
|
64
|
+
}
|
|
27
65
|
|
|
28
66
|
export const SSH_FORWARD_OPTIONS = Object.freeze([
|
|
29
67
|
"-N", "-T",
|
|
@@ -91,11 +129,23 @@ function validPort(value) {
|
|
|
91
129
|
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
|
|
92
130
|
}
|
|
93
131
|
|
|
132
|
+
/** BOT-2503: `-R` binds the callback number on the host and the caller's service holds it here,
|
|
133
|
+
* so a serving-band callback port is refused — never silently renumbered under its service. */
|
|
134
|
+
function assertCallbackPortAdmissible(callbackPort) {
|
|
135
|
+
if (validPort(callbackPort) != null && inServingBand(callbackPort)) {
|
|
136
|
+
throw tunnelError("CALLBACK_PORT_RESERVED",
|
|
137
|
+
`--callback-port ${callbackPort} is inside the leased serving band ${SERVING_BAND_START}-${SERVING_BAND_END - 1}, where co-located stacks bind. ` +
|
|
138
|
+
`Serve the callback on a port outside that band (for example ${TUNNEL_PORT_RANGES[0][0]}-${TUNNEL_PORT_RANGES[0][1]}) and pass that port.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
94
142
|
/** Every stack port the child may touch: api + db always, then `connection.ports[]`. */
|
|
95
143
|
export function remotePortsFromConnection(connection) { return canonicalStackPorts(connection); }
|
|
96
144
|
|
|
97
|
-
/** True when 127.0.0.1:<port> can be bound right now.
|
|
145
|
+
/** True when 127.0.0.1:<port> can be bound right now. A serving-band or invalid port is never
|
|
146
|
+
* free to the tunnel, and is not bound to find out (BOT-2503). */
|
|
98
147
|
export function isLocalPortFree(port) {
|
|
148
|
+
if (!admissibleTunnelPort(port)) return Promise.resolve(false);
|
|
99
149
|
return new Promise((resolve) => {
|
|
100
150
|
const server = createServer();
|
|
101
151
|
server.once("error", () => resolve(false));
|
|
@@ -103,31 +153,27 @@ export function isLocalPortFree(port) {
|
|
|
103
153
|
});
|
|
104
154
|
}
|
|
105
155
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
|
|
112
|
-
const { port } = server.address();
|
|
113
|
-
server.close(() => resolve(port));
|
|
114
|
-
});
|
|
115
|
-
});
|
|
156
|
+
/** One explicit out-of-band candidate, returned when 127.0.0.1 can bind it right now, else null.
|
|
157
|
+
* Never listen(0): the kernel's ephemeral range covers the serving band (BOT-2503). */
|
|
158
|
+
export async function pickFreeLocalPort({ random = Math.random, isFree = isLocalPortFree } = {}) {
|
|
159
|
+
const candidate = randomTunnelPort(random);
|
|
160
|
+
return await isFree(candidate) ? candidate : null;
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
/**
|
|
119
|
-
* Decide each forward's local port: the remote number when it is
|
|
120
|
-
* `avoid
|
|
164
|
+
* Decide each forward's local port: the remote number when it is outside the serving band, free
|
|
165
|
+
* and not in `avoid`, otherwise a fresh out-of-band port (at most TUNNEL_PORT_PICKS picks; an
|
|
166
|
+
* invalid, in-band, used or avoided pick is discarded). Returns the remap table.
|
|
121
167
|
*/
|
|
122
168
|
export async function planForwards(remotePorts, { isFree = isLocalPortFree, pickFree = pickFreeLocalPort, avoid = new Set() } = {}) {
|
|
123
169
|
const used = new Set();
|
|
124
170
|
const plan = [];
|
|
125
171
|
for (const { name, port } of remotePorts) {
|
|
126
172
|
let local = null;
|
|
127
|
-
if (!avoid.has(port) && !used.has(port) && await isFree(port)) local = port;
|
|
128
|
-
for (let tries = 0; local == null && tries <
|
|
173
|
+
if (admissibleTunnelPort(port) && !avoid.has(port) && !used.has(port) && await isFree(port)) local = port;
|
|
174
|
+
for (let tries = 0; local == null && tries < TUNNEL_PORT_PICKS; tries++) {
|
|
129
175
|
const candidate = await pickFree();
|
|
130
|
-
if (!used.has(candidate) && !avoid.has(candidate)) local = candidate;
|
|
176
|
+
if (admissibleTunnelPort(candidate) && !used.has(candidate) && !avoid.has(candidate)) local = candidate;
|
|
131
177
|
}
|
|
132
178
|
if (local == null) throw tunnelError("REMOTE_UNREACHABLE", `could not find a free local port to forward ${name} (${port})`);
|
|
133
179
|
used.add(local);
|
|
@@ -140,6 +186,16 @@ export async function planForwards(remotePorts, { isFree = isLocalPortFree, pick
|
|
|
140
186
|
export function buildSshForwardArgv({ sshTarget, forwards, callbackPort = null }) {
|
|
141
187
|
// `-v` only with a callback forward: OpenSSH then logs whether the host GRANTED this session's
|
|
142
188
|
// `-R` (see openSshForwards `confirmRemoteForward`).
|
|
189
|
+
// BOT-2503: the last line before ssh binds anything — a caller that skipped planForwards still
|
|
190
|
+
// cannot bind into the serving band. Remote destinations are the stack's own ports, unchanged.
|
|
191
|
+
if (callbackPort != null) assertCallbackPortAdmissible(callbackPort);
|
|
192
|
+
for (const f of forwards) {
|
|
193
|
+
if (!admissibleTunnelPort(f.local) || validPort(f.remote) == null) {
|
|
194
|
+
throw tunnelError("REMOTE_UNREACHABLE",
|
|
195
|
+
`refusing to forward ${f.name ?? "a stack port"} from local port ${f.local}: a tunnel never binds an invalid port or one in the ` +
|
|
196
|
+
`leased serving band ${SERVING_BAND_START}-${SERVING_BAND_END - 1}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
143
199
|
const argv = [...(callbackPort != null ? ["-v"] : []), ...SSH_FORWARD_OPTIONS];
|
|
144
200
|
for (const f of forwards) argv.push("-L", `127.0.0.1:${f.local}:127.0.0.1:${f.remote}`);
|
|
145
201
|
if (callbackPort != null) argv.push("-R", `${REVERSE_BIND}:${callbackPort}:127.0.0.1:${callbackPort}`);
|
|
@@ -336,9 +392,10 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
|
|
|
336
392
|
}
|
|
337
393
|
|
|
338
394
|
|
|
339
|
-
/** A random throwaway port
|
|
395
|
+
/** A random throwaway host port for the GatewayPorts probe, outside the serving band (BOT-2503:
|
|
396
|
+
* the serving host runs leased stacks in that band too). */
|
|
340
397
|
export function randomProbePort(random = Math.random) {
|
|
341
|
-
return
|
|
398
|
+
return randomTunnelPort(random);
|
|
342
399
|
}
|
|
343
400
|
|
|
344
401
|
const REMOTE_FORWARD_BUSY = /remote port forwarding failed|address already in use|cannot listen/i;
|
|
@@ -349,11 +406,27 @@ export const DEFAULT_PROBE_READY_MS = 10_000;
|
|
|
349
406
|
* a listener that closes every connection without a byte — so no other local service can bind it
|
|
350
407
|
* while a (possibly wildcard) probe listener on the host forwards there. Resolves { port, close }.
|
|
351
408
|
*/
|
|
352
|
-
export function holdSilentLocalPort() {
|
|
409
|
+
export async function holdSilentLocalPort({ pickPort = randomTunnelPort, listen = listenSilent } = {}) {
|
|
410
|
+
// BOT-2503: an explicit out-of-band port, never listen(0); a lost bind race picks again.
|
|
411
|
+
for (let tries = 0; tries < TUNNEL_PORT_PICKS; tries++) {
|
|
412
|
+
const port = pickPort();
|
|
413
|
+
if (!admissibleTunnelPort(port)) continue;
|
|
414
|
+
try {
|
|
415
|
+
return await listen(port);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw tunnelError("CALLBACK_NOT_LOOPBACK",
|
|
421
|
+
`the GatewayPorts probe could not hold a local port outside the serving band after ${TUNNEL_PORT_PICKS} picks, so no -R for ` +
|
|
422
|
+
"--callback-port was opened.");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function listenSilent(port) {
|
|
353
426
|
return new Promise((resolve, reject) => {
|
|
354
427
|
const server = createServer((socket) => socket.destroy());
|
|
355
428
|
server.once("error", reject);
|
|
356
|
-
server.listen({ host: "127.0.0.1", port
|
|
429
|
+
server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
|
|
357
430
|
resolve({ port: server.address().port, close: () => new Promise((done) => server.close(() => done())) });
|
|
358
431
|
});
|
|
359
432
|
});
|
|
@@ -380,8 +453,13 @@ export async function probeGatewayPorts({
|
|
|
380
453
|
const tried = new Set();
|
|
381
454
|
let busyTail = "";
|
|
382
455
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
383
|
-
|
|
384
|
-
|
|
456
|
+
// BOT-2503: an invalid or serving-band pick never reaches ssh; at most TUNNEL_PORT_PICKS picks.
|
|
457
|
+
let port = null;
|
|
458
|
+
for (let i = 0; port == null && i < TUNNEL_PORT_PICKS; i++) {
|
|
459
|
+
const pick = pickProbePort();
|
|
460
|
+
if (admissibleTunnelPort(pick) && !tried.has(pick)) port = pick;
|
|
461
|
+
}
|
|
462
|
+
if (port == null) { busyTail = `no admissible probe port outside the serving band in ${TUNNEL_PORT_PICKS} picks`; continue; }
|
|
385
463
|
tried.add(port);
|
|
386
464
|
// P must be free on the host BEFORE the probe, or a listener seen afterwards could be someone
|
|
387
465
|
// else's (a loopback one would pass a host that would expose ours). Taken → another port.
|
|
@@ -474,6 +552,11 @@ export async function establishSshTunnel({
|
|
|
474
552
|
runRemote = spawnSync, probe = probeGatewayPorts, probeReadyTimeoutMs = DEFAULT_PROBE_READY_MS,
|
|
475
553
|
}) {
|
|
476
554
|
const target = assertSshTarget(sshTarget, hostKey);
|
|
555
|
+
// BOT-2503: before any probe, availability check or spawn.
|
|
556
|
+
if (callbackPort != null) assertCallbackPortAdmissible(callbackPort);
|
|
557
|
+
// The native callback route (BOT-2775) passes the caller's callback port here instead: its
|
|
558
|
+
// service holds that number on this machine, so it is refused in the band the same way.
|
|
559
|
+
for (const port of reserveLocalPorts) assertCallbackPortAdmissible(port);
|
|
477
560
|
const remotePorts = remotePortsFromConnection(connection);
|
|
478
561
|
const bin = sshBin || env.BOTBUDDY_STACK_SSH_BIN || "ssh";
|
|
479
562
|
// BOT-2184: prove the host keeps -R listeners on loopback BEFORE the real callback forward
|
package/src/stack.mjs
CHANGED
|
@@ -271,7 +271,8 @@ ${bold("run OPTIONS")}
|
|
|
271
271
|
stack. run uploads the materialised stack as a digest-pinned bundle, waits
|
|
272
272
|
for active, forwards every stack port over one \`ssh -N -L\` to the host's
|
|
273
273
|
ssh_target (transport ssh-forward), and gives the child a connection file
|
|
274
|
-
on the LOCAL ports (same numbers when free
|
|
274
|
+
on the LOCAL ports (same numbers when free and outside the leased
|
|
275
|
+
serving band 61000-64999, else remapped; see "forwards").
|
|
275
276
|
--local-only Require a co-located host (execution "local"); no bundle is built.
|
|
276
277
|
Default (neither flag): execution "auto" — co-located if it admits now,
|
|
277
278
|
else a remote host with capacity; the bundle digest is sent up front.
|
|
@@ -1079,18 +1080,24 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
1079
1080
|
// same. A busy port is never retargeted: a new origin makes a new variant, which the
|
|
1080
1081
|
// control plane reprovisions through its canonical reap instead of re-adopting.
|
|
1081
1082
|
previousPort = nonce == null ? idleOriginPort(worktreeRoot, stableDir) : null;
|
|
1083
|
+
// BOT-2503: the caller's callback service binds --callback-port later; an origin never takes it.
|
|
1084
|
+
const originAvoid = opts.callbackPort != null ? [opts.callbackPort] : [];
|
|
1082
1085
|
if (previousPort != null) {
|
|
1083
1086
|
try {
|
|
1084
|
-
requesterOrigin = await reserveOrigin({ context: originContext, port: previousPort });
|
|
1087
|
+
requesterOrigin = await reserveOrigin({ context: originContext, port: previousPort, avoidPorts: originAvoid });
|
|
1085
1088
|
originContinuation = "previous";
|
|
1086
1089
|
} catch (error) {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
+
// BOT-2503: an origin left in the leased serving band (by a CLI before this guard) is
|
|
1091
|
+
// treated like a busy one: a new origin, and the idle stack is reprovisioned.
|
|
1092
|
+
if (error?.code !== "REQUESTER_ORIGIN_PORT_BUSY" && error?.code !== "REQUESTER_ORIGIN_PORT_RESERVED") throw error;
|
|
1093
|
+
originContinuation = error.code === "REQUESTER_ORIGIN_PORT_RESERVED" ? "previous_port_reserved" : "previous_port_busy";
|
|
1094
|
+
warn(error.code === "REQUESTER_ORIGIN_PORT_RESERVED"
|
|
1095
|
+
? `stack: the idle stack's requester origin 127.0.0.1:${previousPort} is in the leased serving band; reserving a new origin (the idle stack is reprovisioned, never retargeted)`
|
|
1096
|
+
: `stack: the idle stack's requester origin 127.0.0.1:${previousPort} is in use on this machine; reserving a new origin (the idle stack is reprovisioned, never retargeted)`);
|
|
1090
1097
|
}
|
|
1091
1098
|
}
|
|
1092
1099
|
if (!requesterOrigin) {
|
|
1093
|
-
requesterOrigin = await reserveOrigin({ context: originContext });
|
|
1100
|
+
requesterOrigin = await reserveOrigin({ context: originContext, avoidPorts: originAvoid });
|
|
1094
1101
|
originContinuation ??= "new";
|
|
1095
1102
|
}
|
|
1096
1103
|
// Validate the source config before anything is materialized: an explicit conflicting
|
|
@@ -5330,7 +5337,7 @@ export function runReceiptFields(result) {
|
|
|
5330
5337
|
...(result.callbackOutage ? { callback_outage: result.callbackOutage } : {}),
|
|
5331
5338
|
// BOT-2780: the reserved requester origin generated auth links name (never a credential).
|
|
5332
5339
|
...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
|
|
5333
|
-
// BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
|
|
5340
|
+
// BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy", "previous_port_reserved" (BOT-2503: in the serving band) or "new".
|
|
5334
5341
|
...(result.execution?.requesterOriginContinuation ? { requester_origin_continuation: result.execution.requesterOriginContinuation } : {}),
|
|
5335
5342
|
...(result.allocationRecovery ? { allocation_recovery: result.allocationRecovery } : {}),
|
|
5336
5343
|
...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
|