diff --git a/group_vars/all.yml b/group_vars/all.yml index 3c98b13..d335cd2 100644 --- a/group_vars/all.yml +++ b/group_vars/all.yml @@ -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 diff --git a/hosts b/hosts index 2f1afcb..8b22b89 100644 --- a/hosts +++ b/hosts @@ -163,6 +163,9 @@ smtp1.pyrocufflink.blue [squid] +[step-ssh:children] +pyrocufflink + [synapse] matrix0.pyrocufflink.blue diff --git a/roles/step-ssh/defaults/main.yml b/roles/step-ssh/defaults/main.yml new file mode 100644 index 0000000..e34e25b --- /dev/null +++ b/roles/step-ssh/defaults/main.yml @@ -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= diff --git a/roles/step-ssh/files/dch-smallstep.repo b/roles/step-ssh/files/dch-smallstep.repo new file mode 100644 index 0000000..8b9dde5 --- /dev/null +++ b/roles/step-ssh/files/dch-smallstep.repo @@ -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 diff --git a/roles/step-ssh/files/step-ssh-renew.target b/roles/step-ssh/files/step-ssh-renew.target new file mode 100644 index 0000000..c321cda --- /dev/null +++ b/roles/step-ssh/files/step-ssh-renew.target @@ -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 diff --git a/roles/step-ssh/files/step-ssh-renew.timer b/roles/step-ssh/files/step-ssh-renew.timer new file mode 100644 index 0000000..f7344a6 --- /dev/null +++ b/roles/step-ssh/files/step-ssh-renew.timer @@ -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 diff --git a/roles/step-ssh/files/step-ssh-renew@.service b/roles/step-ssh/files/step-ssh-renew@.service new file mode 100644 index 0000000..11ead95 --- /dev/null +++ b/roles/step-ssh/files/step-ssh-renew@.service @@ -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 diff --git a/roles/step-ssh/files/trustedusercakeys.conf b/roles/step-ssh/files/trustedusercakeys.conf new file mode 100644 index 0000000..6b31833 --- /dev/null +++ b/roles/step-ssh/files/trustedusercakeys.conf @@ -0,0 +1 @@ +TrustedUserCAKeys /etc/ssh/ca.pub diff --git a/roles/step-ssh/handlers/main.yml b/roles/step-ssh/handlers/main.yml new file mode 100644 index 0000000..46ccb50 --- /dev/null +++ b/roles/step-ssh/handlers/main.yml @@ -0,0 +1,4 @@ +- name: reload sshd + service: + name: sshd + state: reloaded diff --git a/roles/step-ssh/library/ssh_host_certs.py b/roles/step-ssh/library/ssh_host_certs.py new file mode 100644 index 0000000..286a802 --- /dev/null +++ b/roles/step-ssh/library/ssh_host_certs.py @@ -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() diff --git a/roles/step-ssh/tasks/main.yml b/roles/step-ssh/tasks/main.yml new file mode 100644 index 0000000..0af6580 --- /dev/null +++ b/roles/step-ssh/tasks/main.yml @@ -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 diff --git a/roles/step-ssh/templates/ca.pub.j2 b/roles/step-ssh/templates/ca.pub.j2 new file mode 100644 index 0000000..2319a00 --- /dev/null +++ b/roles/step-ssh/templates/ca.pub.j2 @@ -0,0 +1,3 @@ +{% for cert in step_ssh_trueted_user_ca_keys %} +{{ cert }} +{% endfor %} diff --git a/roles/step-ssh/templates/hostcertificate.conf.j2 b/roles/step-ssh/templates/hostcertificate.conf.j2 new file mode 100644 index 0000000..802624b --- /dev/null +++ b/roles/step-ssh/templates/hostcertificate.conf.j2 @@ -0,0 +1,5 @@ +{% if host_certs.certs|d(none) %} +{% for cert in host_certs.certs | sort %} +HostCertificate {{ cert }} +{% endfor %} +{% endif %} diff --git a/roles/step-ssh/templates/step-ssh-renew.env.j2 b/roles/step-ssh/templates/step-ssh-renew.env.j2 new file mode 100644 index 0000000..778d812 --- /dev/null +++ b/roles/step-ssh/templates/step-ssh-renew.env.j2 @@ -0,0 +1,3 @@ +STEP_CA_URL=https://ca.pyrocufflink.blue:32599 +STEP_ROOT={{ step_root_ca_path }} +STEP_PROVISIONER=sshpop diff --git a/step-ssh.yml b/step-ssh.yml new file mode 100644 index 0000000..b56bc3d --- /dev/null +++ b/step-ssh.yml @@ -0,0 +1,3 @@ +- hosts: step-ssh + roles: + - step-ssh