fireclaw 0.1.2 → 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 +21 -0
- package/README.md +117 -109
- package/bin/fireclaw +11 -3
- package/bin/vm-common.sh +244 -37
- package/bin/vm-ctl +182 -37
- package/bin/vm-provision +152 -18
- package/bin/vm-setup +237 -94
- package/package.json +5 -4
- package/scripts/provision-guest.sh +134 -25
package/bin/vm-common.sh
CHANGED
|
@@ -12,6 +12,7 @@ SUBNET_CIDR="${SUBNET_CIDR:-172.16.0.0/24}"
|
|
|
12
12
|
|
|
13
13
|
OPENCLAW_IMAGE_DEFAULT="${OPENCLAW_IMAGE_DEFAULT:-ghcr.io/openclaw/openclaw:latest}"
|
|
14
14
|
SSH_KEY_PATH="${SSH_KEY_PATH:-/home/ubuntu/.ssh/vmdemo_vm}"
|
|
15
|
+
SSH_KEY_PATH_DEFAULT="$SSH_KEY_PATH"
|
|
15
16
|
|
|
16
17
|
log() { printf '==> %s\n' "$*"; }
|
|
17
18
|
warn() { printf 'Warning: %s\n' "$*" >&2; }
|
|
@@ -28,6 +29,60 @@ validate_instance_id() {
|
|
|
28
29
|
[[ "$id" =~ ^[a-z0-9_-]+$ ]] || die "instance id must match [a-z0-9_-]+"
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
validate_host_port() {
|
|
33
|
+
local port="$1"
|
|
34
|
+
[[ "$port" =~ ^[0-9]+$ ]] || die "host port must be numeric"
|
|
35
|
+
port=$((10#$port))
|
|
36
|
+
(( port >= 1 && port <= 65535 )) || die "host port must be in range 1-65535"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
validate_base_port() {
|
|
40
|
+
[[ "$BASE_PORT" =~ ^[0-9]+$ ]] || die "BASE_PORT must be numeric"
|
|
41
|
+
local base=$((10#$BASE_PORT))
|
|
42
|
+
(( base >= 0 && base < 65535 )) || die "BASE_PORT must be in range 0-65534"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
validate_ipv4() {
|
|
46
|
+
local ip="$1"
|
|
47
|
+
local label="$2"
|
|
48
|
+
local a b c d extra octet n
|
|
49
|
+
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "invalid $label: $ip"
|
|
50
|
+
IFS='.' read -r a b c d extra <<< "$ip"
|
|
51
|
+
[[ -z "${extra:-}" && -n "${a:-}" && -n "${b:-}" && -n "${c:-}" && -n "${d:-}" ]] || die "invalid $label: $ip"
|
|
52
|
+
for octet in "$a" "$b" "$c" "$d"; do
|
|
53
|
+
[[ "$octet" =~ ^[0-9]+$ ]] || die "invalid $label: $ip"
|
|
54
|
+
n=$((10#$octet))
|
|
55
|
+
(( n >= 0 && n <= 255 )) || die "invalid $label octet: $ip"
|
|
56
|
+
done
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subnet_mask_bits() {
|
|
60
|
+
local mask="${SUBNET_CIDR#*/}"
|
|
61
|
+
[[ "$mask" =~ ^[0-9]+$ ]] || die "invalid SUBNET_CIDR: $SUBNET_CIDR"
|
|
62
|
+
mask=$((10#$mask))
|
|
63
|
+
(( mask == 24 )) || die "SUBNET_CIDR must use /24 for automatic IP allocation: $SUBNET_CIDR"
|
|
64
|
+
echo "$mask"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
subnet_prefix() {
|
|
68
|
+
local subnet_ip="${SUBNET_CIDR%/*}"
|
|
69
|
+
validate_ipv4 "$subnet_ip" "SUBNET_CIDR network"
|
|
70
|
+
local prefix="${subnet_ip%.*}"
|
|
71
|
+
local base_octet="${subnet_ip##*.}"
|
|
72
|
+
(( 10#$base_octet == 0 )) || die "SUBNET_CIDR network must end in .0 for /24: $SUBNET_CIDR"
|
|
73
|
+
echo "$prefix"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
bridge_gateway_ip() {
|
|
77
|
+
local gateway="${BRIDGE_ADDR%/*}"
|
|
78
|
+
local mask="${BRIDGE_ADDR#*/}"
|
|
79
|
+
[[ "$BRIDGE_ADDR" == */* && "$mask" =~ ^[0-9]+$ ]] || die "invalid BRIDGE_ADDR: $BRIDGE_ADDR"
|
|
80
|
+
mask=$((10#$mask))
|
|
81
|
+
(( mask == 24 )) || die "BRIDGE_ADDR must use /24: $BRIDGE_ADDR"
|
|
82
|
+
validate_ipv4 "$gateway" "BRIDGE_ADDR"
|
|
83
|
+
echo "$gateway"
|
|
84
|
+
}
|
|
85
|
+
|
|
31
86
|
instance_dir() { printf '%s/.vm-%s\n' "$STATE_ROOT" "$1"; }
|
|
32
87
|
instance_env() { printf '%s/.env\n' "$(instance_dir "$1")"; }
|
|
33
88
|
instance_token() { printf '%s/.token\n' "$(instance_dir "$1")"; }
|
|
@@ -36,97 +91,249 @@ vm_service() { printf 'firecracker-vmdemo-%s.service\n' "$1"; }
|
|
|
36
91
|
proxy_service() { printf 'vmdemo-proxy-%s.service\n' "$1"; }
|
|
37
92
|
guest_health_script() { printf '/usr/local/bin/openclaw-health-%s.sh\n' "$1"; }
|
|
38
93
|
|
|
94
|
+
# Pin the guest host key on first contact instead of discarding it; the pin
|
|
95
|
+
# lives and dies with the instance state dir.
|
|
96
|
+
ssh_known_hosts_file() {
|
|
97
|
+
local id="${1:-}"
|
|
98
|
+
if [[ -n "$id" && -d "$(instance_dir "$id")" ]]; then
|
|
99
|
+
printf '%s/known_hosts\n' "$(instance_dir "$id")"
|
|
100
|
+
else
|
|
101
|
+
printf '/dev/null\n'
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
require_model_provider_key() {
|
|
106
|
+
local model="$1"
|
|
107
|
+
case "$model" in
|
|
108
|
+
openai/*) [[ -n "${OPENAI_API_KEY:-}" ]] || die "model '$model' requires an OpenAI API key (--openai-api-key or OPENAI_API_KEY)" ;;
|
|
109
|
+
anthropic/*) [[ -n "${ANTHROPIC_API_KEY:-}" ]] || die "model '$model' requires an Anthropic API key (--anthropic-api-key or ANTHROPIC_API_KEY)" ;;
|
|
110
|
+
minimax/*) [[ -n "${MINIMAX_API_KEY:-}" ]] || die "model '$model' requires a MiniMax API key (--minimax-api-key or MINIMAX_API_KEY)" ;;
|
|
111
|
+
esac
|
|
112
|
+
}
|
|
113
|
+
|
|
39
114
|
load_instance_env() {
|
|
40
115
|
local id="$1"
|
|
41
116
|
validate_instance_id "$id"
|
|
42
|
-
local f
|
|
117
|
+
local f line key value k
|
|
43
118
|
f="$(instance_env "$id")"
|
|
44
119
|
[[ -f "$f" ]] || die "instance '$id' not found"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
120
|
+
[[ -r "$f" ]] || die "Cannot read instance state: $f (try: sudo fireclaw ...)"
|
|
121
|
+
for k in INSTANCE_ID HOST_PORT VM_IP VM_TAP VM_MAC GATEWAY_TOKEN MODEL SKILLS TELEGRAM_USERS OPENCLAW_IMAGE VM_VCPU VM_MEM_MIB DISK_SIZE API_SOCK SKIP_BROWSER_INSTALL ANTHROPIC_API_KEY OPENAI_API_KEY MINIMAX_API_KEY; do
|
|
122
|
+
printf -v "$k" '%s' ""
|
|
123
|
+
done
|
|
124
|
+
SSH_KEY_PATH="$SSH_KEY_PATH_DEFAULT"
|
|
125
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
126
|
+
[[ -n "$line" && "$line" != \#* ]] || continue
|
|
127
|
+
[[ "$line" == *=* ]] || die "invalid state entry in $f: $line"
|
|
128
|
+
key="${line%%=*}"
|
|
129
|
+
value="${line#*=}"
|
|
130
|
+
case "$key" in
|
|
131
|
+
FIRECLAW_STATE_FORMAT)
|
|
132
|
+
;;
|
|
133
|
+
INSTANCE_ID|HOST_PORT|VM_IP|VM_TAP|VM_MAC|GATEWAY_TOKEN|MODEL|SKILLS|TELEGRAM_USERS|OPENCLAW_IMAGE|VM_VCPU|VM_MEM_MIB|DISK_SIZE|API_SOCK|SSH_KEY_PATH|SKIP_BROWSER_INSTALL|ANTHROPIC_API_KEY|OPENAI_API_KEY|MINIMAX_API_KEY)
|
|
134
|
+
printf -v "$key" '%s' "$value"
|
|
135
|
+
;;
|
|
136
|
+
*)
|
|
137
|
+
die "unknown state key in $f: $key"
|
|
138
|
+
;;
|
|
139
|
+
esac
|
|
140
|
+
done < "$f"
|
|
48
141
|
}
|
|
49
142
|
|
|
50
|
-
|
|
51
|
-
local
|
|
143
|
+
_host_port_allocated() {
|
|
144
|
+
local candidate="$1"
|
|
145
|
+
local nullglob_was_set=0 found=1
|
|
146
|
+
shopt -q nullglob && nullglob_was_set=1
|
|
52
147
|
shopt -s nullglob
|
|
53
148
|
local f p
|
|
54
149
|
for f in "$STATE_ROOT"/.vm-*/.env; do
|
|
55
150
|
p="$(grep '^HOST_PORT=' "$f" | cut -d= -f2 || true)"
|
|
56
|
-
if [[
|
|
57
|
-
|
|
151
|
+
if [[ "$p" == "$candidate" ]]; then
|
|
152
|
+
found=0
|
|
153
|
+
break
|
|
58
154
|
fi
|
|
59
155
|
done
|
|
60
|
-
shopt -u nullglob
|
|
61
|
-
|
|
156
|
+
(( nullglob_was_set )) || shopt -u nullglob
|
|
157
|
+
return "$found"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_host_port_in_use() {
|
|
161
|
+
local candidate="$1"
|
|
162
|
+
if command -v ss >/dev/null 2>&1; then
|
|
163
|
+
ss -H -ltn "sport = :$candidate" 2>/dev/null | grep -q .
|
|
164
|
+
return $?
|
|
165
|
+
fi
|
|
166
|
+
if command -v lsof >/dev/null 2>&1; then
|
|
167
|
+
lsof -nP -iTCP:"$candidate" -sTCP:LISTEN >/dev/null 2>&1
|
|
168
|
+
return $?
|
|
169
|
+
fi
|
|
170
|
+
warn "neither ss nor lsof found; cannot check if port $candidate is already in use"
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ensure_host_port_available() {
|
|
175
|
+
local candidate="$1"
|
|
176
|
+
validate_host_port "$candidate"
|
|
177
|
+
(( 10#$candidate >= 1024 )) || die "host port must be >= 1024 (the proxy runs unprivileged): $candidate"
|
|
178
|
+
_host_port_allocated "$candidate" && die "Host port is already assigned to an existing instance: $candidate"
|
|
179
|
+
_host_port_in_use "$candidate" && die "Host port is already in use on this host: $candidate"
|
|
180
|
+
return 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
next_port() {
|
|
184
|
+
validate_base_port
|
|
185
|
+
local base candidate
|
|
186
|
+
base=$((10#$BASE_PORT))
|
|
187
|
+
for ((candidate=base + 1; candidate<=65535; candidate++)); do
|
|
188
|
+
if _host_port_allocated "$candidate"; then
|
|
189
|
+
continue
|
|
190
|
+
fi
|
|
191
|
+
if _host_port_in_use "$candidate"; then
|
|
192
|
+
continue
|
|
193
|
+
fi
|
|
194
|
+
echo "$candidate"
|
|
195
|
+
return 0
|
|
196
|
+
done
|
|
197
|
+
die "No available host port found above BASE_PORT=$BASE_PORT"
|
|
62
198
|
}
|
|
63
199
|
|
|
64
200
|
next_ip() {
|
|
65
|
-
local
|
|
201
|
+
local prefix gateway gateway_octet mask
|
|
202
|
+
prefix="$(subnet_prefix)"
|
|
203
|
+
gateway="$(bridge_gateway_ip)"
|
|
204
|
+
mask="$(subnet_mask_bits)"
|
|
205
|
+
[[ "$gateway" == "$prefix".* ]] || die "BRIDGE_ADDR gateway ($gateway) must be in SUBNET_CIDR ($SUBNET_CIDR)"
|
|
206
|
+
gateway_octet="${gateway##*.}"
|
|
207
|
+
gateway_octet=$((10#$gateway_octet))
|
|
208
|
+
|
|
209
|
+
local -a used=()
|
|
210
|
+
used["$gateway_octet"]=1
|
|
211
|
+
|
|
212
|
+
local nullglob_was_set=0
|
|
213
|
+
shopt -q nullglob && nullglob_was_set=1
|
|
66
214
|
shopt -s nullglob
|
|
67
215
|
local f ip oct
|
|
68
216
|
for f in "$STATE_ROOT"/.vm-*/.env; do
|
|
69
217
|
ip="$(grep '^VM_IP=' "$f" | cut -d= -f2 || true)"
|
|
218
|
+
[[ "$ip" == "$prefix".* ]] || continue
|
|
70
219
|
oct="${ip##*.}"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
220
|
+
[[ "$oct" =~ ^[0-9]+$ ]] || continue
|
|
221
|
+
oct=$((10#$oct))
|
|
222
|
+
(( oct >= 0 && oct <= 255 )) || continue
|
|
223
|
+
used["$oct"]=1
|
|
74
224
|
done
|
|
75
|
-
shopt -u nullglob
|
|
76
225
|
|
|
77
|
-
local
|
|
78
|
-
((
|
|
79
|
-
|
|
80
|
-
|
|
226
|
+
local candidate chosen=""
|
|
227
|
+
for ((candidate=2; candidate<=254; candidate++)); do
|
|
228
|
+
[[ -n "${used[$candidate]:-}" ]] && continue
|
|
229
|
+
chosen="$prefix.$candidate"
|
|
230
|
+
break
|
|
231
|
+
done
|
|
232
|
+
(( nullglob_was_set )) || shopt -u nullglob
|
|
81
233
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
234
|
+
if [[ -n "$chosen" ]]; then
|
|
235
|
+
echo "$chosen"
|
|
236
|
+
return 0
|
|
85
237
|
fi
|
|
86
|
-
ip addr add "$BRIDGE_ADDR" dev "$BRIDGE_NAME" 2>/dev/null || true
|
|
87
|
-
ip link set "$BRIDGE_NAME" up
|
|
88
|
-
|
|
89
|
-
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
|
90
238
|
|
|
91
|
-
|
|
92
|
-
|| iptables -t nat -A POSTROUTING -s "$SUBNET_CIDR" ! -o "$BRIDGE_NAME" -j MASQUERADE
|
|
239
|
+
die "IP pool exhausted for subnet $prefix.0/$mask"
|
|
93
240
|
}
|
|
94
241
|
|
|
95
242
|
wait_for_ssh() {
|
|
96
243
|
local ip="$1"
|
|
97
244
|
local key="${2:-$SSH_KEY_PATH}"
|
|
98
245
|
local retries="${3:-120}"
|
|
246
|
+
local instance_id="${4:-${INSTANCE_ID:-}}"
|
|
247
|
+
local vm_svc=""
|
|
99
248
|
|
|
100
249
|
if [[ ! -r "$key" ]]; then
|
|
101
250
|
if [[ $EUID -ne 0 ]]; then
|
|
102
|
-
|
|
251
|
+
warn "Cannot read SSH key: $key (try: sudo fireclaw ...)"
|
|
103
252
|
else
|
|
104
|
-
|
|
253
|
+
warn "SSH key not found: $key"
|
|
105
254
|
fi
|
|
255
|
+
return 1
|
|
106
256
|
fi
|
|
107
257
|
|
|
108
|
-
|
|
109
|
-
|
|
258
|
+
if [[ -n "$instance_id" ]]; then
|
|
259
|
+
vm_svc="$(vm_service "$instance_id")"
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
local known_hosts vm_state
|
|
263
|
+
known_hosts="$(ssh_known_hosts_file "$instance_id")"
|
|
110
264
|
|
|
265
|
+
# Probe with /dev/null so a guest that regenerates host keys mid-boot (or
|
|
266
|
+
# accepts connections before authorized_keys lands) cannot poison the pin;
|
|
267
|
+
# the pin is recorded/verified only after the first successful login.
|
|
111
268
|
local i
|
|
112
269
|
for ((i=1; i<=retries; i++)); do
|
|
113
270
|
if ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 "ubuntu@$ip" true >/dev/null 2>&1; then
|
|
271
|
+
if [[ "$known_hosts" != "/dev/null" ]] && \
|
|
272
|
+
! ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" -o ConnectTimeout=3 "ubuntu@$ip" true >/dev/null 2>&1; then
|
|
273
|
+
warn "Guest SSH host key does not match the pinned key in $known_hosts. If the change is expected (rebuilt VM), remove that file and retry."
|
|
274
|
+
return 1
|
|
275
|
+
fi
|
|
114
276
|
return 0
|
|
115
277
|
fi
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
278
|
+
if [[ -n "$vm_svc" ]]; then
|
|
279
|
+
vm_state="$(systemctl is-active "$vm_svc" 2>/dev/null || true)"
|
|
280
|
+
case "$vm_state" in
|
|
281
|
+
active|activating|reloading|deactivating) ;;
|
|
282
|
+
*)
|
|
283
|
+
warn "VM is not running ($(printf '\033[31m%s\033[0m' "${vm_state:-inactive}")). Start it with: sudo fireclaw start $instance_id"
|
|
284
|
+
return 1
|
|
285
|
+
;;
|
|
286
|
+
esac
|
|
119
287
|
fi
|
|
120
288
|
sleep 2
|
|
121
289
|
done
|
|
122
|
-
|
|
290
|
+
warn "VM is running but SSH did not become reachable at ubuntu@$ip after $((retries * 2))s"
|
|
291
|
+
return 1
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
ssh_reachable() {
|
|
295
|
+
local ip="$1"
|
|
296
|
+
local key="${2:-$SSH_KEY_PATH}"
|
|
297
|
+
local instance_id="${3:-${INSTANCE_ID:-}}"
|
|
298
|
+
[[ -r "$key" ]] || return 1
|
|
299
|
+
ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$instance_id")" -o ConnectTimeout=3 "ubuntu@$ip" true >/dev/null 2>&1
|
|
123
300
|
}
|
|
124
301
|
|
|
125
302
|
check_guest_health() {
|
|
126
303
|
local id="$1"
|
|
127
304
|
local ip="$2"
|
|
128
305
|
local key="${3:-$SSH_KEY_PATH}"
|
|
306
|
+
validate_instance_id "$id"
|
|
129
307
|
local script
|
|
130
308
|
script="$(guest_health_script "$id")"
|
|
131
|
-
ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile
|
|
309
|
+
ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$id")" -o ConnectTimeout=3 "ubuntu@$ip" "if [[ -x '$script' ]]; then sudo '$script'; else curl -fsS http://127.0.0.1:18789/health >/dev/null; fi" >/dev/null 2>&1
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
wait_for_instance_health() {
|
|
313
|
+
local id="$1"
|
|
314
|
+
local ip="$2"
|
|
315
|
+
local port="$3"
|
|
316
|
+
local key="${4:-$SSH_KEY_PATH}"
|
|
317
|
+
local retries="${5:-30}"
|
|
318
|
+
local host_ok="false"
|
|
319
|
+
local guest_ok="false"
|
|
320
|
+
|
|
321
|
+
validate_instance_id "$id"
|
|
322
|
+
validate_host_port "$port"
|
|
323
|
+
|
|
324
|
+
local i
|
|
325
|
+
for ((i=1; i<=retries; i++)); do
|
|
326
|
+
host_ok="false"
|
|
327
|
+
guest_ok="false"
|
|
328
|
+
curl -fsS "http://127.0.0.1:$port/health" >/dev/null 2>&1 && host_ok="true"
|
|
329
|
+
if check_guest_health "$id" "$ip" "$key"; then
|
|
330
|
+
guest_ok="true"
|
|
331
|
+
fi
|
|
332
|
+
if [[ "$host_ok" == "true" && "$guest_ok" == "true" ]]; then
|
|
333
|
+
return 0
|
|
334
|
+
fi
|
|
335
|
+
sleep 2
|
|
336
|
+
done
|
|
337
|
+
|
|
338
|
+
return 1
|
|
132
339
|
}
|