Puppet and Augeas

Photo by Riccardo Farinazzo on Unsplash

augtool is a program that can be obtained via the augeas package or as part of puppet.

dnf install augeas OR dnf install puppet

It’s a tool to programmatically edit linux configuration files safely.

Here are some cheatsheet examples :

augtool print /files/etc/ssh/sshd_config
augtool ls /files/etc/ssh/sshd_config
augtool preview /files/etc/ssh/sshd_config
augtool set /files/etc/ssh/sshd_config/PermitRootLogin yes

So for example, looking at the /etc/hosts file :

augtool print /files/etc/hosts | tail 
/files/etc/hosts/9
/files/etc/hosts/9/ipaddr = "192.168.1.145"
/files/etc/hosts/9/canonical = "sonic"
/files/etc/hosts/9/alias = "sonic.fabio.org.uk"
/files/etc/hosts/10
/files/etc/hosts/10/ipaddr = "192.168.1.34"
/files/etc/hosts/10/canonical = "foreman3"
/files/etc/hosts/10/alias[1] = "puppetmaster7"
/files/etc/hosts/10/alias[2] = "puppetmaster7.localdomain"

You could write and use an augeas script to add that last line:

#!/usr/bin/augtool -if
ins  /files/etc/hosts/*[last()+1] after /files/etc/hosts/*[last()]
set /files/etc/hosts/*[last()]/ipaddr 192.168.1.34
set /files/etc/hosts/*[last()]/canonical foreman3
set /files/etc/hosts/*[last()]/alias[1] puppetmaster7
set /files/etc/hosts/*[last()]/alias[2] puppetmaster7.localdomain
save
quit

And an augtool script to drop a line on the end of rsyslog.conf:

#!/usr/bin/augtool -if
set '/files/etc/rsyslog.conf/entry[last()+1]/selector/facility' authpriv 
set '/files/etc/rsyslog.conf/entry[last()]/selector/level' *
set '/files/etc/rsyslog.conf/entry[last()]/action/protocol' @
set '/files/etc/rsyslog.conf/entry[last()]/action/hostname' 192.168.1.32
save
quit

Achieving that using puppet here is the manifest:

augeas { "rsyslog":
  context => "/files/etc/rsyslog.conf",
  changes => [
    "set entry[last()+1]/selector/facility authpriv",
    "set entry[last()]/selector/level *",
    "set entry[last()]/action/protocol @",
    "set entry[last()]/action/hostname '192.168.1.32'",
  ],
  onlyif => "match entry[*]/action/hostname[. =  '192.168.1.32' ] size == 0" ,
  notify => Service["rsyslog"],
  } 
service { "rsyslog":
  require => Augeas["rsyslog"],
  enable  => true,
  ensure  => running,
}

You could also install bolt and run that on a remote host if you can ssh into them:

bolt apply rsyslog.pp --targets mgptmp -u user1 --password-prompt --sudo-password-prompt --run-as root
Please enter your password: 
Please enter your privilege escalation password: 
Starting: install puppet and gather facts on mgptmp
Finished: install puppet and gather facts with 0 failures in 3.81 sec
Starting: apply catalog on mgptmp
Started on mgptmp...
Finished on mgptmp:
  Notice: /Stage[main]/Main/Augeas[rsyslog]/returns: executed successfully
  Notice: /Stage[main]/Main/Service[rsyslog]: Triggered 'refresh' from 1 event
  changed: 2, failed: 0, unchanged: 0 skipped: 0, noop: 0
Finished: apply catalog with 0 failures in 5.12 sec
Successful on 1 target: mgptmp
Ran on 1 target in 8.98 sec

Here is configuring chrony. We are putting 4 server lines in, and removing the pool line.

#get rid of all the pool* lines
augeas { 'nopool':
  context => "/files/etc/chrony.conf",
  changes => [
    "rm pool[*]",
  ],
  onlyif => "match pool[*]  size != 0",
  notify => Service["chronyd"],
}

# add 4 server lines
# touch creates the iburst node with no value
augeas { "chronyconf":
  context => "/files/etc/chrony.conf",
  changes => [
    "touch server",
    "set server[1] ntp1.herts.ac.uk",
    "touch server[1]/iburst",
    "set server[2] ntp2.herts.ac.uk",
    "touch server[2]/iburst",
    "set server[3] ntp3.herts.ac.uk",
    "touch server[3]/iburst",
    "set server[4] ntp4.herts.ac.uk",
    "touch server[4]/iburst",
  ],
  onlyif => "match server[*][. =  'ntp*herts.ac.uk' ] size < 4",
  show_diff => true,
  notify => Service["chronyd"],
}

# to restart on change
service { "chronyd":
  require => Augeas["chronyconf","nopool"],
  enable  => true,
  ensure  => running,
}

And we can apply it locally or using bolt:

puppet apply chrony.pp
Notice: Compiled catalog for vuh-lb-foreman03.herts.ac.uk in environment production in 0.02 seconds
Notice: Augeas[nopool](provider=augeas): 
--- /etc/chrony.conf    2024-07-16 20:43:48.934393433 +0100
+++ /etc/chrony.conf.augnew     2024-07-16 20:43:55.008444093 +0100
@@ -1,7 +1,6 @@
 # Use public servers from the pool.ntp.org project.
 # Please consider joining the pool (https://www.pool.ntp.org/join.html).
 #
-pool ntp.rocky iburst
 
 # Use NTP servers from DHCP.
 sourcedir /run/chrony-dhcp

Notice: /Stage[main]/Main/Augeas[nopool]/returns: executed successfully
Notice: Augeas[chronyconf](provider=augeas): 
--- /etc/chrony.conf    2024-07-16 20:43:55.059444796 +0100
+++ /etc/chrony.conf.augnew     2024-07-16 20:43:55.450450186 +0100
@@ -48,3 +48,7 @@
 
 # Select which information is logged.
 #log measurements statistics tracking
+server ntp1.herts.ac.uk iburst
+server ntp2.herts.ac.uk iburst
+server ntp3.herts.ac.uk iburst
+server ntp4.herts.ac.uk iburst

Notice: /Stage[main]/Main/Augeas[chronyconf]/returns: executed successfully
Notice: /Stage[main]/Main/Service[chronyd]: Triggered 'refresh' from 2 event

Ok so now lets switch to using the augeas resource in puppet to add a complex group of stanzas in sshd_config . This is the stanza:

# my puppet anchor
PubkeyAuthentication no
PasswordAuthentication no
PermitRootLogin no
AllowGroups grafana programmers root 
Match Group "programmers,grafana,root" Address "193.101.30.0/23,193.101.32.0/23,193.101.108.149,193.101.52.0/24"
  PubkeyAuthentication yes
  PasswordAuthentication yes

How does augeas describe the format of that file. Use augtool to find out:

augtool print /files/etc/ssh/sshd_config | tail 
/files/etc/ssh/sshd_config/Match
/files/etc/ssh/sshd_config/Match/Condition
/files/etc/ssh/sshd_config/Match/Condition/Group = "programmers,grafana,root"
/files/etc/ssh/sshd_config/Match/Condition/Address = ""193.101.30.0/23,193.101.32.0/23,193.101.108.149,193.101.52.0/24"
/files/etc/ssh/sshd_config/Match/Settings
/files/etc/ssh/sshd_config/Match/Settings/PubkeyAuthentication = "yes"
/files/etc/ssh/sshd_config/Match/Settings/PasswordAuthentication = "yes"

Using a puppet manifest file (mymanifest.pp) first lets add an anchor comment in the file we can use to group our settings together.

augeas { "mycomment":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins #comment after #comment[last()]",
    "set #comment[last()] 'my puppet anchor'",
  ],
  onlyif => "match #comment[. = 'my puppet anchor'] size == 0",
  notify => Service["sshd"],
  } 
service { "sshd":
  require => Augeas["mycomment"],
  enable  => true,
  ensure  => running,
}

We apply a manifest file like this:

puppet apply mymanifest.pp 
Notice: Compiled catalog for puppetmaster in environment production in 0.09 seconds
Notice: /Stage[main]/Main/Augeas[mycomment]/returns: executed successfully
Notice: /Stage[main]/Main/Service[sshd]: Triggered 'refresh' from 1 event
Notice: Applied catalog in 0.75 seconds

Now I want to add three lines if they are not already in the file, below the anchor. Here is one of those:

# if there is no PermitRootLogin then insert it (else do nothing)
augeas { "permitrootlogin":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins PermitRootLogin after #comment[. = 'my puppet anchor']",
    "set PermitRootLogin no",
  ],
  onlyif => "match PermitRootLogin size == 0",
  notify => Service["sshd"],
  }

Ok here’s the whole shabang as its quite involved, maybe you learn by examples like me:

augeas { "mycomment":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins #comment after #comment[last()]",
    "set #comment[last()] 'my puppet anchor'",
  ],
  onlyif => "match #comment[. = 'my puppet anchor'] size == 0",
  notify => Service["sshd"],
  }

# if there is no PermitRootLogin then insert it (else do nothing)
augeas { "permitrootlogin":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins PermitRootLogin after #comment[. = 'my puppet anchor']",
    "set PermitRootLogin no",
  ],
  onlyif => "match PermitRootLogin size == 0",
  notify => Service["sshd"],
  }

# if there is no PasswordAuthentiction then insert it
augeas { "passwordauthentication":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins PasswordAuthentication after #comment[. = 'my puppet anchor']",
    "set PasswordAuthentication no",
  ],
  onlyif => "match PasswordAuthentication size == 0",
  notify => Service["sshd"],
  }

# if there is no PubkeyAuthentication then insert it
augeas { "pubkeyauthentication":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "ins PubkeyAuthentication after #comment[. = 'my puppet anchor']",
    "set PubkeyAuthentication no",
  ],
  onlyif => "match PubkeyAuthentication size == 0",
  notify => Service["sshd"],
  }

# puppet maintain global  PermitRootLogin , PasswordAuthentication, PubkeyAuthentication to "no"                                                                                  
# puppet maintain AllowGroups
# puppet maintain Match Stanza 1
augeas { "sshd_config":
  context => "/files/etc/ssh/sshd_config",
  changes => [
    "set PermitRootLogin no",
    "set PasswordAuthentication no",
    "set PubkeyAuthentication no",
    "set AllowGroups/1 grafana",
    "set AllowGroups/2 programmers",
    "set AllowGroups/3 root",
    "set Match[1]/Condition/Group programmers,grafana,root",
    "set Match[1]/Condition/Address 193.101.30.0/23,193.101.32.0/23,193.101.108.149,193.101.52.0/24",                                                                             
    "set Match[1]/Settings/PubkeyAuthentication yes",
    "set Match[1]/Settings/PasswordAuthentication yes",                                                                        
  ],
  notify => Service["sshd"],
}

service { "sshd":
  require => Augeas["sshd_config"],
  enable  => true,
  ensure  => running,
}

Hopefully there are enough breadcrumbs in that code to inspire or describe what is involved. It certainly seems like a lot of learning for such a simple result.