draft: r/step-ssh

step-ssh
Dustin 2023-09-30 15:21:05 -05:00
parent 480b3d3fb6
commit c40d69803e
15 changed files with 342 additions and 0 deletions

View File

@ -79,3 +79,5 @@ firemon_networks:
- 172.24.16.0/20
- 172.28.33.0/24
- 10.64.11.0/24
step_root_ca: dch-root-ca.crt

3
hosts
View File

@ -163,6 +163,9 @@ smtp1.pyrocufflink.blue
[squid]
[step-ssh:children]
pyrocufflink
[synapse]
matrix0.pyrocufflink.blue

View File

@ -0,0 +1,3 @@
step_root_ca_path: /etc/pki/ca-trust/source/anchors/dch-root-ca.crt
step_ssh_trueted_user_ca_keys:
- ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBImIoTTmhynCVy/vJ/Q2bWydzqVsvwhGvDgBbklw0eDt8UEbbP9HHPhxiMDtiAhbvRTg5BhYVAlR1MgdooT5dwQ=

View File

@ -0,0 +1,7 @@
[dch-smallstep]
name=dch-smallstep
baseurl=https://files.pyrocufflink.blue/~dustin/smallstep/
enabled=1
gpgcheck=1
gpgkey=https://files.pyrocufflink.blue/~dustin/smallstep/RPM-GPG-KEY-dch
skip_if_unavailable=1

View File

@ -0,0 +1,5 @@
[Unit]
Description=Renew SSH host certificates
Wants=step-ssh-renew@ed25519.service
Wants=step-ssh-renew@ecdsa.service
Wants=step-ssh-renew@rsa.service

View File

@ -0,0 +1,11 @@
[Unit]
Description=Periodically renew SSH host certificates
[Timer]
Unit=%N.target
OnCalendar=Tue *-*-* 00:00:00
RandomizedDelaySec=48h
Persistent=yes
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,38 @@
[Unit]
Description=Renew SSH host %I certificate
After=network-online.target
Wants=network-online.target
ConditionPathExists=/etc/ssh/ssh_host_%I_key-cert.pub
[Service]
Type=oneshot
EnvironmentFile=/etc/sysconfig/step-ssh-renew
Environment=STEPPATH=/var/lib/step
ExecStart=/usr/bin/step ssh renew -f /etc/ssh/ssh_host_%I_key-cert.pub /etc/ssh/ssh_host_%I_key
CapabilityBoundingSet=CAP_CHOWN
DeviceAllow=
DevicePolicy=closed
LockPersonality=yes
#MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
PrivateDevices=yes
PrivateUsers=yes
PrivateTmp=yes
ProcSubset=pid
ProtectClock=yes
ProtectControlGroups=yes
ProtectHome=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
ProtectSystem=strict
ReadWritePaths=/etc/ssh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
#SystemCallArchitectures=native
#SystemCallFilter=@system-service
#SystemCallFilter=~@privileged @resources

View File

@ -0,0 +1 @@
TrustedUserCAKeys /etc/ssh/ca.pub

View File

@ -0,0 +1,4 @@
- name: reload sshd
service:
name: sshd
state: reloaded

View File

@ -0,0 +1,136 @@
import email.mime.application
import email.mime.multipart
import email.mime.nonmultipart
import email.parser
import email.policy
import json
import logging
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Iterable, Optional, Union
from ansible.module_utils.basic import AnsibleModule
log = logging.getLogger('ssh_cert_sign')
Json = dict[str, Any]
FormField = tuple[str, Union[str, Path]]
class SignFailed(Exception):
...
def ansible_main():
module_args = {
'url': {
'type': 'str',
'required': False,
'default': 'https://webhooks.pyrocufflink.blue/sshkeys/sign',
},
'hostname': {
'type': 'str',
'required': False,
'default': os.uname().nodename,
},
'ssh_dir': {
'type': 'str',
'required': False,
'default': '/etc/ssh',
},
}
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
url = module.params['url']
hostname = module.params['hostname']
ssh_dir = Path(module.params['ssh_dir'])
certs = list_certs(ssh_dir)
changed = not certs
if changed and not module.check_mode:
try:
certs = sign_and_save_certs(url, hostname, ssh_dir)
except Exception as e:
module.fail_json(msg=str(e))
module.exit_json(changed=changed, certs=[str(p) for p in certs])
def list_certs(sshdir: Path) -> list[Path]:
return list(sshdir.glob('ssh_host_*_key-cert.pub'))
def multipart_data(fields: Iterable[FormField]) -> tuple[str, bytes]:
m = email.mime.multipart.MIMEMultipart('form-data')
for name, value in fields:
if isinstance(value, Path):
with value.open('rb') as f:
part = email.mime.application.MIMEApplication(f.read())
part.add_header('Content-Disposition', 'form-data')
part.set_param(
'filename', value.name, header='Content-Disposition'
)
else:
part = email.mime.nonmultipart.MIMENonMultipart('text', 'plain')
part.add_header('Content-Disposition', 'form-data')
part.set_payload(value.encode('utf-8'))
part.set_param('name', name, header='Content-Disposition')
del part['MIME-Version']
m.attach(part)
data = m.as_bytes(policy=email.policy.HTTP)
headers, __, content = data.partition(b'\r\n\r\n')
parser = email.parser.BytesHeaderParser()
header_map = parser.parsebytes(headers)
content_type = header_map['Content-Type']
return (content_type, content)
def request_sign(
url: str, hostname: str, sshdir: Optional[Path] = None
) -> Json:
if sshdir is None:
sshdir = Path('/etc/ssh')
fields: list[FormField] = [('hostname', hostname)]
for path in sshdir.glob('ssh_host_*_key.pub'):
fields.append(('keys', path))
content_type, data = multipart_data(fields)
req = urllib.request.Request(url, data=data)
req.add_header('Content-Type', content_type)
try:
res = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
with e:
buf = e.read()
try:
r = json.loads(buf)
except ValueError as ve:
log.error(f'Error decoding response as JSON: {ve}')
else:
errors = r.get('errors')
if errors:
raise SignFailed('; '.join(errors)) from e
msg = buf.strip().decode('utf-8', errors='replace') or e.reason
raise SignFailed(msg) from e
else:
with res:
return json.load(res)
def sign_and_save_certs(url: str, hostname: str, sshdir: Path) -> list[Path]:
certs = []
response = request_sign(url, hostname, sshdir)
for name, content in response['certificates'].items():
path = sshdir / name
log.info('Writing new certificate to %s', path)
with path.open('w', encoding='utf-8') as f:
f.write(content)
certs.append(path)
return certs
if __name__ == '__main__':
ansible_main()

View File

@ -0,0 +1,118 @@
- name: ensure ssh host certificates are signed
ssh_host_certs:
register: host_certs
tags:
- cert
- name: ensure sshd is configured to use host certificates
template:
src: hostcertificate.conf.j2
dest: /etc/ssh/sshd_config.d/10-hostcertificate.conf
mode: u=rw,go=r
owner: root
group: root
notify:
- reload sshd
tags:
- config
- sshd_config
- name: ensure dch-smallstep repo is configured
copy:
src: dch-smallstep.repo
dest: /etc/yum.repos.d/dch-smallstep.repo
mode: u=rw,go=r
owner: root
group: root
tags:
- yumrepo
- name: ensure step-cli is installed
package:
name: step-cli
state: present
tags:
- install
- name: ensure step certificate directory exists
file:
path: '{{ step_root_ca_path | dirname }}'
mode: u=rwx,go=rx
owner: root
group: root
state: directory
tags:
- cert
- name: ensure step root ca is installed
copy:
src: '{{ step_root_ca }}'
dest: '{{ step_root_ca_path }}'
mode: u=rw,go=r
owner: root
group: root
tags:
- cert
- name: ensure step-ssh-renew systemd units are installed
copy:
src: '{{ item }}'
dest: /etc/systemd/system/{{ item }}
mode: u=rw,go=r
owner: root
group: root
loop:
- step-ssh-renew@.service
- step-ssh-renew.target
- step-ssh-renew.timer
tags:
- systemd
- name: ensure step-ssh-renew environment variables are set
template:
src: step-ssh-renew.env.j2
dest: /etc/sysconfig/step-ssh-renew
mode: u=rw,go=r
owner: root
group: root
tags:
- config
- step-cli-config
- name: ensure step-ssh-renew.timer is enabled
systemd:
name: step-ssh-renew.timer
enabled: true
tags:
- service
- name: ensure step-ssh-renew.timer is running
systemd:
name: step-ssh-renew.timer
state: started
tags:
- service
- name: ensure sshd is configured to trust user certificate ca
copy:
src: trustedusercakeys.conf
dest: /etc/ssh/sshd_config.d/70-trustedusercakeys.conf
mode: u=rw,go=r
owner: root
group: root
tags:
- config
- sshd-config
notify:
- reload sshd
- name: ensure user ssh ca certificates are trusted
template:
src: ca.pub.j2
dest: /etc/ssh/ca.pub
mode: u=rw,go=r
owner: root
group: root
tags:
- config
- sshd-config
notify:
- reload sshd

View File

@ -0,0 +1,3 @@
{% for cert in step_ssh_trueted_user_ca_keys %}
{{ cert }}
{% endfor %}

View File

@ -0,0 +1,5 @@
{% if host_certs.certs|d(none) %}
{% for cert in host_certs.certs | sort %}
HostCertificate {{ cert }}
{% endfor %}
{% endif %}

View File

@ -0,0 +1,3 @@
STEP_CA_URL=https://ca.pyrocufflink.blue:32599
STEP_ROOT={{ step_root_ca_path }}
STEP_PROVISIONER=sshpop

3
step-ssh.yml Normal file
View File

@ -0,0 +1,3 @@
- hosts: step-ssh
roles:
- step-ssh