Compare commits

...

11 Commits

Author SHA1 Message Date
unkin-agent c97c7d3a97 Merge remote-tracking branch 'origin/develop' into benvin/halb-drop-grafana
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
# Conflicts:
#	hieradata/country/au/region/syd1/infra/halb/haproxy2.yaml
2026-10-05 01:36:16 +11:00
unkin-agent 18fd966f92 halb: stop publishing haproxy2 site CNAMEs (#542)
The haproxy2 VMs are being decommissioned and the site names they front move to bind-internal DNSRecord CRs in argocd-apps. dns-updater on these hosts deletes records that leave its records file, so the CNAMEs drop once this applies.

- empty profiles::haproxy::dns::vrrp_cnames for haproxy2
- empty profiles::haproxy::dns::cnames for haproxy2

Reviewed-on: #542
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-05 01:31:54 +11:00
unkin-agent 98032ae209 Drop grafana.unkin.net from certbot issuer domains
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
2026-10-05 00:34:34 +11:00
unkin-agent 069da56856 Drop grafana.unkin.net from halb haproxy
ci/woodpecker/pr/ruby-validate Pipeline was successful
ci/woodpecker/pr/puppet-lint Pipeline was successful
ci/woodpecker/pr/bolt-validate Pipeline was successful
ci/woodpecker/pr/yamllint Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/erb-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was canceled
ci/woodpecker/pr/puppet-validate Pipeline was canceled
2026-10-05 00:33:09 +11:00
unkin-agent b74bced771 Persist rp_filter=0 on every router interface (#539)
Effective rp_filter is max(all, <iface>). Setting `all`/`default` to 0 is not enough after a reboot: `/usr/lib/sysctl.d/50-redhat.conf` sets `net.ipv4.conf.*.rp_filter = 1`, which udev applies to every interface as it is created, so routed asymmetric traffic gets dropped once shorewall goes.

- add `net.ipv4.conf.*.rp_filter: 0` to the router role, overriding the vendor glob
- disable `enforce` for it, since `sysctl -n` cannot read glob keys

Reviewed-on: #539
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-04 16:02:00 +11:00
unkin-agent 0cfe598f90 Install certmanager and sshsignhost from RPM (#524)
certmanager and sshsignhost are now Go binaries released as RPMs, and their packaged paths collide with the venv install these helper classes manage.

- Drop the pyvenv, pip, rendered script and /usr/local/bin symlink resources
- Delete the now-unused Python script templates
- Keep rendering /opt/<tool>/config.yaml, unchanged ownership and mode
- Nest certmanager's output_path under vault:, where the binary reads it
- Drop output_path from sshsignhost's config; the binary has no such key
- Pin certmanager to 0.2.0 and sshsignhost to 0.1.0 on the puppet master role
- Point sshsignhost at the sshca mount and signhost role, documented in doc/vault

Reviewed-on: #524
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-04 15:07:54 +11:00
unkin-agent 0272104504 Add wireguard module (#538)
WireGuard on the router is configured by hand, so its tunnels are not reproducible from code. This adds a module to manage it from hieradata.

- add `wireguard` class to install wireguard-tools and manage interfaces from a hash
- add `wireguard::interface` to render `/etc/wireguard/<iface>.conf` (0600) and enable `wg-quick@<iface>`
- keep private and preshared keys `Sensitive` end to end (`wireguard::interfaces` lookup_options `convert_to: Sensitive`, typed peer Struct)
- without `private_key`, generate `/etc/wireguard/<iface>.key` (0600) only if absent and load it via PostUp, so the key never rotates
- apply config changes with `wg syncconf` instead of restarting the tunnel

Reviewed-on: #538
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-04 14:17:07 +11:00
unkin-agent 1ed268acda Add dnsmasq module (#537)
The router runs dnsmasq as a DNS forwarder and DHCP relay from hand-edited config, so its state is not reproducible from code.

- add `dnsmasq` module installing the package, rendering `/etc/dnsmasq.conf` from class params and running the service
- cover listen addresses/interfaces, bind mode, upstream servers, no-resolv, cache-size, domain-needed, bogus-priv, per-domain forwards and dhcp-relay
- add raw `options` lines for anything else
- add `purge_config_dir` (default off) to remove unmanaged `/etc/dnsmasq.d` files

Reviewed-on: #537
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-03 22:40:46 +10:00
unkin-agent 47e3bdc8f5 Add router role for prodnxsr0020 (#536)
prodnxsr0020 runs FRR/OSPF hand-configured; bring its routing config under puppet without touching interfaces, firewall or dnsmasq.

- add roles::infra::network::router (base + frrouting + frr_exporter)
- enable ip_forward and disable rp_filter via sysctl::base
- add prodnxsr0020 OSPF config (dum0, dum1, bond0.201; src 198.18.21.160)
- pin dns, consul and router-id to dum0 instead of the WAN-facing primary IP
- listen sshd on 127.0.0.1 and dum0 only, knocking out the common WAN primary IP
- keep resolv.conf on the local dnsmasq (127.0.0.1)

Reviewed-on: #536
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-03 22:40:01 +10:00
unkin-agent 410a1f13d0 Add 198.18.2.0/24 router loopback subnet (#535)
Router loopbacks in 198.18.2.0/24 (e.g. prodnxsr0020, 198.18.2.160) match no subnet entry, so they get unknown environment/region/country facts and are not autosigned.

- add 198.18.2.0/24 to subnet_facts as prod/syd1/au/common
- add 198.18.2.0/24 to puppet master autosign subnet_ranges

Reviewed-on: #535
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-03 20:46:44 +10:00
unkin-agent ec74484d89 Pin the journald ingest URL to port 443 (#533)
Estate journald ingestion has been down since 00:33Z. `systemd-journal-upload` appends `:19532/upload` to the configured URL whenever that URL carries no explicit port, so the portless k8s endpoint became `/insert/journald:19532/upload`, which vlinsert rejects as an unsupported path. The previous consul URL only worked because `:9428` was explicit.

- pin `victorialogs::client::journald::inserturl` to port 443

Requests then land on `/insert/journald/upload`, which vlinsert accepts.

Reviewed-on: #533
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-27 11:22:07 +10:00
20 changed files with 366 additions and 375 deletions
+8 -31
View File
@@ -88,36 +88,13 @@
# SSH Hostkey Signing
## create ssh engine, key, set ttl
vault secrets enable -path=ssh-host-signer ssh
vault write ssh-host-signer/config/ca generate_signing_key=true
vault secrets tune -max-lease-ttl=87600h ssh-host-signer
The `sshca` ssh engine, its `signhost` role, the `sshca/sign/signhost` policy and
the `sshsigner` approle are managed in terraform-vault:
## create role
vault write ssh-host-signer/roles/hostrole \
key_type=ca \
algorithm_signer=rsa-sha2-256 \
ttl=87600h \
allow_host_certificates=true \
allowed_domains="unkin.net" \
allow_subdomains=true \
allow_baredomains=true
- `config/ssh_secret_backend/sshca.yaml`
- `config/ssh_secret_backend_role/sshca/signhost.yaml`
- `config/auth_approle_role/approle/sshsigner.yaml`
- `policies/sshca/sign/signhost.yaml`
## create policy to use hostrole
cat <<EOF > sshsign-host.hcl
path "ssh-host-signer/sign/hostrole" {
capabilities = ["create", "update"]
}
EOF
vault policy write sshsign-host-policy sshsign-host.hcl
vault write auth/approle/role/sshsign-host-role \
bind_secret_id=false \
token_policies="sshsign-host-policy" \
token_ttl=30s \
token_max_ttl=30s \
token_bound_cidrs="198.18.17.3/32,198.18.13.32/32,198.18.13.33/32,198.18.13.34/32"
## get the sshsign-host-role approle id
vault read -field=role_id auth/approle/role/sshsign-host-role/role-id
## get the sshsigner approle id
vault read -field=role_id auth/approle/role/sshsigner/role-id
+3 -1
View File
@@ -178,6 +178,8 @@ lookup_options:
convert_to: Sensitive
stalwart::fallback_admin_password:
convert_to: Sensitive
wireguard::interfaces:
convert_to: Sensitive
facts_path: '/opt/puppetlabs/facter/facts.d'
@@ -401,7 +403,7 @@ networking::route_defaults:
# logging:
victorialogs::client::journald::enable: true
victorialogs::client::journald::inserturl: https://logs-ingest.k8s.syd1.au.unkin.net/insert/journald
victorialogs::client::journald::inserturl: https://logs-ingest.k8s.syd1.au.unkin.net:443/insert/journald
# FIXME these are for the proxmox ceph cluster
profiles::ceph::client::fsid: 7f7f00cb-95de-498c-8dcc-14b54e4e9ca8
@@ -3,23 +3,7 @@ haproxy_server_k8s_syd1_traefik_internal: 'k8s-traefik-internal 198.18.200.4:443
haproxy_server_k8s_syd1_traefik_external: 'k8s-traefik-external 198.18.199.0:443 ssl verify none check inter 2s rise 3 fall 2'
profiles::haproxy::dns::ipaddr: "%{hiera('anycast_ip')}"
profiles::haproxy::dns::vrrp_cnames:
- sonarr.main.unkin.net
- radarr.main.unkin.net
- lidarr.main.unkin.net
- readarr.main.unkin.net
- prowlarr.main.unkin.net
- nzbget.main.unkin.net
- git.unkin.net
- fafflix.unkin.net
- grafana.unkin.net
- dashboard.ceph.unkin.net
- mail-webadmin.main.unkin.net
- mail-in.main.unkin.net
- mail.main.unkin.net
- autoconfig.main.unkin.net
- autodiscover.main.unkin.net
- auth.unkin.net
profiles::haproxy::dns::vrrp_cnames: []
profiles::haproxy::mappings:
fe_http:
@@ -36,7 +20,6 @@ profiles::haproxy::mappings:
- 'jellyfin.main.unkin.net be_jellyfin'
- 'fafflix.unkin.net be_jellyfin'
- 'git.unkin.net be_gitea'
- 'grafana.unkin.net be_grafana'
- 'dashboard.ceph.unkin.net be_ceph_dashboard'
- 'mail-webadmin.main.unkin.net be_stalwart_webadmin'
- 'autoconfig.main.unkin.net be_stalwart_webadmin'
@@ -56,7 +39,6 @@ profiles::haproxy::mappings:
- 'jellyfin.main.unkin.net be_jellyfin'
- 'fafflix.unkin.net be_jellyfin'
- 'git.unkin.net be_gitea'
- 'grafana.unkin.net be_grafana'
- 'dashboard.ceph.unkin.net be_ceph_dashboard'
- 'mail-webadmin.main.unkin.net be_stalwart_webadmin'
- 'autoconfig.main.unkin.net be_stalwart_webadmin'
@@ -81,7 +63,6 @@ profiles::haproxy::frontends:
- 'acl_jellyfin req.hdr(host) -i jellyfin.main.unkin.net'
- 'acl_fafflix req.hdr(host) -i fafflix.unkin.net'
- 'acl_gitea req.hdr(host) -i git.unkin.net'
- 'acl_grafana req.hdr(host) -i grafana.unkin.net'
- 'acl_ceph_dashboard req.hdr(host) -i dashboard.ceph.unkin.net'
- 'acl_stalwart_webadmin req.hdr(host) -i mail-webadmin.main.unkin.net'
- 'acl_stalwart_webadmin req.hdr(host) -i autoconfig.main.unkin.net'
@@ -103,7 +84,6 @@ profiles::haproxy::frontends:
- 'set-header X-Frame-Options DENY if acl_jellyfin'
- 'set-header X-Frame-Options DENY if acl_fafflix'
- 'set-header X-Frame-Options DENY if acl_gitea'
- 'set-header X-Frame-Options DENY if acl_grafana'
- 'set-header X-Frame-Options DENY if acl_ceph_dashboard'
- 'set-header X-Frame-Options DENY if acl_stalwart_webadmin'
- 'set-header X-Frame-Options DENY if acl_kanidm'
@@ -419,7 +399,6 @@ profiles::haproxy::certlist::certificates:
- /etc/pki/tls/letsencrypt/nzbget.main.unkin.net/fullchain_combined.pem
- /etc/pki/tls/letsencrypt/fafflix.unkin.net/fullchain_combined.pem
- /etc/pki/tls/letsencrypt/git.unkin.net/fullchain_combined.pem
- /etc/pki/tls/letsencrypt/grafana.unkin.net/fullchain_combined.pem
- /etc/pki/tls/letsencrypt/dashboard.ceph.unkin.net/fullchain_combined.pem
- /etc/pki/tls/letsencrypt/auth.unkin.net/fullchain_combined.pem
- /etc/pki/tls/vault/certificate.pem
@@ -432,9 +411,7 @@ profiles::pki::vault::alt_names:
- mail-webadmin.main.unkin.net
# additional cnames
profiles::haproxy::dns::cnames:
- au-syd1-pve.main.unkin.net
- au-syd1-pve-api.main.unkin.net
profiles::haproxy::dns::cnames: []
# letsencrypt certificates
certbot::client::service: haproxy
@@ -449,6 +426,5 @@ certbot::client::domains:
- nzbget.main.unkin.net
- fafflix.unkin.net
- git.unkin.net
- grafana.unkin.net
- dashboard.ceph.unkin.net
- auth.unkin.net
@@ -0,0 +1,45 @@
---
# primary interface is the WAN uplink; pin host identity to the dum0 loopback
networking_loopback0_ip: 198.18.2.160
networking_loopback1_ip: 198.18.21.160
# dns: keep the local dnsmasq resolver
profiles::dns::base::nameservers:
- 127.0.0.1
profiles::dns::base::search:
- main.unkin.net
profiles::dns::base::primary_interface: dum0
profiles::dns::updater::deny_ranges:
- 198.18.199.0/24
- 198.18.200.0/24
- 10.42.0.0/16
- 10.43.0.0/16
- 10.10.12.0/24 # wg0
- 103.216.190.0/23 # wan uplink
profiles::consul::client::host_addr: "%{hiera('networking_loopback0_ip')}"
# ssh: listen on localhost and dum0 only; knock out the common wan primary ip
lookup_options:
ssh::server::options:
merge:
strategy: deep
knockout_prefix: '--'
ssh::server::options:
ListenAddress:
- "--%{facts.networking.ip}"
- 127.0.0.1
- "%{hiera('networking_loopback0_ip')}"
profiles::ssh::sign::principals:
- "%{hiera('networking_loopback0_ip')}"
# frrouting
frrouting::ospfd_router_id: "%{hiera('networking_loopback0_ip')}"
frrouting::ospfd_interfaces:
dum0:
area: 0.0.0.0
dum1:
area: 0.0.0.0
bond0.201:
area: 0.0.0.0
frrouting::ospf_preferred_source_enable: true
frrouting::ospf_preferred_source: "%{hiera('networking_loopback1_ip')}"
+30
View File
@@ -0,0 +1,30 @@
---
hiera_include:
- frrouting
- exporters::frr_exporter
# routing
sysctl::base::values:
net.ipv4.ip_forward:
value: '1'
net.ipv4.conf.all.rp_filter:
value: '0'
net.ipv4.conf.default.rp_filter:
value: '0'
# overrides 50-redhat.conf's per-interface rp_filter=1 (applied by udev on link add); sysctl -n can't glob, so no enforce
net.ipv4.conf.*.rp_filter:
value: '0'
enforce: false
# frrouting
exporters::frr_exporter::enable: true
frrouting::ospfd_redistribute:
- connected
frrouting::daemons:
ospfd: true
# consul
profiles::consul::client::node_rules:
- resource: service
segment: frr_exporter
disposition: write
-1
View File
@@ -14,6 +14,5 @@ certbot::domains:
- nzbget.main.unkin.net
- fafflix.unkin.net
- git.unkin.net
- grafana.unkin.net
- dashboard.ceph.unkin.net
- auth.unkin.net
+7 -3
View File
@@ -1,5 +1,6 @@
---
profiles::puppet::autosign::subnet_ranges:
- '198.18.2.0/24'
- '198.18.13.0/24'
- '198.18.14.0/24'
- '198.18.15.0/24'
@@ -31,6 +32,10 @@ profiles::puppet::enc::repo: https://git.service.au-syd1.consul/unkinben/puppet-
profiles::packages::include:
encapic:
ensure: '0.2.0'
certmanager:
ensure: '0.2.0'
sshsignhost:
ensure: '0.1.0'
profiles::puppet::encapic::encapi_url: https://encapi.k8s.syd1.au.unkin.net
profiles::puppet::server::external_nodes: '/usr/bin/encapic-enc'
@@ -56,10 +61,9 @@ profiles::helpers::certmanager::vault_config:
profiles::helpers::sshsignhost::vault_config:
addr: 'https://vault.service.consul:8200'
mount_point: 'ssh-host-signer'
mount_point: 'sshca'
approle_path: 'approle'
role_name: 'hostrole'
output_path: '/tmp/sshsignhost'
role_name: 'signhost'
role_id: "%{lookup('sshsignhost::role_id')}"
profiles::puppet::server::agent_server: 'puppet.query.consul'
+51
View File
@@ -0,0 +1,51 @@
# manage dnsmasq as a dns forwarder and dhcp relay
class dnsmasq (
Boolean $manage_package = true,
Boolean $manage_service = true,
String $package_name = 'dnsmasq',
String $service_name = 'dnsmasq',
Stdlib::Absolutepath $config_file = '/etc/dnsmasq.conf',
Stdlib::Absolutepath $config_dir = '/etc/dnsmasq.d',
Boolean $purge_config_dir = false,
Array[String] $interfaces = [],
Array[Stdlib::IP::Address] $listen_addresses = ['127.0.0.1'],
Enum['bind-interfaces', 'bind-dynamic', 'none'] $bind_mode = 'bind-interfaces',
Boolean $no_resolv = false,
Array[String] $servers = [],
Hash[String, Array[String]] $forwards = {},
Optional[Integer[0]] $cache_size = undef,
Boolean $domain_needed = true,
Boolean $bogus_priv = true,
Array[String] $dhcp_relays = [],
Array[String] $options = [],
) {
if $manage_package {
package { $package_name:
ensure => installed,
before => File[$config_file, $config_dir],
}
}
file { $config_dir:
ensure => directory,
recurse => $purge_config_dir,
purge => $purge_config_dir,
}
file { $config_file:
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => template('dnsmasq/dnsmasq.conf.erb'),
}
if $manage_service {
service { $service_name:
ensure => running,
enable => true,
subscribe => File[$config_file, $config_dir],
}
}
}
@@ -0,0 +1,40 @@
# THIS FILE IS MANAGED BY PUPPET
user=dnsmasq
group=dnsmasq
conf-dir=<%= @config_dir %>,.rpmnew,.rpmsave,.rpmorig
<% @interfaces.each do |iface| -%>
interface=<%= iface %>
<% end -%>
<% unless @listen_addresses.empty? -%>
listen-address=<%= @listen_addresses.join(',') %>
<% end -%>
<% unless @bind_mode == 'none' -%>
<%= @bind_mode %>
<% end -%>
<% if @no_resolv -%>
no-resolv
<% end -%>
<% if @domain_needed -%>
domain-needed
<% end -%>
<% if @bogus_priv -%>
bogus-priv
<% end -%>
<% if @cache_size -%>
cache-size=<%= @cache_size %>
<% end -%>
<% @servers.each do |server| -%>
server=<%= server %>
<% end -%>
<% @forwards.keys.sort.each do |domain| -%>
<% @forwards[domain].each do |server| -%>
server=/<%= domain %>/<%= server %>
<% end -%>
<% end -%>
<% @dhcp_relays.each do |relay| -%>
dhcp-relay=<%= relay %>
<% end -%>
<% @options.each do |line| -%>
<%= line %>
<% end -%>
+1
View File
@@ -5,6 +5,7 @@ require 'ipaddr'
# a class that creates facts based on the subnet
class SubnetAttributes
SUBNET_TO_ATTRIBUTES = {
'198.18.2.0/24' => { environment: 'prod', region: 'syd1', country: 'au', zone: 'common' }, # router loopbacks
'198.18.13.0/24' => { environment: 'prod', region: 'syd1', country: 'au', zone: 'common' },
'198.18.14.0/24' => { environment: 'prod', region: 'syd1', country: 'au', zone: 'common' },
'198.18.15.0/24' => { environment: 'prod', region: 'syd1', country: 'au', zone: 'common' },
+44
View File
@@ -0,0 +1,44 @@
# manage wireguard interfaces via wg-quick
class wireguard (
Boolean $manage_package = true,
String $package_name = 'wireguard-tools',
Variant[Hash, Sensitive[Hash]] $interfaces = {},
) {
if $manage_package {
package { $package_name:
ensure => installed,
before => File['/etc/wireguard'],
}
}
file { '/etc/wireguard':
ensure => directory,
owner => 'root',
group => 'root',
mode => '0700',
}
# hiera hands eyaml secrets over as plain strings inside the (Sensitive) hash; re-wrap them per resource
$raw = $interfaces ? {
Sensitive => $interfaces.unwrap,
default => $interfaces,
}
$raw.each |String $iface, Hash $data| {
$peers = $data.get('peers', []).map |Hash $peer| {
$peer['preshared_key'] =~ String ? {
true => $peer + { 'preshared_key' => Sensitive($peer['preshared_key']) },
default => $peer,
}
}
$private_key = $data['private_key'] =~ String ? {
true => Sensitive($data['private_key']),
default => $data['private_key'],
}
wireguard::interface { $iface:
* => $data + { 'peers' => $peers, 'private_key' => $private_key },
}
}
}
+63
View File
@@ -0,0 +1,63 @@
# manage one wg-quick interface; without private_key, /etc/wireguard/<iface>.key is generated once and loaded via PostUp
define wireguard::interface (
Array[Stdlib::IP::Address] $addresses,
Optional[Stdlib::Port] $listen_port = undef,
Optional[Integer[1280, 9000]] $mtu = undef,
Optional[Sensitive[String[1]]] $private_key = undef,
Array[Struct[{
public_key => String[1],
allowed_ips => Variant[String[1], Array[String[1], 1]],
preshared_key => Optional[Sensitive[String[1]]],
endpoint => Optional[String[1]],
persistent_keepalive => Optional[Integer[0, 65535]],
}]] $peers = [],
) {
$conf = "/etc/wireguard/${name}.conf"
$key = $private_key.then |$k| { $k.unwrap }
if $private_key =~ Undef {
$keyfile = "/etc/wireguard/${name}.key"
exec { "wireguard_genkey_${name}":
command => "/bin/sh -c 'umask 077; wg genkey > ${keyfile}'",
creates => $keyfile,
path => ['/usr/bin', '/usr/sbin', '/bin', '/sbin'],
require => File['/etc/wireguard'],
}
file { $keyfile:
ensure => file,
owner => 'root',
group => 'root',
mode => '0600',
require => Exec["wireguard_genkey_${name}"],
before => [File[$conf], Service["wg-quick@${name}"]],
}
}
file { $conf:
ensure => file,
owner => 'root',
group => 'root',
mode => '0600',
content => Sensitive(template('wireguard/wg.conf.erb')),
show_diff => false,
notify => Exec["wireguard_syncconf_${name}"],
}
service { "wg-quick@${name}":
ensure => running,
enable => true,
require => File[$conf],
}
# syncconf applies peer/key changes without bouncing the tunnel; address/mtu changes need a manual restart
exec { "wireguard_syncconf_${name}":
command => "/bin/bash -c 'wg syncconf ${name} <(wg-quick strip ${name})'",
onlyif => "/usr/sbin/ip link show ${name}",
path => ['/usr/bin', '/usr/sbin', '/bin', '/sbin'],
refreshonly => true,
require => Service["wg-quick@${name}"],
}
}
+31
View File
@@ -0,0 +1,31 @@
# THIS FILE IS MANAGED BY PUPPET
[Interface]
<% @addresses.each do |addr| -%>
Address = <%= addr %>
<% end -%>
<% if @listen_port -%>
ListenPort = <%= @listen_port %>
<% end -%>
<% if @mtu -%>
MTU = <%= @mtu %>
<% end -%>
<% if @key -%>
PrivateKey = <%= @key %>
<% else -%>
PostUp = wg set %i private-key /etc/wireguard/%i.key
<% end -%>
<% @peers.each do |peer| -%>
[Peer]
PublicKey = <%= peer['public_key'] %>
<% if peer['preshared_key'] -%>
PresharedKey = <%= peer['preshared_key'].unwrap %>
<% end -%>
AllowedIPs = <%= Array(peer['allowed_ips']).join(', ') %>
<% if peer['endpoint'] -%>
Endpoint = <%= peer['endpoint'] %>
<% end -%>
<% if peer['persistent_keepalive'] -%>
PersistentKeepalive = <%= peer['persistent_keepalive'] %>
<% end -%>
<% end -%>
+14 -63
View File
@@ -1,77 +1,28 @@
# profiles::helpers::certmanager
#
# wrapper class for python, pip and venv
# renders the config.yaml read by the certmanager binary (RPM-installed)
class profiles::helpers::certmanager (
String $script_name = 'certmanager',
Stdlib::AbsolutePath $base_path = "/opt/${script_name}",
Stdlib::AbsolutePath $venv_path = "${base_path}/venv",
Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml",
Hash $vault_config = {},
String $owner = 'root',
String $group = 'root',
Boolean $systempkgs = false,
String $version = 'system',
Array[String[1]] $packages = ['requests', 'pyyaml'],
){
if $::facts['python3_version'] {
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
$python_version = $version ? {
'system' => $::facts['python3_version'],
default => $version,
}
# ensure the base_path exists
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
# create a venv
python::pyvenv { $venv_path :
ensure => present,
version => $python_version,
systempkgs => $systempkgs,
venv_dir => $venv_path,
owner => $owner,
group => $group,
require => File[$base_path],
}
# install the required pip packages
$packages.each |String $package| {
python::pip { "${venv_path}_${package}":
ensure => present,
pkgname => $package,
virtualenv => $venv_path,
}
}
# create the script from a template
file { "${base_path}/${script_name}":
ensure => file,
mode => '0755',
content => template("profiles/helpers/${script_name}.erb"),
require => Python::Pyvenv[$venv_path],
}
# create the config from a template
file { $config_path:
ensure => file,
mode => '0660',
owner => 'puppet',
group => 'root',
content => Sensitive(template("profiles/helpers/${script_name}_config.yaml.erb")),
require => Python::Pyvenv[$venv_path],
}
# create symbolic link in $PATH
file { "/usr/local/bin/${script_name}":
ensure => 'link',
target => "${base_path}/${script_name}",
require => File["${base_path}/${script_name}"],
}
file { $config_path:
ensure => file,
mode => '0660',
owner => 'puppet',
group => 'root',
content => Sensitive(template("profiles/helpers/${script_name}_config.yaml.erb")),
require => File[$base_path],
}
}
+14 -63
View File
@@ -1,77 +1,28 @@
# profiles::helpers::sshsignhost
#
# wrapper class for python, pip and venv
# renders the config.yaml read by the sshsignhost binary (RPM-installed)
class profiles::helpers::sshsignhost (
String $script_name = 'sshsignhost',
Stdlib::AbsolutePath $base_path = "/opt/${script_name}",
Stdlib::AbsolutePath $venv_path = "${base_path}/venv",
Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml",
Hash $vault_config = {},
String $owner = 'root',
String $group = 'root',
Boolean $systempkgs = false,
String $version = 'system',
Array[String[1]] $packages = ['requests', 'pyyaml'],
){
if $::facts['python3_version'] {
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
$python_version = $version ? {
'system' => $::facts['python3_version'],
default => $version,
}
# ensure the base_path exists
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
# create a venv
python::pyvenv { $venv_path :
ensure => present,
version => $python_version,
systempkgs => $systempkgs,
venv_dir => $venv_path,
owner => $owner,
group => $group,
require => File[$base_path],
}
# install the required pip packages
$packages.each |String $package| {
python::pip { "${venv_path}_${package}":
ensure => present,
pkgname => $package,
virtualenv => $venv_path,
}
}
# create the script from a template
file { "${base_path}/${script_name}":
ensure => file,
mode => '0755',
content => template("profiles/helpers/${script_name}.erb"),
require => Python::Pyvenv[$venv_path],
}
# create the config from a template
file { $config_path:
ensure => file,
mode => '0660',
owner => 'puppet',
group => 'root',
content => Sensitive(template("profiles/helpers/${script_name}_config.yaml.erb")),
require => Python::Pyvenv[$venv_path],
}
# create symbolic link in $PATH
file { "/usr/local/bin/${script_name}":
ensure => 'link',
target => "${base_path}/${script_name}",
require => File["${base_path}/${script_name}"],
}
file { $config_path:
ensure => file,
mode => '0660',
owner => 'puppet',
group => 'root',
content => Sensitive(template("profiles/helpers/${script_name}_config.yaml.erb")),
require => File[$base_path],
}
}
@@ -1,102 +0,0 @@
#!<%= @venv_path %>/bin/python
import argparse
import requests
import json
import os
import yaml
from zipfile import ZipFile
# remove this after certs are generated everywhere
requests.packages.urllib3.disable_warnings()
def load_config(config_path):
with open(config_path, 'r') as file:
config = yaml.safe_load(file)
return config['vault']
def authenticate_approle(vault_config):
url = f"{vault_config['addr']}/v1/auth/{vault_config['approle_path']}/login"
payload = {
"role_id": vault_config['role_id'],
}
response = requests.post(url, json=payload, verify=False)
if response.status_code == 200:
auth_response = response.json()
return auth_response['auth']['client_token']
else:
print(f"Error authenticating with AppRole: {response.text}")
return None
def request_certificate(common_name, alt_names, ip_sans, expiry_days, vault_config):
# Authenticate using AppRole and get a token
client_token = authenticate_approle(vault_config)
if not client_token:
print("Failed to authenticate with Vault using AppRole.")
return None
url = f"{vault_config['addr']}/v1/{vault_config['mount_point']}/issue/{vault_config['role_name']}"
headers = {'X-Vault-Token': client_token}
payload = {
"common_name": common_name,
"alt_names": ",".join(alt_names),
"ip_sans": ",".join(ip_sans),
"ttl": f"{expiry_days}d"
}
response = requests.post(url, headers=headers, json=payload, verify=False)
if response.status_code == 200:
return response.json()
else:
print(f"Error requesting certificate: {response.text}")
return None
def save_cert_files(certificate_response, common_name, compress, config, json_output):
base_path = config.get('output_path', '.')
cert_dir = os.path.join(base_path, common_name)
if json_output:
import json
output = {
'certificate': certificate_response['data']['certificate'],
'private_key': certificate_response['data']['private_key'],
'full_chain': certificate_response['data']['issuing_ca'] + "\n" + certificate_response['data']['certificate'],
}
print(json.dumps(output))
elif not compress:
os.makedirs(cert_dir, exist_ok=True)
with open(os.path.join(cert_dir, "certificate.crt"), "w") as cert_file:
cert_file.write(certificate_response['data']['certificate'])
with open(os.path.join(cert_dir, "private.key"), "w") as key_file:
key_file.write(certificate_response['data']['private_key'])
with open(os.path.join(cert_dir, "full_chain.crt"), "w") as full_chain_file:
full_chain_file.write(certificate_response['data']['issuing_ca'] + "\n" + certificate_response['data']['certificate'])
else:
zip_name = f"{os.path.join(base_path, common_name)}.zip"
with ZipFile(zip_name, 'w') as zipf:
zipf.writestr("certificate.crt", certificate_response['data']['certificate'])
zipf.writestr("private.key", certificate_response['data']['private_key'])
zipf.writestr("full_chain.crt", certificate_response['data']['issuing_ca'] + "\n" + certificate_response['data']['certificate'])
def main(config_file):
config = load_config(config_file)
parser = argparse.ArgumentParser(description='Request and retrieve a certificate from Vault.')
parser.add_argument('common_name', type=str, help='Common Name for the certificate')
parser.add_argument('-a', '--alt-names', type=str, default='', help='Comma-separated alternative names for the certificate')
parser.add_argument('-i', '--ip-sans', type=str, default='', help='Comma-separated IP Subject Alternative Names for the certificate')
parser.add_argument('-e', '--expiry-days', type=int, default=365, help='Validity of the certificate in days (default: 365)')
parser.add_argument('-c', '--compress', action='store_true', help='Compress the certificate, key, and full chain into a zip file')
parser.add_argument('--json', action='store_true', help='Output results in JSON format')
args = parser.parse_args()
alt_names = [name.strip() for name in args.alt_names.split(',') if name]
ip_sans = [ip.strip() for ip in args.ip_sans.split(',') if ip]
certificate_response = request_certificate(args.common_name, alt_names, ip_sans, args.expiry_days, config)
if certificate_response:
if args.json:
save_cert_files(certificate_response, args.common_name, args.compress, config, True)
else:
save_cert_files(certificate_response, args.common_name, args.compress, config, False)
else:
print("Failed to obtain certificate.")
if __name__ == "__main__":
config_file = '<%= @config_path %>'
main(config_file)
@@ -4,4 +4,4 @@ vault:
approle_path: '<%= @vault_config['approle_path'] %>'
mount_point: '<%= @vault_config['mount_point'] %>'
role_name: '<%= @vault_config['role_name'] %>'
output_path: '<%= @vault_config['output_path'] %>'
output_path: '<%= @vault_config['output_path'] %>'
@@ -1,83 +0,0 @@
#!<%= @venv_path %>/bin/python
import argparse
import requests
import json
import yaml
# remove this after certs are generated everywhere
requests.packages.urllib3.disable_warnings()
def load_config(config_path):
with open(config_path, 'r') as file:
config = yaml.safe_load(file)
return config['vault']
def authenticate_approle(vault_config):
url = f"{vault_config['addr']}/v1/auth/{vault_config['approle_path']}/login"
payload = {
"role_id": vault_config['role_id'],
}
response = requests.post(url, json=payload, verify=False)
if response.status_code == 200:
auth_response = response.json()
return auth_response['auth']['client_token']
else:
print(f"Error authenticating with AppRole: {response.text}")
return None
def sign_ssh_certificate(vault_config, public_key, valid_principals, ttl):
# Authenticate using AppRole and get a token
client_token = authenticate_approle(vault_config)
if not client_token:
print("Failed to authenticate with Vault using AppRole.")
return None
# Prepare the SSH certificate signing request
url = f"{vault_config['addr']}/v1/{vault_config['mount_point']}/sign/{vault_config['role_name']}"
headers = {'X-Vault-Token': client_token}
payload = {
"cert_type": "host",
"public_key": public_key,
"valid_principals": valid_principals,
"ttl": ttl
}
# Request the SSH certificate signing
response = requests.post(url, headers=headers, json=payload, verify=False)
if response.status_code == 200:
return response.json()
else:
print(f"Error requesting certificate: {response.text}")
return None
def main(config_file):
config = load_config(config_file)
parser = argparse.ArgumentParser(description='Sign SSH host certificate using Vault.')
parser.add_argument('--public_key', required=True, help='SSH public key as a string')
parser.add_argument('--valid_principals', required=True, help='Comma-separated list of valid principals')
parser.add_argument('--ttl', default='87600h', help='Time-to-live for the certificate (default: 87600h)')
parser.add_argument('--json', action='store_true', help='Output the resulting certificate as JSON')
args = parser.parse_args()
# Load configuration
config = load_config(config_file)
# Sign SSH certificate
response = sign_ssh_certificate(config, args.public_key, args.valid_principals, args.ttl)
if response and 'data' in response and 'signed_key' in response['data']:
if args.json:
output = {
'signed_key': response['data']['signed_key'],
}
print(json.dumps(output))
else:
print(response['data']['signed_key'])
else:
print("Error: The response does not contain the expected data.")
exit(1)
if __name__ == "__main__":
config_file = '<%= @config_path %>'
main(config_file)
@@ -4,4 +4,3 @@ vault:
approle_path: '<%= @vault_config['approle_path'] %>'
mount_point: '<%= @vault_config['mount_point'] %>'
role_name: '<%= @vault_config['role_name'] %>'
output_path: '<%= @vault_config['output_path'] %>'
@@ -0,0 +1,12 @@
# roles::infra::network::router
# an ospf router; frr only, interfaces and firewall are managed outside puppet
#
class roles::infra::network::router {
if $facts['firstrun'] {
include profiles::defaults
include profiles::firstrun::init
}else{
include profiles::defaults
include profiles::base
}
}