diff --git a/Documentation/cloud-config.md b/Documentation/cloud-config.md index e061fc1..b0143aa 100644 --- a/Documentation/cloud-config.md +++ b/Documentation/cloud-config.md @@ -13,6 +13,40 @@ Provided public SSH keys will be authorized for the `core` user. The keys will be named "coreos-cloudinit" by default. Override this with the `--ssh-key-name` flag when calling `coreos-cloudinit`. +#### users + +Add or modify users with the `users` directive by providing a list of user objects, each consisting of the following fields. +Each field is optional and of type string unless otherwise noted. +All but the `passwd` and `ssh-authorized-keys` fields will be ignored if the user already exists. + +- **name**: Required. Login name of user +- **gecos**: GECOS comment of user +- **passwd**: Hash of the password to use for this user +- **homedir**: User's home directory. Defaults to /home/ +- **no-create-home**: Boolean. Skip home directory createion. +- **primary-group**: Default group for the user. Defaults to a new group created named after the user. +- **groups**: Add user to these additional groups +- **no-user-group**: Boolean. Skip default group creation. +- **ssh-authorized-keys**: List of public SSH keys to authorize for this user +- **system**: Create the user as a system user. No home directory will be created. +- **no-log-init**: Boolean. Skip initialization of lastlog and faillog databases. + +The following fields are not yet implemented: + +- **inactive**: Deactivate the user upon creation +- **lock-passwd**: Boolean. Disable password login for user +- **sudo**: Entry to add to /etc/sudoers for user. By default, no sudo access is authorized. +- **selinux-user**: Corresponding SELinux user +- **ssh-import-id**: Import SSH keys by ID from Launchpad. + +##### Generating a password hash + +You can generate a safe hash via: + + mkpasswd --method=SHA-512 --rounds=4096 + +Using a higher number of rounds will help create more secure passwords, but given enough time, password hashes can be reversed. + ## Custom cloud-config Parameters ### coreos.etcd.discovery_url @@ -80,3 +114,18 @@ coreos: [Install] WantedBy=local.target ``` + +### Add a user + +``` +#cloud-config + +users: + - name: elroy + passwd: $6$5s2u6/jR$un0AvWnqilcgaNB3Mkxd5yYv6mTlWfOoCYHZmfi3LDKVltj.E8XNKEcwWm... + groups: + - staff + - docker + ssh-authorized-keys: + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0g+ZTxC7weoIJLUafOgrm+h... +``` diff --git a/cloudinit/cloud_config.go b/cloudinit/cloud_config.go index aa30aa9..aa36507 100644 --- a/cloudinit/cloud_config.go +++ b/cloudinit/cloud_config.go @@ -18,6 +18,7 @@ type CloudConfig struct { } WriteFiles []WriteFile `yaml:"write_files"` Hostname string + Users []User } func NewCloudConfig(contents []byte) (*CloudConfig, error) { @@ -46,8 +47,41 @@ func ApplyCloudConfig(cfg CloudConfig, sshKeyName string) error { log.Printf("Set hostname to %s", cfg.Hostname) } + if len(cfg.Users) > 0 { + for _, user := range cfg.Users { + if user.Name == "" { + log.Printf("User object has no 'name' field, skipping") + continue + } + + if UserExists(&user) { + log.Printf("User '%s' exists, ignoring creation-time fields", user.Name) + if user.PasswordHash != "" { + log.Printf("Setting '%s' user's password", user.Name) + if err := SetUserPassword(user.Name, user.PasswordHash); err != nil { + log.Printf("Failed setting '%s' user's password: %v", user.Name, err) + return err + } + } + } else { + log.Printf("Creating user '%s'", user.Name) + if err := CreateUser(&user); err != nil { + log.Printf("Failed creating user '%s': %v", user.Name, err) + return err + } + } + + if len(user.SSHAuthorizedKeys) > 0 { + log.Printf("Authorizing %d SSH keys for user '%s'", len(user.SSHAuthorizedKeys), user.Name) + if err := AuthorizeSSHKeys(user.Name, sshKeyName, user.SSHAuthorizedKeys); err != nil { + return err + } + } + } + } + if len(cfg.SSHAuthorizedKeys) > 0 { - err := AuthorizeSSHKeys(sshKeyName, cfg.SSHAuthorizedKeys) + err := AuthorizeSSHKeys("core", sshKeyName, cfg.SSHAuthorizedKeys) if err == nil { log.Printf("Authorized SSH keys for core user") } else { diff --git a/cloudinit/cloud_config_test.go b/cloudinit/cloud_config_test.go index 66b8af3..c7c540e 100644 --- a/cloudinit/cloud_config_test.go +++ b/cloudinit/cloud_config_test.go @@ -164,3 +164,89 @@ func TestCloudConfigSerializationHeader(t *testing.T) { t.Fatalf("Serialized config did not have expected header") } } + +func TestCloudConfigUsers(t *testing.T) { + contents := []byte(` +users: + - name: elroy + passwd: somehash + ssh-authorized-keys: + - somekey + gecos: arbitrary comment + homedir: /home/place + no-create-home: yes + primary-group: things + groups: + - ping + - pong + no-user-group: true + system: y + no-log-init: True +`) + cfg, err := NewCloudConfig(contents) + if err != nil { + t.Fatalf("Encountered unexpected error: %v", err) + } + + if len(cfg.Users) != 1 { + t.Fatalf("Parsed %d users, expected 1", cfg.Users) + } + + user := cfg.Users[0] + + if user.Name != "elroy" { + t.Errorf("User name is %q, expected 'elroy'", user.Name) + } + + if user.PasswordHash != "somehash" { + t.Errorf("User passwd is %q, expected 'somehash'", user.PasswordHash) + } + + if keys := user.SSHAuthorizedKeys; len(keys) != 1 { + t.Errorf("Parsed %d ssh keys, expected 1", len(keys)) + } else { + key := user.SSHAuthorizedKeys[0] + if key != "somekey" { + t.Errorf("User SSH key is %q, expected 'somekey'", key) + } + } + + if user.GECOS != "arbitrary comment" { + t.Errorf("Failed to parse gecos field, got %q", user.GECOS) + } + + if user.Homedir != "/home/place" { + t.Errorf("Failed to parse homedir field, got %q", user.Homedir) + } + + if !user.NoCreateHome { + t.Errorf("Failed to parse no-create-home field") + } + + if user.PrimaryGroup != "things"{ + t.Errorf("Failed to parse primary-group field, got %q", user.PrimaryGroup) + } + + if len(user.Groups) != 2 { + t.Errorf("Failed to parse 2 goups, got %d", len(user.Groups)) + } else { + if user.Groups[0] != "ping" { + t.Errorf("First group was %q, not expected value 'ping'", user.Groups[0]) + } + if user.Groups[1] != "pong" { + t.Errorf("First group was %q, not expected value 'pong'", user.Groups[1]) + } + } + + if !user.NoUserGroup { + t.Errorf("Failed to parse no-user-group field") + } + + if !user.System { + t.Errorf("Failed to parse system field") + } + + if !user.NoLogInit { + t.Errorf("Failed to parse no-log-init field") + } +} diff --git a/cloudinit/ssh_key.go b/cloudinit/ssh_key.go index 7a3c502..623ee9a 100644 --- a/cloudinit/ssh_key.go +++ b/cloudinit/ssh_key.go @@ -10,7 +10,7 @@ import ( // Add the provide SSH public key to the core user's list of // authorized keys -func AuthorizeSSHKeys(name string, keys []string) error { +func AuthorizeSSHKeys(user string, keysName string, keys []string) error { for i, key := range keys { keys[i] = strings.TrimSpace(key) } @@ -19,7 +19,7 @@ func AuthorizeSSHKeys(name string, keys []string) error { // also ends with a newline joined := fmt.Sprintf("%s\n", strings.Join(keys, "\n")) - cmd := exec.Command("update-ssh-keys", "-u", "core", "-a", name) + cmd := exec.Command("update-ssh-keys", "-u", user, "-a", keysName) stdin, err := cmd.StdinPipe() if err != nil { return err diff --git a/cloudinit/user.go b/cloudinit/user.go new file mode 100644 index 0000000..9d8a36f --- /dev/null +++ b/cloudinit/user.go @@ -0,0 +1,106 @@ +package cloudinit + +import ( + "fmt" + "log" + "os/exec" + "os/user" + "strings" +) + +type User struct { + Name string `yaml:"name"` + PasswordHash string `yaml:"passwd"` + SSHAuthorizedKeys []string `yaml:"ssh-authorized-keys"` + GECOS string `yaml:"gecos"` + Homedir string `yaml:"homedir"` + NoCreateHome bool `yaml:"no-create-home"` + PrimaryGroup string `yaml:"primary-group"` + Groups []string `yaml:"groups"` + NoUserGroup bool `yaml:"no-user-group"` + System bool `yaml:"system"` + NoLogInit bool `yaml:"no-log-init"` +} + +func UserExists(u *User) bool { + _, err := user.Lookup(u.Name) + return err == nil +} + +func CreateUser(u *User) error { + args := []string{} + + if u.PasswordHash != "" { + args = append(args, "--password", u.PasswordHash) + } + + if u.GECOS != "" { + args = append(args, "--comment", fmt.Sprintf("%q", u.GECOS)) + } + + if u.Homedir != "" { + args = append(args, "--home-dir", u.Homedir) + } + + if u.NoCreateHome { + args = append(args, "--no-create-home") + } else { + args = append(args, "--create-home") + } + + if u.PrimaryGroup != "" { + args = append(args, "--primary-group", u.PrimaryGroup) + } + + if len(u.Groups) > 0 { + args = append(args, "--groups", strings.Join(u.Groups, ",")) + } + + if u.NoUserGroup { + args = append(args, "--no-user-group") + } + + if u.System { + args = append(args, "--system") + } + + if u.NoLogInit { + args = append(args, "--no-log-init") + } + + args = append(args, u.Name) + + output, err := exec.Command("useradd", args...).CombinedOutput() + if err != nil { + log.Printf("Command 'useradd %s' failed: %v\n%s", strings.Join(args, " "), err, output) + } + return err +} + +func SetUserPassword(user, hash string) error { + cmd := exec.Command("/usr/sbin/chpasswd", "-e") + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + err = cmd.Start() + if err != nil { + log.Fatal(err) + } + + arg := fmt.Sprintf("%s:%s", user, hash) + _, err = stdin.Write([]byte(arg)) + if err != nil { + return err + } + stdin.Close() + + err = cmd.Wait() + if err != nil { + return err + } + + return nil +}