fireclaw 0.1.1 → 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 +32 -22
- package/bin/vm-common.sh +254 -31
- package/bin/vm-ctl +229 -40
- 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
|
@@ -6,12 +6,13 @@ FC_ROOT="${FC_ROOT:-/srv/firecracker/vm-demo}"
|
|
|
6
6
|
STATE_ROOT="${STATE_ROOT:-/var/lib/fireclaw}"
|
|
7
7
|
BASE_PORT="${BASE_PORT:-18890}"
|
|
8
8
|
|
|
9
|
-
BRIDGE_NAME="${BRIDGE_NAME:-
|
|
9
|
+
BRIDGE_NAME="${BRIDGE_NAME:-fc-br0}"
|
|
10
10
|
BRIDGE_ADDR="${BRIDGE_ADDR:-172.16.0.1/24}"
|
|
11
11
|
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
|
-
SSH_KEY_PATH="${SSH_KEY_PATH
|
|
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,81 +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=""
|
|
248
|
+
|
|
249
|
+
if [[ ! -r "$key" ]]; then
|
|
250
|
+
if [[ $EUID -ne 0 ]]; then
|
|
251
|
+
warn "Cannot read SSH key: $key (try: sudo fireclaw ...)"
|
|
252
|
+
else
|
|
253
|
+
warn "SSH key not found: $key"
|
|
254
|
+
fi
|
|
255
|
+
return 1
|
|
256
|
+
fi
|
|
257
|
+
|
|
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")"
|
|
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.
|
|
99
268
|
local i
|
|
100
269
|
for ((i=1; i<=retries; i++)); do
|
|
101
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
|
|
102
276
|
return 0
|
|
103
277
|
fi
|
|
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
|
|
287
|
+
fi
|
|
104
288
|
sleep 2
|
|
105
289
|
done
|
|
290
|
+
warn "VM is running but SSH did not become reachable at ubuntu@$ip after $((retries * 2))s"
|
|
106
291
|
return 1
|
|
107
292
|
}
|
|
108
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
|
|
300
|
+
}
|
|
301
|
+
|
|
109
302
|
check_guest_health() {
|
|
110
303
|
local id="$1"
|
|
111
304
|
local ip="$2"
|
|
112
305
|
local key="${3:-$SSH_KEY_PATH}"
|
|
306
|
+
validate_instance_id "$id"
|
|
113
307
|
local script
|
|
114
308
|
script="$(guest_health_script "$id")"
|
|
115
|
-
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
|
|
116
339
|
}
|