Shodan 在圈子里一直是个自带话题度的名字。很多人提到它第一反应是"互联网设备搜索引擎"或者"网络测绘",但真正做开发落地时,你会发现它远不止一个查询入口,而是一整套结构清晰、扩展性极强的基础数据服务。这篇文章我想抛开那些重复的入门介绍,直接以 Python 开发者的视角,拆一拆 Shodan 的 API 架构设计,再演示如何基于它做自定义扩展,把它接进你自己的数据管道、资产监控和告警体系里。
1. 先理解 Shodan 的底层数据模型
1.1 Banner 是灵魂
用过 Shodan 的人都知道,搜索结果里最核心的东西叫 Banner。它是一个服务在握手阶段返回的指纹信息,可能是一段 HTTP 响应头、一个 SSH 的版本字符串、一条 MySQL 的查询提示,甚至是一段 MQTT 的报文。Shodan 做的事情本质上是全球范围的服务指纹采集和索引,而 Banner 就是这套体系的原子单位。
在 Python SDK 里,你拿到的每个结果项本质上都是一个 Banner 字典。初次接触时容易犯的错是把 Banner 当成一个固定的扁平结构,其实它里面的字段会随着服务类型变化而完全不同。比如 HTTP 服务的 Banner 里会有http子字段,内含title、server、html这些;而 FTP 服务可能只有ftp子字段,记录简单的响应信息。这意味着你的数据清洗逻辑必须容忍字段的稀疏性。
1.2 Host 才是聚合单位
Banner 对应的是"IP 加端口"上的某个服务,Host 则是按 IP 维度聚合后的完整画像。每个 Host 下挂着一组端口列表、对应的 Banner 列表、地理位置信息、ASN、操作系统指纹等。
从开发角度看,这个设计非常聪明。它天然帮你区分了两类查询场景:针对特定 IP 的纵深调查,以及全网范围的关键字搜索。在 SDK 中对应的方法分别是host(ip)和search(query),二者返回的数据结构差异很大,后续的入库表设计也应该分开建,不要把两种数据强行塞进同一张表。
2. 认证、配额与请求机制
2.1 API Key 的定位
Shodan 的 API 使用 API Key 作为唯一认证凭证。它通过?key=xxx的方式附加在请求中,也可以用请求头的方式传递。这个机制简单直接,但把安全责任完全交给了开发者。
我见过不少人在代码仓库里硬编码 API Key,这是非常危险的做法。正确的做法是使用环境变量或密钥管理服务。在 Python 端,我习惯用pydantic或dotenv加载配置,保证 Key 不出现在代码和版本控制中。
2.2 速率限制的真相
在开发测试阶段,免费额度下的请求限制是每秒 1 次。付费版本会高一些,但即便付费也并非无限。Shodan 的限速策略是按"每秒请求数"来控制的,而不是按天计算总量,这一点和在用的其他服务不太一样。
这个设计对开发者来说意味着两件事:
- 你的代码必须考虑并发控制,不能盲目用线程池去并发拉数据。
- 批量任务需要做请求节流,用队列加固定速率的方式比较稳妥。
我通常用一个简单的令牌桶来做限流,配合重试机制,基本不会触发 429。
2.3 配额的理解与充值
Shodan 的配额是"搜索次数"和"结果条数"两个维度分开算的。搜索请求消耗查询次数,导出结果时如果使用minified=True参数则消耗结果配额更少。这是官方文档里不太显眼、但实际影响成本的关键参数。
在架构设计上,我建议把所有对 Shodan 的请求统一收敛到一个服务模块中,方便后续做配额统计和成本核算。不要把shodan.Shodan(api_key)这个客户端对象到处传,否则后期想统一加缓存和限流会非常被动。
3. Python SDK 的快速上手与工程化封装
3.1 安装与第一个请求
官方 SDK 安装很简单,pip install shodan即可。核心类只有一个,就是shodan.Shodan。
import shodan api = shodan.Shodan(os.environ["SHODAN_API_KEY"]) try: results = api.search("apache") for service in results["matches"]: ip = service["ip_str"] port = service["port"] print(f"{ip}:{port} - {service.get('http', {}).get('title', '')}") except shodan.APIError as e: print(f"Error: {e}")这个示例足以跑通流程,但距离工程化还差得远。你会发现search方法默认只返回第一页数据,而且如果请求触发限流,这个代码会直接抛异常中断。
3.2 封装一个可复用的客户端
在实际项目中,我倾向于封装一个自己的客户端类,把配置加载、日志、限流、重试、缓存全部做进去。核心思路是:不让业务代码感知到底层是 Shodan 还是别的数据源。
import time import logging from typing import Any, Dict, List, Optional import requests import shodan class ShodanClient: def __init__(self, api_key: str, min_backoff: float = 1.0, max_backoff: float = 10.0): self._api = shodan.Shodan(api_key) self._min_backoff = min_backoff self._max_backoff = max_backoff self._logger = logging.getLogger("shodan_client") def _request_with_retry(self, func, *args, **kwargs): backoff = self._min_backoff while True: try: return func(*args, **kwargs) except shodan.APIError as e: if e.code == 429: self._logger.warning("Rate limited, waiting %.1fs", backoff) time.sleep(backoff) backoff = min(backoff * 2, self._max_backoff) continue if e.code == 403: self._logger.error("Forbidden, check API key or usage rights") raise raise def search(self, query: str, page: int = 1, limit: Optional[int] = None) -> List[Dict[str, Any]]: results = self._request_with_retry( self._api.search, query, page=page, limit=limit ) return results.get("matches", [])这个封装解决了三个核心问题:限流退避、异常收敛、和分页参数透传。在调用方看来,只需要关心"给一个搜索词,拿回一组资产数据"。
3.3 数据字段的归一化处理
Shodan 返回的数据字段在不同服务、不同时间点上差异很大。为了让下游数据管道不使用脆弱的下标访问,我在封装层做了一层字段归一化。
def transform_banner(item: Dict[str, Any]) -> Dict[str, Any]: return { "ip": item.get("ip_str"), "port": item.get("port"), "org": item.get("org", ""), "asn": item.get("asn", ""), "hostname": item.get("hostnames", []), "timestamp": item.get("timestamp"), "banner": item.get("data", ""), }这层转换的价值在于,下游的数据库表结构是稳定的,不会因为 Shodan 某次字段调整而大规模返工。
4. 自定义扩展实现
4.1 扩展一:分页与去重入库
单一页面查询大多满足不了资产测绘的需求,尤其是当搜索词返回几万条结果时,必须处理分页。这里有个很多人踩过的坑:Shodan 的免费额度下,翻页越深,请求越容易被限流,而且页面过深时数据质量反而下降。更稳妥的方法是配合搜索结果去重,把同一 IP 不同端口的数据聚合后再入库。
我这里用 SQLite 做一套简单的资产存储:
import sqlite3 import json def store_banners(items: list[dict], db_path: str = "assets.db"): conn = sqlite3.connect(db_path) conn.execute( """CREATE TABLE IF NOT EXISTS banners ( ip TEXT, port INTEGER, data_json TEXT, first_seen TEXT, PRIMARY KEY (ip, port) )""" ) for item in items: conn.execute( "INSERT OR REPLACE INTO banners (ip, port, data_json) VALUES (?, ?, ?)", (item["ip"], item["port"], json.dumps(item)), ) conn.commit() conn.close()去重的原则是(ip, port)唯一键。实际过程中我发现,同一个端口前后两次抓取返回的 Banner 内容可能不同,这种变更信息很有价值,可以记录到变更表里,形成资产变动的历史。
4.2 扩展二:关键字资产监控与告警
自定义扩展最有价值的场景之一,是把 Shodan 从"手动查询工具"变成"主动监控引擎"。核心思路是定时跑一组固定的搜索词,比对结果变化,有新增或者敏感端口暴露时触发告警。
import sched import time from datetime import datetime monitor_queries = ["product:nginx", "port:3306 country:CN", "ssl.cert.subject.cn:example"] def check_and_alert(client: ShodanClient, query: str, known: set): matches = client.search(query, limit=100) current = {f"{m['ip']}:{m['port']}" for m in matches} new_assets = current - known if new_assets: push_alert(query, new_assets) def push_alert(query, new_assets): for asset in new_assets: print(f"[ALERT] {query} -> {asset}")实际落地时建议把known集合持久化到 Redis 或者数据库,否则每次重启进程都会重复告警。另外告警不能只靠打印,需要接入 Webhook 或邮件通知。
4.3 扩展三:多 API Key 负载均衡
如果你所在团队购买了多个 API Key,可以通过简单的轮询来提升吞吐量。改造ShodanClient,让它在内部持有多个认证 token 并轮换使用,这样能有效突破单 Key 的速率限制。
class ShodanPoolClient: def __init__(self, api_keys: list[str]): self._clients = [shodan.Shodan(key) for key in api_keys] self._counter = 0 def _next_client(self): client = self._clients[self._counter % len(self._clients)] self._counter += 1 return client def search(self, query, page=1): return self._next_client().search(query, page=page)这里有个注意点:轮询并不能真正突破账号层面的权限限制,如果多 Key 属于同一个主账号,配额可能仍是共享的。但如果是多个独立账号的 Key,吞吐量提升会非常明显。不过要小心,频繁轮换 Key 可能触发风控,建议在低并发场景下使用。
4.4 扩展四:把数据接入消息队列
很多场景下,我并不仅仅是想把数据落库,还需要把 Shodan 的发现结果投递给下游系统,比如漏洞扫描器、威胁情报平台、资产管理 CMDB。这时可以通过消息队列做解耦。
import pika import json def publish_to_queue(channel, queue_name: str, payload: dict): channel.basic_publish( exchange="", routing_key=queue_name, body=json.dumps(payload).encode(), )结合上面的分页拉取逻辑,每个 Banner 可以作为一个消息体发布。消费者只管订阅,做自己的数据处理。这套模式非常适合团队协作,数据采集是一个组,消费和处理是另一个组。
5. 常见问题与排查技巧实录
5.1 429 限流的处理细节
我刚开始做数据拉取时,用了一个简单循环去调search(),结果跑到第五页就频繁触发 429。排查后发现不是代码逻辑的问题,而是确实请求过于密集。解决思路不复杂,但需要严谨:
- 每次请求之间至少间隔 1 秒。
- 对单次任务设置最大重试次数,超过后记日志并进入下一轮。
- 定期任务之间加入随机抖动,避免和其他同事的任务同步执行造成瞬时高峰。
给出一段实际在用的限流器示例:
import threading import time class RateLimiter: def __init__(self, max_per_second: float): self._min_interval = 1.0 / max_per_second self._lock = threading.Lock() self._next_time = 0.0 def wait(self): with self._lock: now = time.monotonic() wait_time = self._next_time - now if wait_time > 0: time.sleep(wait_time) self._next_time = time.monotonic() + self._min_interval5.2 查询语法拼错却不知道
搜索词语法是 Shodan 使用体验中比较"隐蔽"的一环。比如port:3306和port = 3306的写法是不一样的,前者才是合法的过滤语法。还有country:CN这种过滤条件,如果拼成country=CN就不会报错,但结果为空。这种静默失败很容易让开发者误以为是 API 的问题。
我的经验是:在封装层中加入查询语法的基础校验,至少检查是否包含:这个核心分隔符。更完善的做法是维护一个过滤字段白名单,拼查询前先用正则或字典预校验。
ALLOWED_FILTERS = {"port", "country", "product", "hostname", "ssl.cert.subject.cn"} def validate_query(query: str) -> bool: for part in query.split(): if ":" in part: field = part.split(":")[0] if field not in ALLOWED_FILTERS: raise ValueError(f"Unsupported filter: {field}") return True5.3 返回数据中的编码问题
Banner 数据本质上是网络服务的原始指纹,包含各种古怪字符很正常。Python 的requests库在解码时偶尔会抛异常,导致整批数据入库失败。我建议在解析层强行使用"替换错误"的策略。
raw = resp.content text = raw.decode("utf-8", errors="replace")如果是直接把json()解析的结果存库,最好也做一层字符串清洗,把不可见字符、控制字符统一替换成空格,避免污染数据库字段。
5.4 API Key 的轮换与租户隔离
在一个稍大的团队里,不同业务线可能共用同一个数据采集服务。如果大家都用同一把 Key,一旦其中某个任务写了死循环,会把整条链路的配额全打爆。我的做法是按业务线分配独立的 Key,甚至独立的小服务实例。
在服务端做租户隔离,在代码层面就是配置项分开加载。每个微服务只持有自己业务所需的 Key,配额统计也按业务线维度记录。
5.5 搜索结果的去重与增量更新
资产不是静态的,IP 的端口可能在短期内频繁变动。如果每天全量拉取一次,数据量很大而且大部分是无效重复。实际工程中我采用了两层策略:
- 短期增量:按
first_seen字段记录首次发现时间,24 小时内只更新状态。 - 长期全量:每周跑一次全量,用于修正过期数据。
这个策略的收益非常直接:API 配额消耗量下降了一个数量级,数据库的写入压力也小了很多。
6. 绕不开的合规边界与使用建议
使用 Shodan 做资产发现和安全研究本身是合法的,但要注意合法用途的边界。我个人的建议是:
- 只对自己拥有或者有书面授权的目标做深度数据采集。
- 不利用 Shodan 的结果去扫描或渗透未经授权的系统。
- 采集到的数据中如果包含个人身份信息或敏感内容,要按数据合规要求处理。
- 对外分享数据时做脱敏处理,至少隐藏 IP 完整地址和 Banner 细节。
这些不只是道德要求,也是避免正规项目翻车的底线。在开发能力之外,把边界意识纳入架构设计,比任何代码优化都重要。
我现在的自动化数据管道已经跑了半年多,每天定时拉取、入库、比对变化、推送到内部平台。对比最初每人抱着浏览器去 Ctrl+F 找资产的方式,效率完全不在一个量级。回到标题里的"深度解析"这个词,我认为深度不在于用了多少 API 方法,而在于你是否理解了这套系统的数据结构、配额模型和扩展点。把这些想清楚,Shodan 就不只是一个搜索工具,而是一个可以稳定供数的基础服务,你也才能真正把它的数据能力变成你自己业务的一部分。
最后分享一个长期维护的小技巧:给所有 Shodan 相关的定时任务加一个统一的状态检查和错误日志表。有段时间我某个采集任务每周五都会静默失败,因为目标站点做了访问控制,而我的代码没有把空结果当成异常处理。因为没有日志,这个问题拖了好几周才发现。自从把所有任务的结果量和错误码都落表,异常业务就能在第一时间暴露出来,排查效率完全不一样。