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/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
- set -a
46
- source "$f"
47
- set +a
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
- next_port() {
51
- local max="$BASE_PORT"
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 [[ -n "${p:-}" && "$p" -gt "$max" ]]; then
57
- max="$p"
151
+ if [[ "$p" == "$candidate" ]]; then
152
+ found=0
153
+ break
58
154
  fi
59
155
  done
60
- shopt -u nullglob
61
- echo $((max + 1))
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 max_octet=1
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
- if [[ "$oct" =~ ^[0-9]+$ ]] && (( oct > max_octet )); then
72
- max_octet="$oct"
73
- fi
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 next=$((max_octet + 1))
78
- (( next < 255 )) || die "IP pool exhausted"
79
- echo "172.16.0.$next"
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
- ensure_bridge_and_nat() {
83
- if ! ip link show "$BRIDGE_NAME" >/dev/null 2>&1; then
84
- ip link add "$BRIDGE_NAME" type bridge
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
- iptables -t nat -C POSTROUTING -s "$SUBNET_CIDR" ! -o "$BRIDGE_NAME" -j MASQUERADE 2>/dev/null \
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
- die "Cannot read SSH key: $key (try: sudo fireclaw ...)"
251
+ warn "Cannot read SSH key: $key (try: sudo fireclaw ...)"
103
252
  else
104
- die "SSH key not found: $key"
253
+ warn "SSH key not found: $key"
105
254
  fi
255
+ return 1
106
256
  fi
107
257
 
108
- local vm_svc vm_state
109
- vm_svc="$(vm_service "${INSTANCE_ID:-}")"
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
- vm_state="$(systemctl is-active "$vm_svc" 2>/dev/null)" || vm_state="inactive"
117
- if [[ "$vm_state" != "active" ]]; then
118
- die "VM is not running ($(printf '\033[31m%s\033[0m' "$vm_state")). Start it with: sudo fireclaw start ${INSTANCE_ID:-<id>}"
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
- die "VM is running but SSH did not become reachable at ubuntu@$ip after $((retries * 2))s"
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=/dev/null -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
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
  }