server/app/plugins/machines.py (165 lines of code) (raw):

#!/usr/bin/env python3 # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. """ Machine fingerprint scanner and checker app thingy Spits out: - index.html: a human readable page with all fingerprints - machines.json: a json file to keep score of previous fingerprints - fp.json: a json file for nodeping to work with """ import asyncio from ..lib import config from .. import plugins import aiohttp import aiohttp.client_exceptions import functools import os import json import requests import subprocess import base64 import hashlib import time import datetime import fnmatch import sys KEYSCAN = "/usr/bin/ssh-keyscan" IPDATA = requests.get( "https://svn.apache.org/repos/infra/infrastructure/trunk/dns/zones/ipdata.json", timeout=120 ).json() IGNORE_HOSTS = ( "bb-win10", "ci.hive", "ci2.ignite", "cloudstack-gateway", "corpora.tika", "demo.*", "donate", "jenkins-*", "metrics.beam", "mtcga*.ignite", "reviews.ignite", "vpn.plc4x", "weex", "pnap-us-west-generic-nat", "bb-win-azr-1", "bb-win-azr-2", "www", "www.play*", ) FPDATA = {} COUNT = 0 def get_fps(): if bool(globals()["FPDATA"]): return globals()["FPDATA"] else: return {"HTML": "<h4>Scanning machine fingerprints...</h4>"} class Host: def __init__(self, name, ip): self.ips = [ip] self.name = name def l2fp(line): """Public key to fingerprints""" key = base64.b64decode(line.strip().split()[-1]) fp_plain = hashlib.md5(key).hexdigest() fp_md5 = ":".join(a + b for a, b in zip(fp_plain[::2], fp_plain[1::2])) fp_sha256 = ( base64.b64encode(hashlib.sha256(key).digest()).decode("ascii").rstrip("=") ) return fp_md5, fp_sha256 async def fpscan(): old_hosts = {} hosts = {} for ip, name in IPDATA.items(): if name in hosts: hosts[name].ips.append(ip) else: hosts[name] = Host(name, ip) reachable = 0 unreachable = [] all_notes = [] for name, host_data in sorted(hosts.items()): if any(fnmatch.fnmatch(name, pattern) for pattern in IGNORE_HOSTS): continue ipv4 = [x for x in host_data.ips if "." in x][0] try: kdata_rsa = await asyncio.create_subprocess_shell( f"{KEYSCAN} -T 1 -4 -t rsa {name}.apache.org", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE # (KEYSCAN, '-T', '1', '-4', '-t', 'rsa' f"{name}.apache.org"), stderr=asyncio.subprocess.PIPE ) kdata_ecdsa = await asyncio.create_subprocess_shell( f"{KEYSCAN} -T 1 -4 -t ecdsa {name}.apache.org", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE # (KEYSCAN, '-T', '1', '-4', '-t', 'ecdsa', f"{name}.apache.org"), stderr=asyncio.subprocess.PIPE ) keydata_rsa, rsa_stderr = await kdata_rsa.communicate() keydata_ecdsa, ecdsa_stderr = await kdata_ecdsa.communicate() if not keydata_rsa: unreachable.append(name) continue gunk, rsa_sha256 = l2fp(keydata_rsa) gunk, ecdsa_sha256 = l2fp(keydata_ecdsa) reachable += 1 now = int(time.time()) now_str = datetime.datetime.fromtimestamp(now).strftime("%c") if name not in old_hosts: old_hosts[name] = { "ipv4": ipv4, "fingerprint_ecdsa": ecdsa_sha256, "fingerprint_rsa": rsa_sha256, "first_seen": now, "last_seen": now, "okay": True, "notes": [], } else: oho = old_hosts[name] if oho["fingerprint_rsa"] != rsa_sha256: note = f"Fingerprint of {name} changed at {now_str}, from {oho['fingerprint_rsa']} to {rsa_sha256}!" oho["okay"] = False oho["notes"].append(note) all_notes.append(note) # print(note) except KeyboardInterrupt: break except subprocess.CalledProcessError as e: unreachable.append(name) stamp = time.strftime("%Y-%m-%d %H:%M:%S %z", time.gmtime()) rtxt = "" if unreachable: rtxt = f"({len(unreachable)} hosts not reachable)" html = ( """ <style> #fingerprints td:last-child { font-size: 0.8rem; font-family: sans-serif; } #fingerprints tr:nth-child(even) { background-color: #f4f4f4 } #fingerprints>kbd,#fingerprints td:not(:first-child) kbd,#fingerprints li>kbd { -moz-border-radius:3px; -moz-box-shadow:0 1px 0 rgba(0,0,0,0.2),0 0 0 2px #fff inset; -webkit-border-radius:3px; -webkit-box-shadow:0 1px 0 rgba(0,0,0,0.2),0 0 0 2px #fff inset; background-color:#f7f7f7; border:1px solid #ccc; border-radius:3px; box-shadow:0 1px 0 rgba(0,0,0,0.2),0 0 0 2px #fff inset; color:#333; display:inline-block; font-family:monospace; font-size:11px; line-height:1.4; margin:0 .1em; padding:.1em .6em; text-shadow:0 1px 0 #fff; } #fingerprints th, #fingerprints td { padding: 6px !important; } </style> """ + f"<h2>{reachable} verified hosts {rtxt} @ {stamp}</h2>" ) html += "<table id='fingerprints' cellpadding='6' cellspacing='0' style='border: 0.75px solid #333;'><tr><th>Hostname</th><th>IPv4</th><th>RSA Fingerprint (SHA256)</th><th>ECDSA Fingerprint (SHA256)</th><th>Status</th></tr>\n" # Print each known host for name, data in sorted(old_hosts.items()): if name in hosts: status = "Verified (OK)" if name in unreachable: status = "Unreachable" if data["notes"]: status = "<span color='F70'>CHANGED</span>" html += ( "<tr style='background: inherit;'><td><kbd><b>%s</b></kbd></td><td><kbd>%s</kbd></td><td><kbd>%s</kbd></td><td><kbd>%s</kbd></td><td>%s</td></tr>\n" % ( name, data["ipv4"], data["fingerprint_rsa"], data["fingerprint_ecdsa"], status, ) ) # Print unknown unreachables at the bottom for name in unreachable: if name in old_hosts: continue data = hosts[name] ipv4 = [x for x in data.ips if "." in x][0] html += ( "<tr style='background: inherit;'><td><kbd><b>%s</b></kbd></td><td><kbd>%s</kbd></td><td><kbd>N/A</kbd></td><td><kbd>N/A</kbd></td><td>Unreachable</td></tr>\n" % (name, ipv4) ) html += "</table>" globals()["FPDATA"] = { "HTML": html, "changes": {"changed": len(all_notes), "notes": all_notes}, "old_hosts": old_hosts, } print("Fingerprint roster updated!") async def fp_scan_loop(): while True: await fpscan() await asyncio.sleep(43200) plugins.root.register( fp_scan_loop, slug="machines", title="Machine Fingerprints", icon="bi-fingerprint" )