draft: r/step-ssh
parent
480b3d3fb6
commit
c40d69803e
|
@ -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
3
hosts
|
@ -163,6 +163,9 @@ smtp1.pyrocufflink.blue
|
|||
|
||||
[squid]
|
||||
|
||||
[step-ssh:children]
|
||||
pyrocufflink
|
||||
|
||||
[synapse]
|
||||
matrix0.pyrocufflink.blue
|
||||
|
||||
|
|
|
@ -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=
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
TrustedUserCAKeys /etc/ssh/ca.pub
|
|
@ -0,0 +1,4 @@
|
|||
- name: reload sshd
|
||||
service:
|
||||
name: sshd
|
||||
state: reloaded
|
|
@ -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()
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{% for cert in step_ssh_trueted_user_ca_keys %}
|
||||
{{ cert }}
|
||||
{% endfor %}
|
|
@ -0,0 +1,5 @@
|
|||
{% if host_certs.certs|d(none) %}
|
||||
{% for cert in host_certs.certs | sort %}
|
||||
HostCertificate {{ cert }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
|
@ -0,0 +1,3 @@
|
|||
STEP_CA_URL=https://ca.pyrocufflink.blue:32599
|
||||
STEP_ROOT={{ step_root_ca_path }}
|
||||
STEP_PROVISIONER=sshpop
|
|
@ -0,0 +1,3 @@
|
|||
- hosts: step-ssh
|
||||
roles:
|
||||
- step-ssh
|
Loading…
Reference in New Issue