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  }