diff --git a/server/Dockerfile b/server/Dockerfile index ada0f42..2b9de8c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -3,9 +3,13 @@ FROM debian:stable-slim RUN \ # Turn off password authentication for ssh echo openssh-server openssh-server/password-authentication boolean false | debconf-set-selections && \ - # Install openssh-server + # Install openssh-server and Python dependencies for Prometheus collector apt-get update && \ - apt-get -y install --no-install-recommends openssh-server && \ + apt-get -y install --no-install-recommends \ + openssh-server \ + python-is-python3 \ + python3-click \ + python3-prometheus-client && \ rm -rf /var/lib/apt/lists/* && \ # Nuke SSH host keys so that we regenerate them at container run time rm /etc/ssh/ssh_host_* && \ @@ -15,7 +19,7 @@ RUN \ mkdir /run/sshd COPY sshd_config /etc/ssh/sshd_config.d/00base.conf -COPY entrypoint.sh / +COPY entrypoint.sh sshd-prometheus-exporter.py / ENTRYPOINT ["/entrypoint.sh"] HEALTHCHECK --start-period=1s --interval=1s --timeout=1s CMD test -s /run/sshd.pid diff --git a/server/entrypoint.sh b/server/entrypoint.sh index d04dc54..c4a2806 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -25,5 +25,8 @@ then install -D -o tunnel -g nogroup <(printf "%s\n" "${SSH_AUTHORIZED_KEYS}") ~tunnel/.ssh/authorized_keys fi +# Start Prometheus collector +/sshd-prometheus-exporter.py & + # Start sshd /usr/sbin/sshd -De diff --git a/server/sshd-prometheus-exporter.py b/server/sshd-prometheus-exporter.py new file mode 100755 index 0000000..f749fc6 --- /dev/null +++ b/server/sshd-prometheus-exporter.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python + +import enum +import ipaddress +import logging +import time +import urllib + +import click +import prometheus_client + +log = logging.getLogger(__name__) + +TcpState = enum.Enum('TcpState', [ + 'TCP_ESTABLISHED', + 'TCP_SYN_SENT', + 'TCP_SYN_RECV', + 'TCP_FIN_WAIT1', + 'TCP_FIN_WAIT2', + 'TCP_TIME_WAIT', + 'TCP_CLOSE', + 'TCP_CLOSE_WAIT', + 'TCP_LAST_ACK', + 'TCP_LISTEN', + 'TCP_CLOSING', +]) + + +def parse_address(address: str): + host, port = address.split(':') + return ipaddress.ip_address(bytes.fromhex(host)), int(port, 16) + + +def parse_net_tcp(path: str): + """Read and parse network statistics in the /proc/net/tcp format. + + See https://www.kernel.org/doc/html/v5.10/networking/proc_net_tcp.html. + """ + with open(path) as f: + keys = next(f).split() + for line in f: + values = line.split() + mapping = dict(zip(keys, values)) + mapping['sl'] = int(mapping['sl'].rstrip(':')) + mapping['local_address'] = parse_address(mapping['local_address']) + try: + mapping['remote_address'] = parse_address(mapping['remote_address']) + except KeyError: + mapping['remote_address'] = parse_address(mapping.pop('rem_address')) + mapping['st'] = TcpState(int(mapping['st'], 16)) + yield mapping + + +connections = prometheus_client.Gauge( + 'connections', 'Number of active SSH sessions', namespace='sshd') + + +filenames = ('/proc/net/tcp', '/proc/net/tcp6') + + +def filter_stat(mapping): + return (mapping['local_address'][1] == 22 and + mapping['st'] == TcpState.TCP_ESTABLISHED) + + +@connections.set_function +def count_connections(): + return sum( + filter_stat(mapping) + for filename in filenames + for mapping in parse_net_tcp(filename)) + + +def host_port(host_port_str): + # Parse netloc like it is done for HTTP URLs. + # This ensures that we will get the correct behavior for hostname:port + # splitting even for IPv6 addresses. + return urllib.parse.urlparse(f'http://{host_port_str}') + + +@click.command() +@click.option( + '--prometheus', type=host_port, default=':8000', show_default=True, + help='Hostname and port to listen on for Prometheus metric reporting') +@click.option( + '--loglevel', type=click.Choice(logging._levelToName.values()), + default='DEBUG', show_default=True, help='Log level') +def main(prometheus, loglevel): + logging.basicConfig(level=loglevel) + + prometheus_client.start_http_server(prometheus.port, + prometheus.hostname or '0.0.0.0') + log.info('Prometheus listening on %s', prometheus.netloc) + + while True: + time.sleep(1) + + +if __name__ == '__main__': + main()