diff --git a/Makefile b/Makefile index 043fbc9..2a0eb0e 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ all: mosctl mosb trust $(ZOT) $(ORAS) $(REGCTL) VERSION_LDFLAGS=-X github.com/project-machine/mos/pkg/mosconfig.Version=$(MAIN_VERSION) \ -X github.com/project-machine/mos/pkg/trust.Version=$(MAIN_VERSION) \ - -X github.com/project-machine/mos/pkg/mosconfig.LayerVersion=0.0.1 \ + -X github.com/project-machine/mos/pkg/mosconfig.LayerVersion=0.0.2 \ -X github.com/project-machine/mos/pkg/trust.BootkitVersion=$(BOOTKIT_VERSION) mosctl: .made-gofmt $(GO_SRC) diff --git a/cmd/trust/launch.go b/cmd/trust/launch.go new file mode 100644 index 0000000..2c46cfd --- /dev/null +++ b/cmd/trust/launch.go @@ -0,0 +1,175 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/project-machine/mos/pkg/provider" + "github.com/project-machine/mos/pkg/trust" + "github.com/urfave/cli" +) + +var launchCmd = cli.Command{ + Name: "launch", + Usage: "launch a new machine", + UsageText: `name install-url + name: name to give the VM + install-url: install.json distoci URL to install (e.g. zothub.io/machine/zot/install:1.0.0) + + Note that install is not yet supported.`, + Action: doLaunch, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "project", + Usage: "keyset:project to which this machine will belong (TRUST_PROJECT)", + }, + cli.StringFlag{ + Name: "serial, uuid", + Usage: "Serial number UUID to assign to the machine, empty to use a random UUID", + Value: "", + }, + cli.BoolFlag{ + Name: "skip-provisioning", + Usage: "Skip provisioning the machine", + }, + cli.BoolFlag{ + Name: "skip-install", + Usage: "Skip running the install ISO", + }, + cli.StringFlag{ + Name: "type", + Usage: "Type of machine to launch.", + Value: "kvm", + }, + }, +} + +func splitFullProject(full string) (string, string, error) { + s := strings.Split(full, ":") + if len(s) != 2 { + return "", "", errors.Errorf("Bad project name %q, should be keyset:project, e.g. snakeoil:default.", full) + } + return s[0], s[1], nil +} + +func doLaunch(ctx *cli.Context) error { + args := ctx.Args() + if len(args) < 1 { + return errors.New("A name for the new machine is required") + } + + if !ctx.Bool("skip-install") && len(args) != 2 { + return errors.New("Install manifest URL is required") + } + + mtype := ctx.String("type") + var p provider.Provider + var err error + switch mtype { + case "kvm": + p, err = provider.NewKVMProvider() + if err != nil { + return errors.Wrapf(err, "Failed to instantiate machine provider for type %q", mtype) + } + default: + return errors.Errorf("Unknown machine type: %q", mtype) + } + + mname := args[0] + if mname == "" { + return errors.New("Please specify machine name") + } + + if p.Exists(mname) { + return errors.Errorf("Machine %q already exists", mname) + } + + fullProject := ctx.String("project") + keyset, project, err := splitFullProject(fullProject) + if err != nil { + return err + } + + trustDir, err := getMosKeyPath() + if err != nil { + return err + } + + keysetDir := filepath.Join(trustDir, keyset) + projDir := filepath.Join(keysetDir, "manifest", project) + if !PathExists(projDir) { + return errors.Errorf("Project %s not found", fullProject) + } + + uuid := ctx.String("uuid") + sudiDir, err := genSudi(keysetDir, projDir, uuid) + if err != nil { + return errors.Wrapf(err, "Failed generating SUDI") + } + if uuid == "" { + uuid = filepath.Base(sudiDir) + } + + defer func() { + if err != nil { + os.RemoveAll(sudiDir) + } + }() + + if err := makeSudiVfat(sudiDir); err != nil { + return errors.Wrapf(err, "Failed creating SUDI disk") + } + + m, err := p.New(mname, fullProject, uuid) + if err != nil { + return errors.Wrapf(err, "Failed to create new machine") + } + + defer func() { + if err != nil { + p.Delete(mname) + } + }() + + if err := m.RunProvision(); err != nil { + return errors.Wrapf(err, "Failed to run provisioning ISO") + } + + return nil +} + +// Make a VFAT disk storing the already-generated SUDI cert and privkey +func makeSudiVfat(sudiDir string) error { + cert := filepath.Join(sudiDir, "cert.pem") + key := filepath.Join(sudiDir, "privkey.pem") + disk := filepath.Join(sudiDir, "sudi.vfat") + if !trust.PathExists(cert) || !trust.PathExists(key) { + return errors.Errorf("cert or key does not exist") + } + if trust.PathExists(disk) { + return errors.Errorf("sudi.vfat already exists") + } + + f, err := os.OpenFile(disk, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return errors.Wrapf(err, "Failed creating sudi disk") + } + f.Close() + + if err := os.Truncate(disk, 20*1024*1024); err != nil { + return errors.Wrapf(err, "Failed truncating sudi disk") + } + if err := trust.RunCommand("mkfs.vfat", "-n", "trust-data", disk); err != nil { + return errors.Wrapf(err, "Failed formatting sudi disk") + } + if err := trust.RunCommand("mcopy", "-i", disk, cert, "::cert.pem"); err != nil { + return errors.Wrapf(err, "Failed copying cert to sudi disk") + } + if err := trust.RunCommand("mcopy", "-i", disk, key, "::privkey.pem"); err != nil { + return errors.Wrapf(err, "Failed copying key to sudi disk") + } + + return nil +} diff --git a/cmd/trust/main.go b/cmd/trust/main.go index c0cd129..da56a4d 100644 --- a/cmd/trust/main.go +++ b/cmd/trust/main.go @@ -28,10 +28,13 @@ func main() { // keyset keysetCmd, + // launch + launchCmd, + // project projectCmd, - // sudo + // sudi sudiCmd, // sign diff --git a/cmd/trust/sudi.go b/cmd/trust/sudi.go index 06e5824..79de493 100644 --- a/cmd/trust/sudi.go +++ b/cmd/trust/sudi.go @@ -48,8 +48,6 @@ func doGenSudi(ctx *cli.Context) error { var myUUID string if len(args) == 3 { myUUID = args[2] - } else { - myUUID = uuid.NewString() } trustDir, err := getMosKeyPath() @@ -66,37 +64,51 @@ func doGenSudi(ctx *cli.Context) error { return fmt.Errorf("Project not found: %s", projName) } - capath := filepath.Join(keysetPath, "sudi-ca") - snPath := filepath.Join(projPath, "sudi", myUUID) - prodUUID, err := os.ReadFile(filepath.Join(projPath, "uuid")) + if _, err = genSudi(keysetPath, projPath, myUUID); err != nil { + return errors.Wrapf(err, "Failed generating SUDI") + } + + return nil +} + +// Generate a SUDI key for given uuid. If uuid is "", then generate a +// new UUID. Return the directory path for this new SUDI cert. +func genSudi(keysetPath, projDir, sudiUUID string) (string, error) { + prodUUID, err := os.ReadFile(filepath.Join(projDir, "uuid")) if err != nil { - return errors.Wrapf(err, "Failed reading project UUID") + return "", errors.Wrapf(err, "Failed reading project UUID") + } + + if sudiUUID == "" { + sudiUUID = uuid.NewString() } // read the project CA certificate + capath := filepath.Join(keysetPath, "sudi-ca") caCert, err := readCertificateFromFile(filepath.Join(capath, "cert.pem")) if err != nil { - return errors.Wrapf(err, "Failed reading SUDI CA certificate") + return "", errors.Wrapf(err, "Failed reading SUDI CA certificate") } // read the project CA private key to sign the sudi key with caKey, err := readPrivKeyFromFile(filepath.Join(capath, "privkey.pem")) if err != nil { - return errors.Wrapf(err, "Failed reading SUDI CA key") + return "", errors.Wrapf(err, "Failed reading SUDI CA key") } - certTmpl := newCertTemplate(string(prodUUID), myUUID) + certTmpl := newCertTemplate(string(prodUUID), sudiUUID) + snPath := filepath.Join(projDir, "sudi", sudiUUID) if err := trust.EnsureDir(snPath); err != nil { - return errors.Wrapf(err, "Failed creating new SUDI directory") + return "", errors.Wrapf(err, "Failed creating new SUDI directory") } if err := SignCert(&certTmpl, caCert, caKey, snPath); err != nil { os.RemoveAll(snPath) - return errors.Wrapf(err, "Failed creating new SUDI keypair") + return "", errors.Wrapf(err, "Failed creating new SUDI keypair") } - return nil + return snPath, nil } func newCertTemplate(productUUID, machineUUID string) x509.Certificate { diff --git a/layers/provision/trust-provision b/layers/provision/trust-provision index 5536322..4add1ac 100755 --- a/layers/provision/trust-provision +++ b/layers/provision/trust-provision @@ -36,11 +36,12 @@ done [ -z "$missing" ] || fail "$devpath was found, but did not contain ${missing# }" -exec mosctl provision --disk /dev/sda --wipe \ +mosctl provision --disk /dev/sda --wipe \ "$mp/cert.pem" "$mp/privkey.pem" if [ $? -eq 0 ]; then echo "XXX provisioned successfully XXX" else echo "XXX FAIL XXX" + exit 1 fi diff --git a/pkg/provider/kvm.go b/pkg/provider/kvm.go new file mode 100644 index 0000000..bd08be7 --- /dev/null +++ b/pkg/provider/kvm.go @@ -0,0 +1,228 @@ +package provider + +import ( + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/project-machine/mos/pkg/trust" +) + +// TODO - can we get machine to auto-detect the uefi-code it needs? +const KVMTemplate = ` + name: %s + type: kvm + ephemeral: false + description: A fresh VM booting trust LiveCD in SecureBoot mode with TPM + config: + name: %s + uefi: true + uefi-code: /usr/share/OVMF/OVMF_CODE.secboot.fd + uefi-vars: %s + cdrom: %s + boot: cdrom + tpm: true + gui: true + serial: true + tpm-version: 2.0 + secure-boot: true + disks: + - file: %s + type: ssd + size: 120G + - file: %s + format: raw + type: hdd` + +type KVMProvider struct { +} + +type KVMMachine struct { + Name string + Keyset string // keyset to which this machine belongs + Project string // key project to which this machine belongs + UUID string // assigned UUID (set in SUDI) +} + +func NewKVMProvider() (KVMProvider, error) { + if err := trust.RunCommand("machine", "list"); err != nil { + return KVMProvider{}, errors.Wrapf(err, "machined not running?") + } + return KVMProvider{}, nil +} + +func (p KVMProvider) Type() ProviderType { + return KVMMachineType +} + +func (p KVMProvider) Exists(mname string) bool { + if err := trust.RunCommand("machine", "info", mname); err == nil { + return true + } + return false +} + +func (p KVMProvider) New(mname, keyproject, UUID string) (Machine, error) { + s := strings.Split(keyproject, ":") + if len(s) != 2 { + return KVMMachine{}, errors.Errorf("Bad keyset project name %q", keyproject) + } + + m := KVMMachine{ + Name: mname, + Keyset: s[0], + Project: s[1], + UUID: UUID, + } + + machineBaseDir, err := trust.MachineDir(m.Name) + if err != nil { + return m, errors.Wrapf(err, "Failed getting machine dir") + } + if err := trust.EnsureDir(machineBaseDir); err != nil { + return m, errors.Wrapf(err, "Failed getting machine dir") + } + + // Create hard drive + qcowPath := filepath.Join(machineBaseDir, fmt.Sprintf("%s.qcow2", m.Name)) + if err := trust.RunCommand("qemu-img", "create", "-f", "qcow2", qcowPath, "600G"); err != nil { + return m, errors.Wrapf(err, "Failed creating disk") + } + + keysetDir, projDir, err := trust.KeyProjectDir(m.Keyset, m.Project) + if err != nil { + return m, errors.Wrapf(err, "Failed finding keyset path") + } + + fmt.Printf("UUID is %q\n", UUID) + sudiPath := filepath.Join(projDir, "sudi", UUID, "sudi.vfat") + + // Write a template + // Note this is set to boot from provisioning ISO with sudi.vfat + // attached. We'll remove those after provisioning. I did consider + // creating without those, and setting those only if the user calls + // Provision, but given the purpose of this machine type I'm not sure + // that's worth it. + provisionISO := filepath.Join(keysetDir, "artifacts", "provision.iso") + uefiVars := filepath.Join(keysetDir, "bootkit", "ovmf-vars.fd") + mData := fmt.Sprintf(KVMTemplate, m.Name, m.Name, uefiVars, provisionISO, + qcowPath, sudiPath) + fmt.Printf("mdata is:\n%s\n", mData) + _, _, err = trust.RunWithStdall(mData, "machine", "init", m.Name) + if err != nil { + return m, errors.Wrapf(err, "Failed initializing machine") + } + + // Write out the details of the machine to persistent storage + + return m, nil +} + +func (p KVMProvider) Delete(mname string) error { + if err := trust.RunCommand("machine", "delete", mname); err != nil { + return errors.Wrapf(err, "Failed deleting %q", mname) + } + return nil +} + +func (m KVMMachine) waitForState(state string) error { + for i := 1; i < 5; i += 1 { + if m.state(state) { + return nil + } + time.Sleep(time.Second * 1) + } + return errors.Errorf("Timed out waiting for %q to start", m.Name) +} + +const ( + STOPPED string = "status: stopped" + RUNNING string = "status: running" + FAILED string = "status: failed" +) + +func (m KVMMachine) RunProvision() error { + // Start the machine, and watch the console for 'provision complete' + if err := m.Start(); err != nil { + return errors.Wrapf(err, "Failed starting the machine to provision") + } + + if err := m.waitForState(RUNNING); err != nil { + return errors.Wrapf(err, "Error waiting for provisioning to begin") + } + + //cmd := exec.Command("machine", "console", m.Name) + home, err := os.UserHomeDir() + if err != nil { + return errors.Wrapf(err, "failed to get homedir") + } + mdir := filepath.Join(home, ".local/state/machine/machines", m.Name, m.Name) + msock := filepath.Join(mdir, "sockets", "console.sock") + time.Sleep(2 * time.Second) + if err := waitForUnix(msock, "provisioned successfully", "XXX FAIL XXX"); err != nil { + return errors.Wrapf(err, "Provisioning failed") + } + + if err := m.waitForState(STOPPED); err != nil { + return errors.Wrapf(err, "Machine did not shut down after provision") + } + + return nil +} + +// Connect to unix socket @sockPath, and waith for either EOF, +// or for either @good or @string to be seen +func waitForUnix(sockPath, good, bad string) error { + c, err := net.Dial("unix", sockPath) + if err != nil { + return errors.Wrapf(err, "Failed opening console socket %q", sockPath) + } + b, err := io.ReadAll(c) + if err != nil { + return errors.Wrapf(err, "Failed reading console socket") + } + s := string(b) + fmt.Printf("unix socket output: %q\n", s) + if strings.Contains(s, good) { + return nil + } + if strings.Contains(s, bad) { + return errors.Errorf("Action failed, as %q was found", bad) + } + + return errors.Errorf("Action timed out, did not find %q nor %q, in %q", good, bad) +} + +func (m KVMMachine) state(desired string) bool { + cmd := exec.Command("machine", "info", m.Name) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + + return strings.Contains(string(output), desired) +} + +func (m KVMMachine) RunInstall(url string) error { + return errors.Errorf("Not yet implemented") +} + +func (m KVMMachine) Start() error { + if err := trust.RunCommand("machine", "start", m.Name); err != nil { + return errors.Wrapf(err, "Failed starting %q", m.Name) + } + return nil +} + +func (m KVMMachine) Stop() error { + if err := trust.RunCommand("machine", "stop", m.Name); err != nil { + return errors.Wrapf(err, "Failed stopping %q", m.Name) + } + return nil +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..4119f1d --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,26 @@ +package provider + +type ProviderType string + +const ( + KVMMachineType ProviderType = "kvm" +) + +type Provider interface { + Type() ProviderType + + // Check whether a machine exists + Exists(string) bool + + // Create a new machine + New(mname, keyProject, UUID string) (Machine, error) + + Delete(name string) error +} + +type Machine interface { + RunProvision() error + RunInstall(url string) error + Start() error + Stop() error +} diff --git a/pkg/trust/utils.go b/pkg/trust/utils.go index 91f1e78..aae9f0e 100644 --- a/pkg/trust/utils.go +++ b/pkg/trust/utils.go @@ -172,6 +172,15 @@ func RunCommand(args ...string) error { return nil } +// UserDataDir returns the user's data directory +func MachineDir(mname string) (string, error) { + p, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(p, ".local", "state", "machine", "machines", mname, mname), nil +} + // UserDataDir returns the user's data directory func UserDataDir() (string, error) { p, err := os.UserHomeDir() @@ -190,6 +199,16 @@ func getMosKeyPath() (string, error) { return filepath.Join(dataDir, "machine", "trust", "keys"), nil } +func KeyProjectDir(keyset, project string) (string, string, error) { + d, err := getMosKeyPath() + if err != nil { + return "", "", err + } + k := filepath.Join(d, keyset) + p := filepath.Join(k, "manifest", project) + return k, p, nil +} + // Just create a cpio file. @path will be the top level directory // or the file in the new cpio file index. func NewCpio(cpio, path string) error {