Merge pull request #13 from bcwaldon/users

Implement users cloud-config directive
This commit is contained in:
Brian Waldon 2014-03-14 10:34:15 -07:00
commit 587ecbf605
5 changed files with 278 additions and 3 deletions

View File

@ -13,6 +13,40 @@ Provided public SSH keys will be authorized for the `core` user.
The keys will be named "coreos-cloudinit" by default. The keys will be named "coreos-cloudinit" by default.
Override this with the `--ssh-key-name` flag when calling `coreos-cloudinit`. 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/<name>
- **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 ## Custom cloud-config Parameters
### coreos.etcd.discovery_url ### coreos.etcd.discovery_url
@ -80,3 +114,18 @@ coreos:
[Install] [Install]
WantedBy=local.target 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...
```

View File

@ -18,6 +18,7 @@ type CloudConfig struct {
} }
WriteFiles []WriteFile `yaml:"write_files"` WriteFiles []WriteFile `yaml:"write_files"`
Hostname string Hostname string
Users []User
} }
func NewCloudConfig(contents []byte) (*CloudConfig, error) { 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) 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 { if len(cfg.SSHAuthorizedKeys) > 0 {
err := AuthorizeSSHKeys(sshKeyName, cfg.SSHAuthorizedKeys) err := AuthorizeSSHKeys("core", sshKeyName, cfg.SSHAuthorizedKeys)
if err == nil { if err == nil {
log.Printf("Authorized SSH keys for core user") log.Printf("Authorized SSH keys for core user")
} else { } else {

View File

@ -164,3 +164,89 @@ func TestCloudConfigSerializationHeader(t *testing.T) {
t.Fatalf("Serialized config did not have expected header") 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")
}
}

View File

@ -10,7 +10,7 @@ import (
// Add the provide SSH public key to the core user's list of // Add the provide SSH public key to the core user's list of
// authorized keys // authorized keys
func AuthorizeSSHKeys(name string, keys []string) error { func AuthorizeSSHKeys(user string, keysName string, keys []string) error {
for i, key := range keys { for i, key := range keys {
keys[i] = strings.TrimSpace(key) keys[i] = strings.TrimSpace(key)
} }
@ -19,7 +19,7 @@ func AuthorizeSSHKeys(name string, keys []string) error {
// also ends with a newline // also ends with a newline
joined := fmt.Sprintf("%s\n", strings.Join(keys, "\n")) 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() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
return err return err

106
cloudinit/user.go Normal file
View File

@ -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
}