From e706b448db0c82642d773e7c13ff46c1ec859618 Mon Sep 17 00:00:00 2001 From: Aleksander Mistewicz Date: Fri, 8 Sep 2017 14:52:34 +0200 Subject: [PATCH] Add dryad package Package dryad provides: * implementation of Dryad interface * utilities to manage Dryad and its users Verification steps: // compile tests GOARCH=arm GOARM=7 GOOS=linux ginkgo build -cover dryad // copy dryad/dryad.test to device // run everything ./dryad.test -ginkgo.v // run without measurements ./dryad.test -ginkgo.v -ginkgo.skipMeasurements // run prepare only ./dyrad.test -ginkgo.focus "should prepare" // private key will be printed on stderr // it can be used to verify that SSH key is properly installed Currently dryad is being run as root on muxpi. Future patches will probably use sudo and require configured file in /etc/sudoers.d/ directory. Change-Id: I095361ffd1f4b2b3fa5dfe2c000f960dd32886e2 Signed-off-by: Aleksander Mistewicz --- dryad/dryad_suite_test.go | 29 ++++++++++ dryad/key.go | 87 ++++++++++++++++++++++++++++ dryad/key_test.go | 41 +++++++++++++ dryad/muxpi.go | 75 ++++++++++++++++++++++++ dryad/rusalka.go | 98 +++++++++++++++++++++++++++++++ dryad/rusalka_test.go | 78 +++++++++++++++++++++++++ dryad/user.go | 143 ++++++++++++++++++++++++++++++++++++++++++++++ dryad/user_test.go | 59 +++++++++++++++++++ 8 files changed, 610 insertions(+) create mode 100644 dryad/dryad_suite_test.go create mode 100644 dryad/key.go create mode 100644 dryad/key_test.go create mode 100644 dryad/muxpi.go create mode 100644 dryad/rusalka.go create mode 100644 dryad/rusalka_test.go create mode 100644 dryad/user.go create mode 100644 dryad/user_test.go diff --git a/dryad/dryad_suite_test.go b/dryad/dryad_suite_test.go new file mode 100644 index 0000000..8f97d36 --- /dev/null +++ b/dryad/dryad_suite_test.go @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestDryad(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Dryad Suite") +} diff --git a/dryad/key.go b/dryad/key.go new file mode 100644 index 0000000..83f322b --- /dev/null +++ b/dryad/key.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad + +import ( + "crypto/rand" + "crypto/rsa" + "os" + "path" + "strconv" + + "golang.org/x/crypto/ssh" +) + +// sizeRSA is a length of the RSA key. +// It is experimentally chosen value as it is the longest key while still being fast to generate. +const sizeRSA = 1024 + +// installPublicKey marshals and stores key in a proper location to be read by ssh daemon. +func installPublicKey(key ssh.PublicKey, homedir, uid, gid string) error { + sshDir := path.Join(homedir, ".ssh") + err := os.MkdirAll(sshDir, 0755) + if err != nil { + return err + } + f, err := os.OpenFile(path.Join(sshDir, "authorized_keys"), + os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + err = updateOwnership(f, sshDir, uid, gid) + if err != nil { + return err + } + _, err = f.Write(ssh.MarshalAuthorizedKey(key)) + return err +} + +// updateOwnership changes the owner of key and sshDir to uid:gid parsed from uidStr and gidStr. +func updateOwnership(key *os.File, sshDir, uidStr, gidStr string) (err error) { + uid, err := strconv.Atoi(uidStr) + if err != nil { + return + } + gid, err := strconv.Atoi(gidStr) + if err != nil { + return + } + err = os.Chown(sshDir, uid, gid) + if err != nil { + return + } + return key.Chown(uid, gid) +} + +// generateAndInstallKey generates a new RSA key pair, installs the public part, +// changes its owner, and returns the private part. +func generateAndInstallKey(homedir, uid, gid string) (*rsa.PrivateKey, error) { + key, err := rsa.GenerateKey(rand.Reader, sizeRSA) + if err != nil { + return nil, err + } + sshPubKey, err := ssh.NewPublicKey(&key.PublicKey) + if err != nil { + return nil, err + } + err = installPublicKey(sshPubKey, homedir, uid, gid) + if err != nil { + return nil, err + } + return key, nil +} diff --git a/dryad/key_test.go b/dryad/key_test.go new file mode 100644 index 0000000..c4dcf1e --- /dev/null +++ b/dryad/key_test.go @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad + +import ( + "crypto/rand" + "crypto/rsa" + "strconv" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("key generator", func() { + generate := func(size int) { + _, err := rsa.GenerateKey(rand.Reader, size) + Expect(err).ToNot(HaveOccurred()) + } + + Measure("2048 should be fast", func(b Benchmarker) { + for _, size := range []int{512, 1024, 2048, 4096} { + b.Time(strconv.Itoa(size), func() { + generate(size) + }) + } + }, 2) +}) diff --git a/dryad/muxpi.go b/dryad/muxpi.go new file mode 100644 index 0000000..63756d4 --- /dev/null +++ b/dryad/muxpi.go @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad + +import ( + "context" + "time" + + "git.tizen.org/tools/muxpi/sw/nanopi/stm" +) + +type colorLED struct { + r, g, b uint8 +} + +var ( + off = colorLED{0, 0, 0} + red = colorLED{128, 0, 0} + green = colorLED{0, 128, 0} + blue = colorLED{0, 0, 128} + yellow = colorLED{96, 144, 0} + pink = colorLED{128, 16, 32} +) + +// setLED wraps stm's SetLED so that simple color definitions may be used. +func setLED(led stm.LED, col colorLED) error { + return stm.SetLED(led, col.r, col.g, col.b) +} + +// blinkMaintenanceLED alternates between LED1 and LED2 lighting each +// with yellow color for approximately 1 second. +// +// It is cancelled by ctx. +func blinkMaintenanceLED(ctx context.Context) { + defer stm.Close() + defer stm.ClearDisplay() + for { + setLED(stm.LED1, yellow) + time.Sleep(time.Second) + setLED(stm.LED1, off) + + setLED(stm.LED2, yellow) + time.Sleep(time.Second) + setLED(stm.LED2, off) + + select { + case <-ctx.Done(): + return + default: + } + } +} + +// printMessage clears the OLED display and prints msg to it. +func printMessage(msg string) error { + err := stm.ClearDisplay() + if err != nil { + return err + } + return stm.PrintText(0, 0, stm.Foreground, msg) +} diff --git a/dryad/rusalka.go b/dryad/rusalka.go new file mode 100644 index 0000000..e69d51c --- /dev/null +++ b/dryad/rusalka.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +// Package dryad provides: +// * implementation of Dryad interface +// * utilities to manage Dryad and its users +package dryad + +import ( + "context" + "crypto/rsa" + "fmt" + + . "git.tizen.org/tools/boruta" + "git.tizen.org/tools/muxpi/sw/nanopi/stm" +) + +// Rusalka implements Dryad interface. It is intended to be used on NanoPi connected to MuxPi. +// It is not safe for concurrent use. +type Rusalka struct { + Dryad + dryadUser *borutaUser + cancelMaintenance context.CancelFunc +} + +// NewRusalka returns Dryad interface to Rusalka. +func NewRusalka(username string, groups []string) Dryad { + return &Rusalka{ + dryadUser: newBorutaUser(username, groups), + } +} + +// PutInMaintenance is part of implementation of Dryad interface. +// Connection to STM is being opened only for the maintenance actions. +// Otherwise it may make it unusable for other STM users. It is closed +// when blinkMaintenanceLED exits. +func (r *Rusalka) PutInMaintenance(msg string) error { + // Connection to STM is closed in blinkMaintenanceLED(). + err := stm.Open() + if err != nil { + return err + } + err = printMessage(msg) + if err != nil { + return err + } + var ctx context.Context + ctx, r.cancelMaintenance = context.WithCancel(context.Background()) + go blinkMaintenanceLED(ctx) + return nil +} + +// Prepare is part of implementation of Dryad interface. Call to Prepare stops LED blinking. +func (r *Rusalka) Prepare() (key *rsa.PrivateKey, err error) { + // Stop maintenance. + if r.cancelMaintenance != nil { + r.cancelMaintenance() + r.cancelMaintenance = nil + } + // Remove/Add user. + err = r.dryadUser.delete() + if err != nil { + return nil, fmt.Errorf("user removal failed: %s", err) + } + err = r.dryadUser.add() + if err != nil { + return nil, fmt.Errorf("user creation failed: %s", err) + } + // Verify user's existance. + err = r.dryadUser.update() + if err != nil { + return nil, fmt.Errorf("user information update failed: %s", err) + } + // Prepare SSH access. + return r.dryadUser.generateAndInstallKey() +} + +// Healthcheck is part of implementation of Dryad interface. +func (r *Rusalka) Healthcheck() (err error) { + err = stm.Open() + if err != nil { + return err + } + return stm.Close() +} diff --git a/dryad/rusalka_test.go b/dryad/rusalka_test.go new file mode 100644 index 0000000..2455128 --- /dev/null +++ b/dryad/rusalka_test.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad_test + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "time" + + . "git.tizen.org/tools/boruta/dryad" + "git.tizen.org/tools/muxpi/sw/nanopi/stm" + + "git.tizen.org/tools/boruta" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Rusalka", func() { + var d boruta.Dryad + const ( + username = "test-user" + homeDir = "/home/" + username + sshDir = homeDir + "/.ssh/" + authorizedKeysFile = sshDir + "authorized_keys" + ) + + BeforeEach(func() { + err := stm.Open() + if err != nil { + Skip(fmt.Sprintf("STM is probably missing: %s", err)) + } + err = stm.Close() + Expect(err).ToNot(HaveOccurred()) + d = NewRusalka(username, []string{"users"}) + }) + + It("should put in maintenance", func() { + err := d.PutInMaintenance("test message") + Expect(err).ToNot(HaveOccurred()) + // TODO(amistewicz): somehow check that goroutine is running and can be terminated. + time.Sleep(10 * time.Second) + }) + + It("should prepare", func() { + key, err := d.Prepare() + Expect(err).ToNot(HaveOccurred()) + Expect(sshDir).To(BeADirectory()) + Expect(authorizedKeysFile).To(BeARegularFile()) + // TODO(amistewicz): Test for file permissions + // sshDir should have 0755 boruta-user + // authorizedKeysFile should be owned by boruta-user + pem.Encode(os.Stderr, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + }) + + It("should be healthy", func() { + err := d.Healthcheck() + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/dryad/user.go b/dryad/user.go new file mode 100644 index 0000000..dc2f48e --- /dev/null +++ b/dryad/user.go @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad + +import ( + "crypto/rsa" + "fmt" + "os/exec" + "os/user" + "strings" + "syscall" +) + +var ( + userAddExit = map[int]string{ + 0: "success", + 1: "can't update password file", + 2: "invalid command syntax", + 3: "invalid argument to option", + 4: "UID already in use (and no -o)", + 6: "specified group doesn't exist", + 9: "username already in use", + 10: "can't update group file", + 12: "can't create home directory", + 14: "can't update SELinux user mapping", + } + userDelExit = map[int]string{ + 0: "success", + 1: "can't update password file", + 2: "invalid command syntax", + 6: "specified user doesn't exist", + 8: "user currently logged in", + 10: "can't update group file", + 12: "can't remove home directory", + } +) + +// borutaUser is a representation of the local Unix user on Dryad. +type borutaUser struct { + username string + groups []string + user *user.User +} + +func newBorutaUser(username string, groups []string) *borutaUser { + return &borutaUser{ + username: username, + groups: groups, + } +} + +func prepareGroups(groups []string) string { + return strings.Join(groups, ",") +} + +// handleUserCmdError attempts to use predefined error message. Otherwise err and output are used. +// It works for useradd and userdel exit codes. +func handleUserCmdError(codeToMsg map[int]string, output []byte, err error) error { + // On Unix systems check exit code and replace error message with predefined string. + if exiterr, ok := err.(*exec.ExitError); ok { + if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { + if errmsg, ok := codeToMsg[status.ExitStatus()]; ok { + return fmt.Errorf("command failed: %s", errmsg) + } + } + } + return fmt.Errorf("command failed: %s, %s", err, string(output)) +} + +// prepareUserAddCmd is a helper function for add(). +func prepareUserAddCmd(username string, groups []string) *exec.Cmd { + // "-p" encrypted password of the new account. + // "-m" create the user's home directory. + // "-G" list of supplementary groups of the new account. + return exec.Command("useradd", "-p", "*", "-m", "-G", prepareGroups(groups), username) +} + +// add creates borutaUser on the system. +// It does nothing if the user with given username already exists. +func (bu *borutaUser) add() error { + if bu.update() == nil { + // user already exists. + return nil + } + output, err := prepareUserAddCmd(bu.username, bu.groups).CombinedOutput() + if err != nil { + return handleUserCmdError(userAddExit, output, err) + } + return nil +} + +// prepareUserDelCmd is a helper function for delete(). +func prepareUserDelCmd(username string) *exec.Cmd { + // "-r" remove home directory and mail spool. + // "-f" force removal of files, even if not owned by user. + return exec.Command("userdel", "-r", "-f", username) +} + +// delete removes borutaUser from the system. It does nothing if the user with given username +// has already been removed. It invalidates user field of borutaUser. +func (bu *borutaUser) delete() error { + err := bu.update() + if err != nil { + if _, ok := err.(user.UnknownUserError); ok { + // user already does not exist. + return nil + } + return err + } + output, err := prepareUserDelCmd(bu.username).CombinedOutput() + if err != nil { + return handleUserCmdError(userDelExit, output, err) + } + bu.user = nil + return nil +} + +// update retrieves information about borutaUser from the system. It should be used after userAdd. +// Stored information may be invalid after call to userdel. +func (bu *borutaUser) update() (err error) { + bu.user, err = user.Lookup(bu.username) + return +} + +// generateAndInstallKey calls generateAndInstallKey with parameters retrieved from the user field +// of borutaUser structure. This filed must be set before call to this function by update() method. +func (bu *borutaUser) generateAndInstallKey() (*rsa.PrivateKey, error) { + return generateAndInstallKey(bu.user.HomeDir, bu.user.Uid, bu.user.Gid) +} diff --git a/dryad/user_test.go b/dryad/user_test.go new file mode 100644 index 0000000..4f655fb --- /dev/null +++ b/dryad/user_test.go @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package dryad + +import ( + "os/user" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("user management", func() { + var bu *borutaUser + + BeforeEach(func() { + bu = &borutaUser{ + username: "test-user", + groups: []string{"users"}, + } + + u, err := user.Current() + Expect(err).ToNot(HaveOccurred()) + if u.Uid != "0" { + Skip("must be run as root") + } + }) + + It("should add user", func() { + err := bu.add() + Expect(err).ToNot(HaveOccurred()) + Expect("/home/test-user/").To(BeADirectory()) + + err = bu.update() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should remove user", func() { + err := bu.delete() + Expect(err).ToNot(HaveOccurred()) + Expect("/home/boruta-user/").ToNot(BeADirectory()) + + err = bu.update() + Expect(err).To(HaveOccurred()) + }) +}) -- 2.7.4