feat(users): Add support for creating/modifying users
This commit is contained in:
		| @@ -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/<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 | ||||
|  | ||||
| ### 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... | ||||
| ``` | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										106
									
								
								cloudinit/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								cloudinit/user.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user