Skip to main content

Let's Encrypt the Puppet Enterprise console

I've recently been working on brushing my puppet skills to keep them fresh; to get started I installed a Puppet Enterprise trial and began started with the latest version; I installed Puppet Enterprise on an fresh Ubuntu 16.04 AWS server and gave it a nice DNS name.

For my first challenge I decided to finish a task that had eluded me last year: apply an SSL certificate to the Puppet Enterprise console without using the console's configuration functions. I'm a big believer in infrastructure as code even for simple one time configurations. Furthermore, an SSL certificate will help keep the information in transit with the console encrypted and provides a better user experience in the browser.

The official instructions how to do this from the documentation are a series of manual steps that have you entering the configuration from the console. To save time and money I decided to use the Let's Encrypt certificate authority to get an auto generated password. I downloaded the Puppet Development Kit and created a Bitbucket Server repository for the puppet_control code. Finally I installed librarian-puppet to bring in the puppet modules I needed.

First I decided to implement the SSL certificate; there's already a puppet module for Let's Encrypt. I updated the Puppetfile with the module and its dependencies like so:
# Puppet-Control Modules
mod 'puppetlabs-inifile', '1.6.0' # https://forge.puppet.com/puppetlabs/inifile
mod 'puppetlabs-stdlib', '4.20.0' # https://forge.puppet.com/puppetlabs/stdlib
mod 'puppetlabs-vcsrepo', '1.5.0' # https://forge.puppet.com/puppetlabs/vcsrepo
mod 'stahnma-epel', '1.2.2' # https://forge.puppet.com/stahnma/epel
mod 'puppet-letsencrypt', '2.0.1' # https://forge.puppet.com/puppet/letsencrypt

Next I used the puppet-letsencrypt module to generate the SSL certificate. Puppet Enterprise places an nginx proxy in front of the console, so it seemed simple enough to apply the certificate. Let's go ahead and modify manifests/site.pp.
  class { ::letsencrypt:
    email => 'projects@onedot.com.au',
  }

  package { 'python-letsencrypt-nginx':
    ensure => present',
  }

  letsencrypt::certonly { 'puppet.graypockets.com':
    domains              => ['puppet.graypockets.com'],
    manage_cron          => true,
    plugin               => 'nginx',
    require              => [
      Package['python-letsencrypt-nginx'],
    ],
    suppress_cron_output => true,
  }
Whoops! Unlike the apache package, the nginx package isn't available on Xenial! Next I used the puppet-letsencrypt module to generate the SSL certificate. Puppet Enterprise places an nginx proxy in front of the console, so it seemed simple enough to apply the certificate. Let's roll up our sleeves!

So we can use the webroot package default, and have Let's Encrypt drop a file on the nginx server. Not so fast! The console's nginx redirects http to https, and Let's Encrypt only allows webroot access over port 80. To further confuse the situation, the puppet agent running on the puppet master has the nginx files under configuration management, loaded from the installation puppet files.

So what to do? The solution will have to modify the nginx configuration in a way that won't break the puppet agent enforcement, it will need to utilize nginx's port 80, and it will need to use Let's Encrypt's webroot plugin.

Digging into the Puppet Enterprise files, it appears the puppet code responsible lies here: /opt/puppetlabs/puppet/modules/puppet_enterprise/manifests/profile/console/proxy/http_redirect.pp

  pe_nginx::directive { 'http_redirect-return':
    directive_ensure => 'present',
    directive_name   => 'return',
    value            => 'https://$server_name$request_uri',
  }
So how can I use code in /opt/puppetlabs/etc/code to interact with this Puppet Enterprise file in /opt/puppetlabs/puppet/modules/puppet_enterprise? With a puppet script of course! First we create a folder for nginx and Let's Encrypt to share. Then we set the original nginx redirect to "absent" to make it go away, then we replace it with our own nginx redirect directives that allows Let's Encrypt access to some webroot files (as an exception).
  file { '/var/www':
    ensure  => directory,
  }  

  file { '/var/www/html':
    ensure  => directory,
    require => File['/var/www'],
  }

  file_line { 'original_redirect':
    ensure             => present,
    line               => '    directive_ensure => \'absent\',',
    after              => '^  pe_nginx::directive { \'http_redirect-return\':$',
    multiple           => false,
    path               => '/opt/puppetlabs/puppet/modules/puppet_enterprise/manifests/profile/console/proxy/http_redirect.pp',
  }

  pe_nginx::directive { 'add_location_return':
    directive_ensure => 'present',
    directive_name   => 'return',
    location_context => '/',
    require          => File_line['original_redirect'],
    server_context   => 'puppet.graypockets.com',
    target           => '/etc/puppetlabs/nginx/conf.d/http_redirect.conf',
    value            => '301 https://$server_name$request_uri',
  }

  pe_nginx::directive { 'root':
    directive_ensure => 'present',
    location_context => '/.well-known',
    require          => File_line['original_redirect'],
    server_context   => 'puppet.graypockets.com',
    target           => '/etc/puppetlabs/nginx/conf.d/http_redirect.conf',
    value            => '/var/www/html',
  }
The bad news? This will take 2 puppet agent runs to resolve. The first run will modify /opt/puppetlabs/puppet/modules/puppet_enterprise/manifests/profile/console/proxy/http_redirect.pp, the second run will apply that modified Puppet code to remove the original redirect directive. If anyone can think of a way around this, I'd be happy to have a solution appear in the comments!

Next we modify our letsencrypt::certonly directive to use the webroot plugin. To follow the Puppet Enterprise standard, we'll move the generated SSL certificates into appropriate puppet folder.


  letsencrypt::certonly { 'puppet.graypockets.com':
    domains              => ['puppet.graypockets.com'],
    manage_cron          => true,
    plugin               => 'webroot',
    require              => [
      File['/var/www/html'],
      Pe_nginx::Directive['add_location_return'],
      Pe_nginx::Directive['root'],
      Pe_nginx::Directive['http_redirect-return'],
    ],
    suppress_cron_output => true,
    webroot_paths        => ['/var/www/html'],
  }

  file { '/opt/puppetlabs/server/data/console-services/certs/public-console.cert.pem':
    ensure  => present,
    links   => 'follow',
    group   => 'pe-console-services',
    owner   => 'pe-console-services',
    require => Letsencrypt::Certonly['puppet.graypockets.com'],
    source  => '/etc/letsencrypt/live/puppet.graypockets.com/fullchain.pem',
  }

  file { '/opt/puppetlabs/server/data/console-services/certs/public-console.private_key.pem':
    ensure  => present,
    links   => 'follow',
    group   => 'pe-console-services',
    owner   => 'pe-console-services',
    require => Letsencrypt::Certonly['puppet.graypockets.com'],
    source  => '/etc/letsencrypt/live/puppet.graypockets.com/privkey.pem',
  }
Be sure to note the links parameter; without it, the symlinks Let's Encrypt uses screws up the copied files. 

Finally, how to apply the settings I was originally so obsessed with? Well after some experimentation, it turns out we can simply make a few hiera entries to have the same effect, so let's modify data/nodes/puppet.graypockets.com.yaml.

puppet_enterprise::profile::console::browser_ssl_cert: /opt/puppetlabs/server/data/console-services/certs/public-console.cert.pem
puppet_enterprise::profile::console::browser_ssl_private_key: /opt/puppetlabs/server/data/console-services/certs/public-console.private_key.pem

The end result is a Puppet Enterprise console protected by https all via code. That is deeply satisfying to me. 

Appendix


manifests/sites.pp
node default {
  # This is where you can declare classes for all nodes.
  # Example:
  #   class { 'my_class': }
}

node puppet.graypockets.com {
  file { '/var/www':
    ensure  => directory,
  }  

  file { '/var/www/html':
    ensure  => directory,
    require => File['/var/www'],
  }

  file_line { 'original_redirect':
    ensure             => present,
    line               => '    directive_ensure => \'absent\',',
    after              => '^  pe_nginx::directive { \'http_redirect-return\':$',
    multiple           => false,
    path               => '/opt/puppetlabs/puppet/modules/puppet_enterprise/manifests/profile/console/proxy/http_redirect.pp',
  }

  pe_nginx::directive { 'add_location_return':
    directive_ensure => 'present',
    directive_name   => 'return',
    location_context => '/',
    require          => File_line['original_redirect'],
    server_context   => 'puppet.graypockets.com',
    target           => '/etc/puppetlabs/nginx/conf.d/http_redirect.conf',
    value            => '301 https://$server_name$request_uri',
  }

  pe_nginx::directive { 'root':
    directive_ensure => 'present',
    location_context => '/.well-known',
    require          => File_line['original_redirect'],
    server_context   => 'puppet.graypockets.com',
    target           => '/etc/puppetlabs/nginx/conf.d/http_redirect.conf',
    value            => '/var/www/html',
  }

  class { ::letsencrypt:
    email => 'projects@onedot.com.au',
  }

  letsencrypt::certonly { 'puppet.graypockets.com':
    domains              => ['puppet.graypockets.com'],
    manage_cron          => true,
    plugin               => 'webroot',
    require              => [
      File['/var/www/html'],
      Pe_nginx::Directive['add_location_return'],
      Pe_nginx::Directive['root'],
      Pe_nginx::Directive['http_redirect-return'],
    ],
    suppress_cron_output => true,
    webroot_paths        => ['/var/www/html'],
  }

  file { '/opt/puppetlabs/server/data/console-services/certs/public-console.cert.pem':
    ensure  => present,
    links   => 'follow',
    group   => 'pe-console-services',
    owner   => 'pe-console-services',
    require => Letsencrypt::Certonly['puppet.graypockets.com'],
    source  => '/etc/letsencrypt/live/puppet.graypockets.com/fullchain.pem',
  }

  file { '/opt/puppetlabs/server/data/console-services/certs/public-console.private_key.pem':
    ensure  => present,
    links   => 'follow',
    group   => 'pe-console-services',
    owner   => 'pe-console-services',
    require => Letsencrypt::Certonly['puppet.graypockets.com'],
    source  => '/etc/letsencrypt/live/puppet.graypockets.com/privkey.pem',
  }
}


data/nodes/puppet.graypockets.com.yaml

puppet_enterprise::profile::console::browser_ssl_cert: /opt/puppetlabs/server/data/console-services/certs/public-console.cert.pem
puppet_enterprise::profile::console::browser_ssl_private_key: /opt/puppetlabs/server/data/console-services/certs/public-console.private_key.pem

Comments