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/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:-fcbr0}"
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:-$HOME/.ssh/vmdemo_vm}"
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
- 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=""
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=/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
116
339
  }