diff --git a/mantle/cmd/kola/devshell.go b/mantle/cmd/kola/devshell.go index b81b249b17..d1aac1b0d6 100644 --- a/mantle/cmd/kola/devshell.go +++ b/mantle/cmd/kola/devshell.go @@ -65,7 +65,7 @@ func displayStatusMsg(ontty bool, status, msg string, termMaxWidth int) { fmt.Printf("\033[2K\r%s", s) } -func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *conf.Conf, sshCommand string) error { +func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *conf.Conf, sshCommand string, rootSMBIOS bool) error { ontty := term.IsTerminal(0) if sshCommand == "" { if !ontty { @@ -95,8 +95,17 @@ func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *co return err } - conf.CopyKeys(keys) - builder.SetConfig(conf) + if rootSMBIOS { + var authorizedKeys string + for _, key := range keys { + authorizedKeys += key.String() + authorizedKeys += "\n" + } + builder.InjectSSHAuthorizedKeysViaSMBIOS(authorizedKeys) + } else { + conf.CopyKeys(keys) + builder.SetConfig(conf) + } // errChan communicates errors from go routines errChan := make(chan error) @@ -105,8 +114,10 @@ func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *co // stateChan reports in-instance state such as shutdown, reboot, etc. stateChan := make(chan guestState) - if err = watchJournal(builder, conf, stateChan, errChan); err != nil { - return err + if conf != nil { + if err = watchJournal(builder, conf, stateChan, errChan); err != nil { + return err + } } // SerialPipe is the pipe output from the serial console. @@ -186,9 +197,25 @@ func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *co // Start the SSH client sc := newSshClient(ip, agent.Socket, sshCommand) + if rootSMBIOS { + sc.user = "root" + } sc.ontty = ontty go sc.controlStartStop() + if rootSMBIOS { + go func() { + err := util.Retry(6, 5*time.Second, func() error { + return sc.check() + }) + if err != nil { + errChan <- fmt.Errorf("failed to await ssh: %w", err) + } else { + stateChan <- guestStateOpenSshStarted + } + }() + } + ready := false statusMsg := "STARTUP" lastMsg := "" @@ -213,7 +240,7 @@ func runDevShellSSH(ctx context.Context, builder *platform.QemuBuilder, conf *co return fmt.Errorf("instance failed in initramfs; try rerunning with --devshell-console") } if err != nil { - fmt.Fprintf(os.Stderr, "errchan: %v", err) + return err } // monitor the instance state @@ -472,6 +499,7 @@ type sshClient struct { port string agent string cmd string + user string ontty bool controlChan chan sshControlMessage errChan chan error @@ -492,6 +520,7 @@ func newSshClient(host, agent, cmd string) *sshClient { host: host, port: port, agent: agent, + user: "core", controlChan: make(chan sshControlMessage), errChan: make(chan error), // this could be a []string, but ssh sends it over as a string anyway, so meh... @@ -499,6 +528,31 @@ func newSshClient(host, agent, cmd string) *sshClient { } } +// baseArgs returns the basic arguments for the SSH command +func (sc *sshClient) baseArgs() []string { + return []string{ + "ssh", "-t", + "-o", "User=" + sc.user, + "-o", "StrictHostKeyChecking=no", + "-o", "CheckHostIP=no", + "-o", "IdentityAgent=" + sc.agent, + "-o", "PreferredAuthentications=publickey", + "-p", sc.port, sc.host, + } +} + +// check queries whether we can successfully execute a command on the remote system +func (sc *sshClient) check() error { + sshArgs := sc.baseArgs() + // Relatively fast timeout to ensure we don't get stuck by firewalling, etc. + sshArgs = append(sshArgs, "-o", "ConnectTimeout=5", "--", "true") + sshCmd := exec.Command(sshArgs[0], sshArgs[1:]...) + sshCmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + } + return sshCmd.Run() +} + // start starts the SSH session and returns the error over the errChan. // While the pure-go SSH client would be nicer, the golang SSH client // doesn't handle some keyboard keys well (arrows, home, end, etc). @@ -517,15 +571,7 @@ func (sc *sshClient) start() { time.Sleep(1 * time.Second) }() - sshArgs := []string{ - "ssh", "-t", - "-o", "User=core", - "-o", "StrictHostKeyChecking=no", - "-o", "CheckHostIP=no", - "-o", "IdentityAgent=" + sc.agent, - "-o", "PreferredAuthentications=publickey", - "-p", sc.port, sc.host, - } + sshArgs := sc.baseArgs() if sc.cmd != "" { sshArgs = append(sshArgs, "--", sc.cmd) } diff --git a/mantle/cmd/kola/qemuexec.go b/mantle/cmd/kola/qemuexec.go index 93082545ef..0966d07f25 100644 --- a/mantle/cmd/kola/qemuexec.go +++ b/mantle/cmd/kola/qemuexec.go @@ -58,6 +58,8 @@ var ( bindro []string bindrw []string + rootSMBIOS bool + directIgnition bool forceConfigInjection bool propagateInitramfsFailure bool @@ -101,6 +103,7 @@ func init() { cmdQemuExec.Flags().BoolVar(&propagateInitramfsFailure, "propagate-initramfs-failure", false, "Error out if the system fails in the initramfs") cmdQemuExec.Flags().StringVarP(&consoleFile, "console-to-file", "", "", "Filepath in which to save serial console logs") cmdQemuExec.Flags().IntVarP(&additionalNics, "additional-nics", "", 0, "Number of additional NICs to add") + cmdQemuExec.Flags().BoolVarP(&rootSMBIOS, "root-ssh-smbios", "S", false, "Inject root login via systemd credentials (SMBIOS), not Ignition") cmdQemuExec.Flags().StringVarP(&sshCommand, "ssh-command", "x", "", "Command to execute instead of spawning a shell") cmdQemuExec.Flags().StringVarP(&netboot, "netboot", "", "", "Filepath to BOOTP program (e.g. PXELINUX/GRUB binary or iPXE script") cmdQemuExec.Flags().StringVarP(&netbootDir, "netboot-dir", "", "", "Directory to serve over TFTP (default: BOOTP parent dir). If specified, --netboot is relative to this dir.") @@ -216,10 +219,12 @@ func runQemuExec(cmd *cobra.Command, args []string) error { if kola.QEMUOptions.DiskImage == "" && kolaPlatform == "qemu" { return fmt.Errorf("No disk image provided") } - ignitionFragments = append(ignitionFragments, "autologin") + if !rootSMBIOS { + ignitionFragments = append(ignitionFragments, "autologin") + } cpuCountHost = true usernet = true - if kola.Options.CosaWorkdir != "" { + if !rootSMBIOS && kola.Options.CosaWorkdir != "" { // Conservatively bind readonly to avoid anything in the guest (stray tests, whatever) // from destroying stuff bindro = append(bindro, fmt.Sprintf("%s,/var/mnt/workdir", kola.Options.CosaWorkdir)) @@ -387,7 +392,12 @@ func runQemuExec(cmd *cobra.Command, args []string) error { } if devshell && !devshellConsole { - return runDevShellSSH(ctx, builder, config, sshCommand) + if rootSMBIOS { + if config != nil { + return fmt.Errorf("cannot perform Ignition configuration when using plain SMBIOS") + } + } + return runDevShellSSH(ctx, builder, config, sshCommand, rootSMBIOS) } if config != nil { if directIgnition { diff --git a/mantle/platform/qemu.go b/mantle/platform/qemu.go index a9841597c9..13bb134145 100644 --- a/mantle/platform/qemu.go +++ b/mantle/platform/qemu.go @@ -32,6 +32,7 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "fmt" "io" "math/rand" @@ -477,6 +478,10 @@ type QemuBuilder struct { ignitionSet bool ignitionRendered bool + // sshAuthorizedKeysForRootViaSMBIOS is a public SSH key that will be rendered as a credential (https://systemd.io/CREDENTIALS) + // if set. + sshAuthorizedKeysForRootViaSMBIOS string + UsermodeNetworking bool usermodeNetworkingAddr string RestrictNetworking bool @@ -575,6 +580,28 @@ func (builder *QemuBuilder) renderIgnition() error { return nil } +// encodedSystemdTmpfilesForSSH implements the bit from systemd.io/CREDENTIALS +// to inject a public SSH key into authorized_keys for the target user. +func encodedSystemdTmpfilesForSSH(user, pubKey string) (string, error) { + pubKeyEnc := base64.StdEncoding.EncodeToString([]byte(pubKey)) + + userHomeDir := "/root" + if user != "root" { + userHomeDir = filepath.Join("/home", user) + } + + tmpFileCmd := fmt.Sprintf("d %[1]s/.ssh 0700 %[2]s %[2]s -\nf+~ %[1]s/.ssh/authorized_keys 600 %[2]s %[2]s - %[3]s", userHomeDir, user, pubKeyEnc) + + tmpFileCmdEnc := base64.StdEncoding.EncodeToString([]byte(tmpFileCmd)) + return tmpFileCmdEnc, nil +} + +// InjectSSHAuthorizedKeysViaSMBIOS writes the `authorized_keys` file for the `root` user +// injected via SMBIOS and systemd credentials. +func (builder *QemuBuilder) InjectSSHAuthorizedKeysViaSMBIOS(authorizedKeys string) { + builder.sshAuthorizedKeysForRootViaSMBIOS = authorizedKeys +} + // AddFd appends a file descriptor that will be passed to qemu, // returning a "/dev/fdset/" argument that one can use with e.g. // -drive file=/dev/fdset/. @@ -1071,6 +1098,8 @@ func (disk *Disk) prepare(builder *QemuBuilder) error { // on our own. if strings.HasSuffix(backingFile, "qcow2") { format = "qcow2" + } else if strings.HasSuffix(backingFile, "raw") { + format = "raw" } } if format != "" { @@ -1775,6 +1804,16 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { } } + if builder.sshAuthorizedKeysForRootViaSMBIOS != "" { + // Right now we hardcode root for simplicity + tmpFilesCmd, err := encodedSystemdTmpfilesForSSH("root", builder.sshAuthorizedKeysForRootViaSMBIOS) + if err != nil { + return nil, err + } + oemString := fmt.Sprintf("type=11,value=io.systemd.credential.binary:tmpfiles.extra=%s", tmpFilesCmd) + argv = append(argv, "-smbios", oemString) + } + // Handle Software TPM if builder.Swtpm && builder.supportsSwtpm() { err = builder.ensureTempdir()