Compare commits

..

1 Commits

Author SHA1 Message Date
unkin-agent 9db9afae8d Let certmanager and sshsignhost authenticate to Vault by kubernetes auth
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/erb-validate Pipeline was successful
ci/woodpecker/pr/epp-validate Pipeline was successful
ci/woodpecker/pr/puppet-validate Pipeline was successful
ci/woodpecker/pr/ruby-check Pipeline was successful
Both helpers are run server-side by generate() during catalog compilation and
only speak AppRole, whose token_bound_cidrs pin them to the six legacy VM
masters, so the autoscaled k8s compilers cannot obtain a token and any compile
needing a cert or a signed host key fails there.

- Add a kubernetes login branch that reads the service account JWT and posts it
  to auth/<k8s_mount>/login, selected by an auth_method config key
- Add auth_method, k8s_mount, k8s_role and jwt_path class parameters, defaulting
  to approle so the VM masters render and behave as before
- Render role_id and approle_path only for the approle case
- Report a missing JWT, a rejected login or an unknown auth_method on stderr
  instead of falling back or raising
- Point sshsignhost at the sshca mount and signhost role that Vault actually has

Needs terraform-vault #152, already applied.
2026-09-13 23:06:32 +10:00
7 changed files with 437 additions and 46 deletions
+1 -7
View File
@@ -50,15 +50,9 @@ profiles::helpers::sshsignhost::vault_config:
mount_point: 'sshca'
approle_path: 'approle'
role_name: 'signhost'
output_path: '/tmp/sshsignhost'
role_id: "%{lookup('sshsignhost::role_id')}"
# Vault signing helpers, delivered as RPMs from the rpm-internal repo.
profiles::packages::include:
certmanager:
ensure: '0.2.0'
sshsignhost:
ensure: '0.1.0'
profiles::puppet::server::agent_server: 'puppet.query.consul'
profiles::puppet::server::report_server: 'puppet.query.consul'
profiles::puppet::server::ca_server: 'puppetca.query.consul'
+75 -14
View File
@@ -1,28 +1,89 @@
# profiles::helpers::certmanager
#
# renders the config.yaml read by the certmanager binary (RPM-installed)
# wrapper class for python, pip and venv
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 = {},
Enum['approle','kubernetes'] $auth_method = 'approle',
String[1] $k8s_mount = 'k8s/au/syd1',
String[1] $k8s_role = 'puppet_certmanager',
Stdlib::AbsolutePath $jwt_path = '/var/run/secrets/kubernetes.io/serviceaccount/token',
String $owner = 'root',
String $group = 'root',
Boolean $systempkgs = false,
String $version = 'system',
Array[String[1]] $packages = ['requests', 'pyyaml'],
){
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
if $::facts['python3_version'] {
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],
# class parameters supply the auth defaults; $vault_config may override them
$vault_settings = {
'auth_method' => $auth_method,
'k8s_mount' => $k8s_mount,
'k8s_role' => $k8s_role,
'jwt_path' => $jwt_path,
} + $vault_config
$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}"],
}
}
}
+75 -14
View File
@@ -1,28 +1,89 @@
# profiles::helpers::sshsignhost
#
# renders the config.yaml read by the sshsignhost binary (RPM-installed)
# wrapper class for python, pip and venv
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 = {},
Enum['approle','kubernetes'] $auth_method = 'approle',
String[1] $k8s_mount = 'k8s/au/syd1',
String[1] $k8s_role = 'puppet_sshsigner',
Stdlib::AbsolutePath $jwt_path = '/var/run/secrets/kubernetes.io/serviceaccount/token',
String $owner = 'root',
String $group = 'root',
Boolean $systempkgs = false,
String $version = 'system',
Array[String[1]] $packages = ['requests', 'pyyaml'],
){
file { $base_path:
ensure => directory,
mode => '0755',
owner => $owner,
group => $group,
}
if $::facts['python3_version'] {
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],
# class parameters supply the auth defaults; $vault_config may override them
$vault_settings = {
'auth_method' => $auth_method,
'k8s_mount' => $k8s_mount,
'k8s_role' => $k8s_role,
'jwt_path' => $jwt_path,
} + $vault_config
$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}"],
}
}
}
@@ -0,0 +1,140 @@
#!<%= @venv_path %>/bin/python
import argparse
import sys
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}", file=sys.stderr)
return None
class VaultAuthError(Exception):
pass
def authenticate_kubernetes(vault_config):
jwt_path = vault_config.get('jwt_path') or '/var/run/secrets/kubernetes.io/serviceaccount/token'
try:
with open(jwt_path, 'r') as file:
jwt = file.read().strip()
except OSError as error:
raise VaultAuthError(f"cannot read service account token '{jwt_path}': {error}")
if not jwt:
raise VaultAuthError(f"service account token '{jwt_path}' is empty")
url = f"{vault_config['addr']}/v1/auth/{vault_config['k8s_mount']}/login"
payload = {
"role": vault_config['k8s_role'],
"jwt": jwt,
}
response = requests.post(url, json=payload, verify=False)
if response.status_code != 200:
raise VaultAuthError(
f"kubernetes login as role '{vault_config['k8s_role']}' on mount "
f"'{vault_config['k8s_mount']}' was rejected ({response.status_code}): {response.text}"
)
return response.json()['auth']['client_token']
def authenticate(vault_config):
auth_method = vault_config.get('auth_method', 'approle')
if auth_method == 'approle':
client_token = authenticate_approle(vault_config)
if not client_token:
raise VaultAuthError("approle login was rejected")
return client_token
if auth_method == 'kubernetes':
return authenticate_kubernetes(vault_config)
raise VaultAuthError(f"unsupported auth_method '{auth_method}': expected 'approle' or 'kubernetes'")
def request_certificate(common_name, alt_names, ip_sans, expiry_days, vault_config):
try:
client_token = authenticate(vault_config)
except VaultAuthError as error:
print(f"Failed to authenticate with Vault: {error}", file=sys.stderr)
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}", file=sys.stderr)
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.", file=sys.stderr)
exit(1)
if __name__ == "__main__":
config_file = '<%= @config_path %>'
main(config_file)
@@ -1,7 +1,14 @@
vault:
addr: '<%= @vault_config['addr'] %>'
role_id: '<%= @vault_config['role_id'] %>'
approle_path: '<%= @vault_config['approle_path'] %>'
mount_point: '<%= @vault_config['mount_point'] %>'
role_name: '<%= @vault_config['role_name'] %>'
output_path: '<%= @vault_config['output_path'] %>'
addr: '<%= @vault_settings['addr'] %>'
auth_method: '<%= @vault_settings['auth_method'] %>'
<% if @vault_settings['auth_method'] == 'kubernetes' -%>
k8s_mount: '<%= @vault_settings['k8s_mount'] %>'
k8s_role: '<%= @vault_settings['k8s_role'] %>'
jwt_path: '<%= @vault_settings['jwt_path'] %>'
<% else -%>
role_id: '<%= @vault_settings['role_id'] %>'
approle_path: '<%= @vault_settings['approle_path'] %>'
<% end -%>
mount_point: '<%= @vault_settings['mount_point'] %>'
role_name: '<%= @vault_settings['role_name'] %>'
output_path: '<%= @vault_settings['output_path'] %>'
@@ -0,0 +1,120 @@
#!<%= @venv_path %>/bin/python
import argparse
import sys
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}", file=sys.stderr)
return None
class VaultAuthError(Exception):
pass
def authenticate_kubernetes(vault_config):
jwt_path = vault_config.get('jwt_path') or '/var/run/secrets/kubernetes.io/serviceaccount/token'
try:
with open(jwt_path, 'r') as file:
jwt = file.read().strip()
except OSError as error:
raise VaultAuthError(f"cannot read service account token '{jwt_path}': {error}")
if not jwt:
raise VaultAuthError(f"service account token '{jwt_path}' is empty")
url = f"{vault_config['addr']}/v1/auth/{vault_config['k8s_mount']}/login"
payload = {
"role": vault_config['k8s_role'],
"jwt": jwt,
}
response = requests.post(url, json=payload, verify=False)
if response.status_code != 200:
raise VaultAuthError(
f"kubernetes login as role '{vault_config['k8s_role']}' on mount "
f"'{vault_config['k8s_mount']}' was rejected ({response.status_code}): {response.text}"
)
return response.json()['auth']['client_token']
def authenticate(vault_config):
auth_method = vault_config.get('auth_method', 'approle')
if auth_method == 'approle':
client_token = authenticate_approle(vault_config)
if not client_token:
raise VaultAuthError("approle login was rejected")
return client_token
if auth_method == 'kubernetes':
return authenticate_kubernetes(vault_config)
raise VaultAuthError(f"unsupported auth_method '{auth_method}': expected 'approle' or 'kubernetes'")
def sign_ssh_certificate(vault_config, public_key, valid_principals, ttl):
try:
client_token = authenticate(vault_config)
except VaultAuthError as error:
print(f"Failed to authenticate with Vault: {error}", file=sys.stderr)
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}", file=sys.stderr)
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.", file=sys.stderr)
exit(1)
if __name__ == "__main__":
config_file = '<%= @config_path %>'
main(config_file)
@@ -1,6 +1,14 @@
vault:
addr: '<%= @vault_config['addr'] %>'
role_id: '<%= @vault_config['role_id'] %>'
approle_path: '<%= @vault_config['approle_path'] %>'
mount_point: '<%= @vault_config['mount_point'] %>'
role_name: '<%= @vault_config['role_name'] %>'
addr: '<%= @vault_settings['addr'] %>'
auth_method: '<%= @vault_settings['auth_method'] %>'
<% if @vault_settings['auth_method'] == 'kubernetes' -%>
k8s_mount: '<%= @vault_settings['k8s_mount'] %>'
k8s_role: '<%= @vault_settings['k8s_role'] %>'
jwt_path: '<%= @vault_settings['jwt_path'] %>'
<% else -%>
role_id: '<%= @vault_settings['role_id'] %>'
approle_path: '<%= @vault_settings['approle_path'] %>'
<% end -%>
mount_point: '<%= @vault_settings['mount_point'] %>'
role_name: '<%= @vault_settings['role_name'] %>'
output_path: '<%= @vault_settings['output_path'] %>'