github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/vm/starnix/starnix.go (about) 1 // Copyright 2023 syzkaller project authors. All rights reserved. 2 // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. 3 4 package starnix 5 6 import ( 7 "bytes" 8 "context" 9 "encoding/json" 10 "fmt" 11 "io" 12 "os" 13 "os/exec" 14 "path/filepath" 15 "slices" 16 "strconv" 17 "strings" 18 "time" 19 20 "github.com/google/syzkaller/pkg/config" 21 "github.com/google/syzkaller/pkg/log" 22 "github.com/google/syzkaller/pkg/osutil" 23 "github.com/google/syzkaller/pkg/report" 24 "github.com/google/syzkaller/sys/targets" 25 "github.com/google/syzkaller/vm/vmimpl" 26 ) 27 28 func init() { 29 var _ vmimpl.Infoer = (*instance)(nil) 30 vmimpl.Register(targets.Starnix, vmimpl.Type{ 31 Ctor: ctor, 32 Overcommit: true, 33 }) 34 } 35 36 type Config struct { 37 // Number of VMs to run in parallel (1 by default). 38 Count int `json:"count"` 39 } 40 41 type Pool struct { 42 count int 43 env *vmimpl.Env 44 cfg *Config 45 ffxDir string 46 } 47 48 type instance struct { 49 fuchsiaDir string 50 ffxBinary string 51 ffxLogBinary string 52 ffxDir string 53 name string 54 index int 55 cfg *Config 56 version string 57 debug bool 58 workdir string 59 port int 60 forwardPort int 61 rpipe io.ReadCloser 62 wpipe io.WriteCloser 63 fuchsiaLogs *exec.Cmd 64 sshBridge *exec.Cmd 65 sshPubKey string 66 sshPrivKey string 67 merger *vmimpl.OutputMerger 68 timeouts targets.Timeouts 69 } 70 71 const targetDir = "/tmp" 72 73 func ctor(env *vmimpl.Env) (vmimpl.Pool, error) { 74 cfg := &Config{} 75 if err := config.LoadData(env.Config, cfg); err != nil { 76 return nil, fmt.Errorf("failed to parse starnix vm config: %w", err) 77 } 78 if cfg.Count < 1 || cfg.Count > 128 { 79 return nil, fmt.Errorf("invalid config param count: %v, want [1, 128]", cfg.Count) 80 } 81 82 ffxDir, err := os.MkdirTemp("", "syz-ffx") 83 if err != nil { 84 return nil, fmt.Errorf("failed to make ffx isolation dir: %w", err) 85 } 86 if env.Debug { 87 log.Logf(0, "initialized vm pool with ffx dir: %v", ffxDir) 88 } 89 90 pool := &Pool{ 91 count: cfg.Count, 92 env: env, 93 cfg: cfg, 94 ffxDir: ffxDir, 95 } 96 return pool, nil 97 } 98 99 func (pool *Pool) Count() int { 100 return pool.count 101 } 102 103 func (pool *Pool) Create(_ context.Context, workdir string, index int) (vmimpl.Instance, error) { 104 inst := &instance{ 105 fuchsiaDir: pool.env.KernelSrc, 106 ffxDir: pool.ffxDir, 107 name: fmt.Sprintf("VM-%v", index), 108 index: index, 109 cfg: pool.cfg, 110 debug: pool.env.Debug, 111 workdir: workdir, 112 timeouts: pool.env.Timeouts, 113 } 114 closeInst := inst 115 defer func() { 116 if closeInst != nil { 117 closeInst.Close() 118 } 119 }() 120 121 var err error 122 inst.ffxBinary, err = GetToolPath(inst.fuchsiaDir, "ffx") 123 if err != nil { 124 return nil, err 125 } 126 inst.ffxLogBinary, err = GetToolPath(inst.fuchsiaDir, "ffx-log") 127 if err != nil { 128 return nil, err 129 } 130 131 inst.rpipe, inst.wpipe, err = osutil.LongPipe() 132 if err != nil { 133 return nil, err 134 } 135 136 if err := inst.setFuchsiaVersion(); err != nil { 137 return nil, fmt.Errorf( 138 "there is an error running ffx commands in the Fuchsia checkout (%q): %w", 139 inst.fuchsiaDir, 140 err) 141 } 142 pubkey, err := inst.getFfxConfigValue("ssh.pub") 143 if err != nil { 144 return nil, err 145 } 146 inst.sshPubKey = pubkey 147 148 privkey, err := inst.getFfxConfigValue("ssh.priv") 149 if err != nil { 150 return nil, err 151 } 152 inst.sshPrivKey = privkey 153 154 // Copy auto-detected paths from in-tree ffx to isolated ffx. 155 err = inst.copyFfxConfigValuesToIsolate( 156 "product.path", 157 "sdk.overrides.aemu_internal", 158 "sdk.overrides.uefi_internal_x64") 159 if err != nil { 160 return nil, err 161 } 162 163 if err := inst.boot(); err != nil { 164 return nil, err 165 } 166 167 closeInst = nil 168 return inst, nil 169 } 170 171 func (pool *Pool) Close() error { 172 if pool.env.Debug { 173 log.Logf(0, "shutting down vm pool with tempdir %v...", pool.ffxDir) 174 } 175 176 // The ffx daemon will exit automatically when it sees its isolation dir removed. 177 return os.RemoveAll(pool.ffxDir) 178 } 179 180 func (inst *instance) boot() error { 181 inst.port = vmimpl.UnusedTCPPort() 182 // Start output merger. 183 var tee io.Writer 184 if inst.debug { 185 tee = os.Stdout 186 } 187 inst.merger = vmimpl.NewOutputMerger(tee) 188 189 inst.runFfx(5*time.Minute, true, "emu", "stop", inst.name) 190 191 if err := inst.startFuchsiaVM(); err != nil { 192 return fmt.Errorf("instance %s: could not start Fuchsia VM: %w", inst.name, err) 193 } 194 if err := inst.startSshdAndConnect(); err != nil { 195 return fmt.Errorf("instance %s: could not start sshd: %w", inst.name, err) 196 } 197 if inst.debug { 198 log.Logf(0, "instance %s: setting up...", inst.name) 199 } 200 if err := inst.startFuchsiaLogs(); err != nil { 201 return fmt.Errorf("instance %s: could not start fuchsia logs: %w", inst.name, err) 202 } 203 if inst.debug { 204 log.Logf(0, "instance %s: booted successfully", inst.name) 205 } 206 return nil 207 } 208 209 func (inst *instance) Close() error { 210 inst.runFfx(5*time.Minute, true, "emu", "stop", inst.name) 211 if inst.fuchsiaLogs != nil { 212 inst.fuchsiaLogs.Process.Kill() 213 inst.fuchsiaLogs.Wait() 214 } 215 if inst.sshBridge != nil { 216 inst.sshBridge.Process.Kill() 217 inst.sshBridge.Wait() 218 } 219 if inst.rpipe != nil { 220 inst.rpipe.Close() 221 } 222 if inst.wpipe != nil { 223 inst.wpipe.Close() 224 } 225 if inst.merger != nil { 226 inst.merger.Wait() 227 } 228 return nil 229 } 230 231 func (inst *instance) startFuchsiaVM() error { 232 if _, err := inst.runFfx( 233 5*time.Minute, 234 true, 235 "emu", "start", "--headless", 236 "--name", inst.name, "--net", "user"); err != nil { 237 return err 238 } 239 return nil 240 } 241 242 func (inst *instance) startFuchsiaLogs() error { 243 // `ffx log` outputs some buffered logs by default, and logs from early boot 244 // trigger a false positive from the unexpected reboot check. To avoid this, 245 // only request logs from now on. 246 cmd := inst.ffxCommand( 247 true, 248 inst.ffxLogBinary, 249 "--target", inst.name, "log", "--since", "now", 250 "--show-metadata", "--show-full-moniker", "--no-color", 251 "--exclude-tags", "netlink") 252 cmd.Stdout = inst.wpipe 253 cmd.Stderr = inst.wpipe 254 inst.merger.Add("fuchsia", inst.rpipe) 255 if inst.debug { 256 log.Logf(1, "instance %s: starting ffx log", inst.name) 257 } 258 if err := cmd.Start(); err != nil { 259 if inst.debug { 260 log.Logf(0, "instance %s: failed to start ffx log", inst.name) 261 } 262 return err 263 } 264 inst.fuchsiaLogs = cmd 265 inst.wpipe.Close() 266 inst.wpipe = nil 267 return nil 268 } 269 270 func (inst *instance) startSshdAndConnect() error { 271 if _, err := inst.runFfx( 272 5*time.Minute, 273 true, 274 "--target", 275 inst.name, 276 "component", 277 "run", 278 "/core/starnix_runner/playground:alpine", 279 "fuchsia-pkg://fuchsia.com/syzkaller_starnix#meta/alpine_container.cm", 280 ); err != nil { 281 return err 282 } 283 if inst.debug { 284 log.Logf(1, "instance %s: started alpine container", inst.name) 285 } 286 if _, err := inst.runFfx( 287 5*time.Minute, 288 true, 289 "--target", 290 inst.name, 291 "component", 292 "run", 293 "/core/starnix_runner/playground:alpine/daemons:start_sshd", 294 "fuchsia-pkg://fuchsia.com/syzkaller_starnix#meta/start_sshd.cm", 295 ); err != nil { 296 return err 297 } 298 if inst.debug { 299 log.Logf(1, "instance %s: started sshd on alpine container", inst.name) 300 } 301 if _, err := inst.runFfx( 302 5*time.Minute, 303 true, 304 "--target", 305 inst.name, 306 "component", 307 "copy", 308 inst.sshPubKey, 309 "/core/starnix_runner/playground:alpine::out::fs_root/tmp/authorized_keys", 310 ); err != nil { 311 return err 312 } 313 if inst.debug { 314 log.Logf(0, "instance %s: copied ssh key", inst.name) 315 } 316 return inst.connect() 317 } 318 319 func (inst *instance) connect() error { 320 if inst.debug { 321 log.Logf(1, "instance %s: attempting to connect to starnix container over ssh", inst.name) 322 } 323 // Even though the formatting option is called `addresses`, it is guaranteed 324 // to return at most 1 address per target. 325 address, err := inst.runFfx( 326 30*time.Second, 327 true, 328 "--target", 329 inst.name, 330 "target", 331 "list", 332 "--format", 333 "addresses", 334 ) 335 if err != nil { 336 return err 337 } 338 if inst.debug { 339 log.Logf(0, "instance %s: the fuchsia instance's address is %s", inst.name, address) 340 } 341 cmd := osutil.Command( 342 "ssh", 343 "-o", "StrictHostKeyChecking=no", 344 "-o", "UserKnownHostsFile=/dev/null", 345 "-i", inst.sshPrivKey, 346 "-NT", 347 "-L", fmt.Sprintf("localhost:%d:localhost:7000", inst.port), 348 fmt.Sprintf("ssh://%s", bytes.Trim(address, "\n")), 349 ) 350 cmd.Stderr = os.Stderr 351 if err = cmd.Start(); err != nil { 352 return err 353 } 354 355 inst.sshBridge = cmd 356 357 time.Sleep(5 * time.Second) 358 if inst.debug { 359 log.Logf(0, "instance %s: forwarded port from starnix container", inst.name) 360 } 361 return nil 362 } 363 364 func (inst *instance) ffxCommand(isolated bool, binary string, args ...string) *exec.Cmd { 365 config := []string{"-c", "log.enabled=false,ffx.analytics.disabled=true"} 366 if !isolated { 367 config = append(config, "-c", "daemon.autostart=false") 368 } 369 args = slices.Concat(config, args) 370 cmd := osutil.Command(binary, args...) 371 cmd.Dir = inst.fuchsiaDir 372 cmd.Env = append(cmd.Environ(), "FUCHSIA_ANALYTICS_DISABLED=1") 373 if isolated { 374 cmd.Env = append(cmd.Env, "FFX_ISOLATE_DIR="+inst.ffxDir) 375 } 376 return cmd 377 } 378 379 func (inst *instance) runFfx(timeout time.Duration, isolated bool, args ...string) ([]byte, error) { 380 if inst.debug { 381 isolation := "without" 382 if isolated { 383 isolation = "with" 384 } 385 log.Logf(1, "instance %s: running ffx %s isolation: %q", inst.name, isolation, args) 386 } 387 388 cmd := inst.ffxCommand(isolated, inst.ffxBinary, args...) 389 cmd.Stderr = os.Stderr 390 output, err := osutil.Run(timeout, cmd) 391 if inst.debug { 392 log.Logf(1, "instance %s: %s", inst.name, output) 393 } 394 return output, err 395 } 396 397 // Gets a value from ffx's default configuration. 398 func (inst *instance) getFfxConfigValue(key string) (string, error) { 399 rawValue, err := inst.runFfx( 400 30*time.Second, 401 false, 402 "config", "get", key) 403 if err != nil { 404 return "", err 405 } 406 return string(bytes.Trim(rawValue, "\"\n")), nil 407 } 408 409 // Copies values from ffx's default configuration into the ffx isolate's configuration. 410 func (inst *instance) copyFfxConfigValuesToIsolate(keys ...string) error { 411 for _, key := range keys { 412 value, err := inst.getFfxConfigValue(key) 413 if err != nil { 414 return err 415 } 416 _, err = inst.runFfx( 417 30*time.Second, 418 true, 419 "config", "set", key, value) 420 if err != nil { 421 return err 422 } 423 } 424 return nil 425 } 426 427 // Runs a command inside the fuchsia directory. 428 func (inst *instance) runCommand(cmd string, args ...string) error { 429 if inst.debug { 430 log.Logf(1, "instance %s: running command: %s %q", inst.name, cmd, args) 431 } 432 output, err := osutil.RunCmd(5*time.Minute, inst.fuchsiaDir, cmd, args...) 433 if inst.debug { 434 log.Logf(1, "instance %s: %s", inst.name, output) 435 } 436 return err 437 } 438 439 func (inst *instance) Forward(port int) (string, error) { 440 if port == 0 { 441 return "", fmt.Errorf("vm/starnix: forward port is zero") 442 } 443 if inst.forwardPort != 0 { 444 return "", fmt.Errorf("vm/starnix: forward port already set") 445 } 446 inst.forwardPort = port 447 return fmt.Sprintf("localhost:%v", port), nil 448 } 449 450 func (inst *instance) Copy(hostSrc string) (string, error) { 451 base := filepath.Base(hostSrc) 452 vmDst := filepath.Join(targetDir, base) 453 if inst.debug { 454 log.Logf(1, "instance %s: attempting to push binary %s to instance over scp", inst.name, base) 455 } 456 err := inst.runCommand( 457 "scp", 458 "-o", "StrictHostKeyChecking=no", 459 "-o", "UserKnownHostsFile=/dev/null", 460 "-i", inst.sshPrivKey, 461 "-P", strconv.Itoa(inst.port), 462 hostSrc, 463 fmt.Sprintf("root@localhost:%s", vmDst), 464 ) 465 if err == nil { 466 return vmDst, err 467 } 468 return vmDst, fmt.Errorf("instance %s: can't push binary %s to instance over scp", inst.name, base) 469 } 470 471 func (inst *instance) Run(ctx context.Context, command string) ( 472 <-chan []byte, <-chan error, error) { 473 rpipe, wpipe, err := osutil.LongPipe() 474 if err != nil { 475 return nil, nil, err 476 } 477 inst.merger.Add("ssh", rpipe) 478 479 // Run `command` on the instance over ssh. 480 const useSystemSSHCfg = false 481 sshArgs := vmimpl.SSHArgsForward(inst.debug, inst.sshPrivKey, inst.port, inst.forwardPort, useSystemSSHCfg) 482 sshCmd := []string{"ssh"} 483 sshCmd = append(sshCmd, sshArgs...) 484 sshCmd = append(sshCmd, "root@localhost", "cd "+targetDir+" && ", command) 485 if inst.debug { 486 log.Logf(1, "instance %s: running command: %#v", inst.name, sshCmd) 487 } 488 489 cmd := osutil.Command(sshCmd[0], sshCmd[1:]...) 490 cmd.Dir = inst.workdir 491 cmd.Stdout = wpipe 492 cmd.Stderr = wpipe 493 if err := cmd.Start(); err != nil { 494 wpipe.Close() 495 return nil, nil, err 496 } 497 wpipe.Close() 498 return vmimpl.Multiplex(ctx, cmd, inst.merger, vmimpl.MultiplexConfig{ 499 Debug: inst.debug, 500 Scale: inst.timeouts.Scale, 501 }) 502 } 503 504 func (inst *instance) Info() ([]byte, error) { 505 info := fmt.Sprintf("%v\n%v", inst.version, "ffx") 506 return []byte(info), nil 507 } 508 509 func (inst *instance) Diagnose(rep *report.Report) ([]byte, bool) { 510 return nil, false 511 } 512 513 func (inst *instance) setFuchsiaVersion() error { 514 version, err := osutil.RunCmd(1*time.Minute, inst.fuchsiaDir, inst.ffxBinary, "version") 515 if err != nil { 516 return err 517 } 518 inst.version = string(version) 519 return nil 520 } 521 522 // Get the currently-selected build dir in a Fuchsia checkout. 523 func getFuchsiaBuildDir(fuchsiaDir string) (string, error) { 524 fxBuildDir := filepath.Join(fuchsiaDir, ".fx-build-dir") 525 contents, err := os.ReadFile(fxBuildDir) 526 if err != nil { 527 return "", fmt.Errorf("failed to read %q: %w", fxBuildDir, err) 528 } 529 530 buildDir := strings.TrimSpace(string(contents)) 531 if !filepath.IsAbs(buildDir) { 532 buildDir = filepath.Join(fuchsiaDir, buildDir) 533 } 534 535 return buildDir, nil 536 } 537 538 // Subset of data format used in tool_paths.json. 539 type toolMetadata struct { 540 Name string 541 Path string 542 } 543 544 // Resolve a tool by name using tool_paths.json in the build dir. 545 func GetToolPath(fuchsiaDir, toolName string) (string, error) { 546 buildDir, err := getFuchsiaBuildDir(fuchsiaDir) 547 if err != nil { 548 return "", err 549 } 550 551 jsonPath := filepath.Join(buildDir, "tool_paths.json") 552 jsonBlob, err := os.ReadFile(jsonPath) 553 if err != nil { 554 return "", fmt.Errorf("failed to read %q: %w", jsonPath, err) 555 } 556 var metadataList []toolMetadata 557 if err := json.Unmarshal(jsonBlob, &metadataList); err != nil { 558 return "", fmt.Errorf("failed to parse %q: %w", jsonPath, err) 559 } 560 561 for _, metadata := range metadataList { 562 if metadata.Name == toolName { 563 return filepath.Join(buildDir, metadata.Path), nil 564 } 565 } 566 567 return "", fmt.Errorf("no path found for tool %q in %q", toolName, jsonPath) 568 }