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 141 additions and 28 deletions
+2 -2
View File
@@ -47,9 +47,9 @@ profiles::helpers::certmanager::vault_config:
profiles::helpers::sshsignhost::vault_config: profiles::helpers::sshsignhost::vault_config:
addr: 'https://vault.service.consul:8200' addr: 'https://vault.service.consul:8200'
mount_point: 'ssh-host-signer' mount_point: 'sshca'
approle_path: 'approle' approle_path: 'approle'
role_name: 'hostrole' role_name: 'signhost'
output_path: '/tmp/sshsignhost' output_path: '/tmp/sshsignhost'
role_id: "%{lookup('sshsignhost::role_id')}" role_id: "%{lookup('sshsignhost::role_id')}"
@@ -7,6 +7,10 @@ class profiles::helpers::certmanager (
Stdlib::AbsolutePath $venv_path = "${base_path}/venv", Stdlib::AbsolutePath $venv_path = "${base_path}/venv",
Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml", Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml",
Hash $vault_config = {}, 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 $owner = 'root',
String $group = 'root', String $group = 'root',
Boolean $systempkgs = false, Boolean $systempkgs = false,
@@ -16,6 +20,14 @@ class profiles::helpers::certmanager (
if $::facts['python3_version'] { if $::facts['python3_version'] {
# 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 ? { $python_version = $version ? {
'system' => $::facts['python3_version'], 'system' => $::facts['python3_version'],
default => $version, default => $version,
@@ -7,6 +7,10 @@ class profiles::helpers::sshsignhost (
Stdlib::AbsolutePath $venv_path = "${base_path}/venv", Stdlib::AbsolutePath $venv_path = "${base_path}/venv",
Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml", Stdlib::AbsolutePath $config_path = "${base_path}/config.yaml",
Hash $vault_config = {}, 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 $owner = 'root',
String $group = 'root', String $group = 'root',
Boolean $systempkgs = false, Boolean $systempkgs = false,
@@ -16,6 +20,14 @@ class profiles::helpers::sshsignhost (
if $::facts['python3_version'] { if $::facts['python3_version'] {
# 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 ? { $python_version = $version ? {
'system' => $::facts['python3_version'], 'system' => $::facts['python3_version'],
default => $version, default => $version,
@@ -1,6 +1,7 @@
#!<%= @venv_path %>/bin/python #!<%= @venv_path %>/bin/python
import argparse import argparse
import sys
import requests import requests
import json import json
import os import os
@@ -25,14 +26,50 @@ def authenticate_approle(vault_config):
auth_response = response.json() auth_response = response.json()
return auth_response['auth']['client_token'] return auth_response['auth']['client_token']
else: else:
print(f"Error authenticating with AppRole: {response.text}") print(f"Error authenticating with AppRole: {response.text}", file=sys.stderr)
return None 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): def request_certificate(common_name, alt_names, ip_sans, expiry_days, vault_config):
# Authenticate using AppRole and get a token try:
client_token = authenticate_approle(vault_config) client_token = authenticate(vault_config)
if not client_token: except VaultAuthError as error:
print("Failed to authenticate with Vault using AppRole.") print(f"Failed to authenticate with Vault: {error}", file=sys.stderr)
return None return None
url = f"{vault_config['addr']}/v1/{vault_config['mount_point']}/issue/{vault_config['role_name']}" url = f"{vault_config['addr']}/v1/{vault_config['mount_point']}/issue/{vault_config['role_name']}"
@@ -47,7 +84,7 @@ def request_certificate(common_name, alt_names, ip_sans, expiry_days, vault_conf
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
print(f"Error requesting certificate: {response.text}") print(f"Error requesting certificate: {response.text}", file=sys.stderr)
return None return None
def save_cert_files(certificate_response, common_name, compress, config, json_output): def save_cert_files(certificate_response, common_name, compress, config, json_output):
@@ -95,7 +132,8 @@ def main(config_file):
else: else:
save_cert_files(certificate_response, args.common_name, args.compress, config, False) save_cert_files(certificate_response, args.common_name, args.compress, config, False)
else: else:
print("Failed to obtain certificate.") print("Failed to obtain certificate.", file=sys.stderr)
exit(1)
if __name__ == "__main__": if __name__ == "__main__":
config_file = '<%= @config_path %>' config_file = '<%= @config_path %>'
@@ -1,7 +1,14 @@
vault: vault:
addr: '<%= @vault_config['addr'] %>' addr: '<%= @vault_settings['addr'] %>'
role_id: '<%= @vault_config['role_id'] %>' auth_method: '<%= @vault_settings['auth_method'] %>'
approle_path: '<%= @vault_config['approle_path'] %>' <% if @vault_settings['auth_method'] == 'kubernetes' -%>
mount_point: '<%= @vault_config['mount_point'] %>' k8s_mount: '<%= @vault_settings['k8s_mount'] %>'
role_name: '<%= @vault_config['role_name'] %>' k8s_role: '<%= @vault_settings['k8s_role'] %>'
output_path: '<%= @vault_config['output_path'] %>' 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'] %>'
@@ -1,5 +1,6 @@
#!<%= @venv_path %>/bin/python #!<%= @venv_path %>/bin/python
import argparse import argparse
import sys
import requests import requests
import json import json
import yaml import yaml
@@ -22,14 +23,50 @@ def authenticate_approle(vault_config):
auth_response = response.json() auth_response = response.json()
return auth_response['auth']['client_token'] return auth_response['auth']['client_token']
else: else:
print(f"Error authenticating with AppRole: {response.text}") print(f"Error authenticating with AppRole: {response.text}", file=sys.stderr)
return None 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): def sign_ssh_certificate(vault_config, public_key, valid_principals, ttl):
# Authenticate using AppRole and get a token try:
client_token = authenticate_approle(vault_config) client_token = authenticate(vault_config)
if not client_token: except VaultAuthError as error:
print("Failed to authenticate with Vault using AppRole.") print(f"Failed to authenticate with Vault: {error}", file=sys.stderr)
return None return None
# Prepare the SSH certificate signing request # Prepare the SSH certificate signing request
@@ -47,7 +84,7 @@ def sign_ssh_certificate(vault_config, public_key, valid_principals, ttl):
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
print(f"Error requesting certificate: {response.text}") print(f"Error requesting certificate: {response.text}", file=sys.stderr)
return None return None
def main(config_file): def main(config_file):
@@ -75,7 +112,7 @@ def main(config_file):
else: else:
print(response['data']['signed_key']) print(response['data']['signed_key'])
else: else:
print("Error: The response does not contain the expected data.") print("Error: The response does not contain the expected data.", file=sys.stderr)
exit(1) exit(1)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,7 +1,14 @@
vault: vault:
addr: '<%= @vault_config['addr'] %>' addr: '<%= @vault_settings['addr'] %>'
role_id: '<%= @vault_config['role_id'] %>' auth_method: '<%= @vault_settings['auth_method'] %>'
approle_path: '<%= @vault_config['approle_path'] %>' <% if @vault_settings['auth_method'] == 'kubernetes' -%>
mount_point: '<%= @vault_config['mount_point'] %>' k8s_mount: '<%= @vault_settings['k8s_mount'] %>'
role_name: '<%= @vault_config['role_name'] %>' k8s_role: '<%= @vault_settings['k8s_role'] %>'
output_path: '<%= @vault_config['output_path'] %>' 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'] %>'