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"
)