Sysadmin stories

Ramblings about my job and trade

Puppet: Embedding Data in Modules

How would you manage admin accounts on ten or so Puppet-enabled Linux hosts? Even if I had LDAP it would not be an option; basically the admins have to be able to log in even on a broken OS, and LDAP tends to be easier to break than /etc/passwd.

So, let’s use Puppet

It would be quite easy to create a module with just a bunch of user resources, but I wanted to avoid Puppet boilerplate as much as possible.

Fortunately, the stdlib module from PuppetLabs provides us with a couple of useful functions to load data from a YAML file located somewhere inside our module, namely get_module_path and loadyaml. So we can list our users in a YAML file and use these two functions in addition to create_resources to generate our user resources.

We should now be able to create a minimal working “users” module. The directory tree looks like this:

+- data/
|    admins.yaml
+- manifests/
     init.pp

The file init.pp is very short; it just needs to call the three functions I mentioned earlier with the right parameters. Here it is in full:

class users {
  $module_path = get_module_path($module_name)
  $admins = loadyaml("${module_path}/data/admins.yaml")
  create_resources(user, $admins)
}

Now we can add our user accounts in admins.yaml:

alice:
  gecos: Alice
  groups:
  - adm
  - sudo
  shell: /bin/bash
  password: $6$XXXXXXXXXXXXXXXXXXXXXXXXX
bob:
  gecos: Robert Tables
  groups:
  - adm
  shell: /bin/csh
  password: $6$YYYYYYYYYYYYYYYYYYYYYYYYYYYY

So we’re done? More or less. We now have our admin accounts, but we probably need to deploy their SSH public keys. On my real-life machines, sshd does not allow password authentication so accounts without SSH keys would be useless.

Adding SSH keys

The obvious way out would be to manage one SSH key for each user with Puppet and let the users themselves push any additional public keys they might want to use. This would be quite easy, mostly a duplicate of what we just did for user accounts.

In this case it would also be suboptimal. So let’s do better.

Basically we need a resource type which can manage a user account with any number of public keys. Each public key is defined by an encryption type (ssh-dss or ssh-rsa), a label (usually user@host but this is absolutely not mandatory) and the key itself. The obvious way to store our keys is in a hash, indexed by the key label (which is used by the Puppet type ssh_authorized_keys as its namevar):

ssh_keys['user@host'] = {
  type => 'ssh-dss' or 'ssh-rsa'
  key  => 'AAA.........'
}

We can now define a new type, which we will call users::sshuser and which will be a wrapper around the native user type with added support for the user’s SSH public keys.

While we’re at it, we will allow users to change their own passwords; basically we will set the user’s password only if it is not already set and ignore our password parameter otherwise. Finally we will set a couple of default properties (gid and shell) to match my local policy, and force the managehome parameter of the native user type to true.

Here is the code for our new defined type:

define users::sshuser (
  $ensure   = present,
  $uid      = undef,
  $gid      = 'users',
  $groups,
  $gecos    = '',
  $shell    = '/bin/bash',
  $password = '',
  $ssh_keys,
) {

  $username = $title

  user { $username:
    ensure     => $ensure,
    managehome => true,
    gid        => $gid,
    comment    => $gecos,
    shell      => $shell,
  }

  if $groups {
    User[$username] { groups => $groups, }
  }

  if $uid {
    User[$username] { uid => $uid, }
  }

  if $password and ($ensure == present) {
    exec { "/usr/sbin/usermod -p '$password' $username":
      path    => '/bin:/usr/bin',
      require => User[$username],
      unless  => "grep $username /etc/shadow | cut -f 2 -d : | grep -v '!'"
    }
  }

  if ($ensure == present) {
    validate_hash($ssh_keys)
    create_resources(ssh_authorized_key, $ssh_keys, {ensure => present, user => $username})
  }

}

Of course we have to modify our main users class:

class users {
  $module_path = get_module_path($module_name)
  $admins = loadyaml("${module_path}/data/admins.yaml")
  create_resources(users::sshuser, $admins)
}

And of course add our SSH keys to our YAML file:

alice:
  gecos: Alice
  groups:
  - adm
  - sudo
  shell: /bin/bash
  password: $6$XXXXXXXXXXXXXXXXXXXXXXXXX
  ssh_keys:
    alice@example.com:
      type: ssh-rsa
      key: AAAA...............
bob:
  gecos: Robert Tables
  groups:
  - adm
  shell: /bin/csh
  password: $6$YYYYYYYYYYYYYYYYYYYYYYYYYYYY
  ssh_keys:
    bob@example.net:
      type: ssh-rsa
      key: AAAA...............
    btables@myhost:
      type: ssh-dss
      key: AAAA..........

Now, we’re done. Adding a user or an SSH key just requires editing the YAML file and the relevant changes will be applied on every host on the next Puppet run, and we didn’t hardcode anything in the Puppet code itself (well, as a matter of fact, we did hardcode a few policy points, but we could easily have made them into configuration parameters).

We can even remove a user’s account or a public key by setting ensure: absent in the right place in the YAML file. To be honest, while this was a “real” feature for user accounts (I planned to disable accounts from the start), for SSH public keys this is mostly an unintended side-effect of the way create_resource works.

Further ideas

I happen to like the solution I described in this article because the data is kept in the module itself. I can think of a couple of alternatives for using another canonical source instead.

The point is moot in this case but I can think of another instance where I had an LDAP directory to get my user data from, even though I still wanted local users for various reasons (including the fact that some hosts are not allowed to talk to the directory servers). In this case I wrote another small module around create_resources and I used my ENC to get user data from my LDAP directory and feed it to this module.

We could also use a custom Hiera backend rather than a static file or an ENC. I must confess I didn’t look in this direction, mostly because Hiera wasn’t yet a given when I began thinking of this pattern. I guess Hiera would be a cleaner solution than a custom ENC, at least conceptually, provided you’re fluent enough in Ruby; I’m not. ;–) And the data would no longer be embedded in the module, which is a design decision I intend to keep for the module I’m talking about here.