现在要做这么一件事情,批量监控域名是否快要到期,并作出提醒。
卡卡西
-
可以确定的是,获取一个网站的域名证书,这是一个加密网络通信必须要实现的最基础的功能,HTTPS请求肯定要用到这个。 平常我们日常使用到的基本上是已经封装好的更高层的东西,比如
requests
包。现在我们并不需要一个完整的HTTPS请求,只需要最开始建立连接用到的证书。 -
我坚信python本身底层应该提供相关API,但是给我文档我也不太好怎么下手。
https://docs.python.org/3.8/library/😳
我更倾向于看看有没有现成的例子可以参考。 -
打开gayhub,关键词
https cert
找到一个项目tls-cert-discovery
这个项目功能是批量扫描ip的443端口, 探测绑定的证书域名。
抠下来获取cert,并从cert中提取信息的实现代码如下:import socket,ssl import OpenSSL cert = ssl.get_server_certificate((host, 443)) x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) for i in range(0, x509.get_extension_count()): ext = x509.get_extension(i) if "subjectAltName" in str(ext.get_short_name()): san = [] content = ext.__str__() for d in content.split(","): san.append(d.strip()[4:])
出错log
- 我发现这样子对有些域名可以,但是有些是会报错的
import ssl cert = ssl.get_server_certificate(('baidu.com', 443)) ok cert = ssl.get_server_certificate(('nicelee.top', 443)) 报错
-
报错是这样的:
... File "D:\Python\Python38\lib\ssl.py", line 1484, in get_server_certificate with context.wrap_socket(sock) as sslsock: File "D:\Python\Python38\lib\ssl.py", line 500, in wrap_socket return self.sslsocket_class._create( File "D:\Python\Python38\lib\ssl.py", line 1040, in _create self.do_handshake() File "D:\Python\Python38\lib\ssl.py", line 1309, in do_handshake self._sslobj.do_handshake() ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1108)
瞎折腾
百度一下、谷歌一下、stackoverflow一下,完全不管用。
- 这些解决方案不外乎:
- 它报了一个
SSLV3
的错,那我就想着禁用这个,直接指定协议使用TLS - 将支持的cipher套件设为ALL(一个服务器解决方案)
- 它报了一个
- 无脑试了一下,卒。
- 去看了一下官方文档,毫无头绪。
出错原因
- 先看看
get_server_certificate
干了什么# python ssl.py 源码 def get_server_certificate(addr, ssl_version=PROTOCOL_TLS, ca_certs=None): """Retrieve the certificate from the server at the specified address, and return it as a PEM-encoded string. If 'ca_certs' is specified, validate the server cert against it. If 'ssl_version' is specified, use it in the connection attempt.""" host, port = addr if ca_certs is not None: cert_reqs = CERT_REQUIRED else: cert_reqs = CERT_NONE # 上面的都是配置不用管,下面实现 # 创建一个上下文 context = _create_stdlib_context(ssl_version, cert_reqs=cert_reqs, cafile=ca_certs) # 创建一个socket连接 with create_connection(addr) as sock: # 将该socket升级为sslSocket with context.wrap_socket(sock) as sslsock: # 获取der证书 dercert = sslsock.getpeercert(True) # 获取pem证书 return DER_cert_to_PEM_cert(dercert)
- 再看看报错的地方。
这里有个注释,在ctx._wrap_socket()调用前需要处理server_hostname。
好家伙,我们调的时候直接传的是None。# python ssl.py 源码 def wrap_socket(self, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, session=None): # SSLSocket class handles server_hostname encoding before it calls # ctx._wrap_socket() return self.sslsocket_class._create( sock=sock, server_side=server_side, do_handshake_on_connect=do_handshake_on_connect, suppress_ragged_eofs=suppress_ragged_eofs, server_hostname=server_hostname, context=self, session=session )
- 破案了,我们在往域名服务器请求加密连接时,在握手环节并没有指定需要证书的域名(即SNI),导致了出错。
那么为什么有的域名可以成功,有的则会失败呢?
- 当我们请求的域名对应的服务器仅仅绑定了一个域名,或者设置了默认访问的域名,那么没有SNI可以成功。
- 当我们请求的域名对应的服务器仅仅绑定了多个域名,并且没有设置默认访问的域名,那么没有SNI,则会报错。因为服务器也不知道你要访问哪个。
这种情况在有CDN的情况下比较常见。
解决方案
- 更新最新版本
- 这应该算是一个python的bug吧,于是看了一下,发现已经有人提交并merge了,也就是说新版本就不会有这个问题。
- python/cpython/pull#16820
- 自定义实现
import socket,ssl import OpenSSL def get_server_certificate(domain, port): # 创建一个上下文 context = ssl.SSLContext() # 不进行证书合法性校验,检验出错会报一个SSLV3的错误,这会损失真正的错误信息 context.verify_mode = ssl.CERT_NONE context.check_hostname = False # 创建一个TCP socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 将该socket升级为sslSocket,并指定SNI with context.wrap_socket(s, server_hostname=domain) as sslSocket: # 开始连接服务器,注意这里如果要绕过CDN的话,可以传真实的IP sslSocket.connect((domain, port)) # 获取der证书 dercert = sslSocket.getpeercert(True) # 获取pem证书 return ssl.DER_cert_to_PEM_cert(dercert)
一个简单的实现的例子
import socket,ssl
import OpenSSL
from dateutil import parser
import time,datetime
def get_server_certificate(domain, port):
context = ssl.SSLContext()
context.verify_mode = ssl.CERT_NONE
context.check_hostname = False
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
with context.wrap_socket(s, server_hostname=domain) as sslSocket:
sslSocket.connect((domain, port))
dercert = sslSocket.getpeercert(True)
return ssl.DER_cert_to_PEM_cert(dercert)
def check(domain:str, validDays:int = 7, port:int = 443) -> (bool, str):
try:
cert = get_server_certificate(domain, port)
# cert = ssl.get_server_certificate((domain, port))
x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
dt_notAfter = parser.parse(x509.get_notAfter().decode("UTF-8"))
t_notAfter = time.mktime(dt_notAfter.timetuple())
t_now = time.time()
time_remain = (t_notAfter - t_now) / 3600 / 24
tips = ("距离证书到期还剩约 %.1f 天"%time_remain)
if time_remain < validDays:
return False, tips
for i in range(0, x509.get_extension_count()):
ext = x509.get_extension(i)
if b'subjectAltName' == ext.get_short_name():
content = ext.__str__()
for d in content.split(","):
subjectAltName = d.strip()[4:]
if isValidDomain(domain, subjectAltName):
return True, tips
return False, "域名与证书不匹配 %s, %s"%(domain, content)
except Exception as e:
# raise
return False, str(e)
def isValidDomain(domain, dn_pattern):
'''
仅仅是简单匹配,更全面的参考ssl._dnsname_match(dn, hostname)
'''
if domain == dn_pattern:
return True
if dn_pattern.startswith('*.') and domain.endswith(dn_pattern[1:]):
return True
return False
if __name__ == "__main__":
result, tips = check("nicelee.top")
# result, tips = check("baidu.com")
print(result, tips)