
「我这边连不上你那个服务」------ 用清单驱动的 TCP 连通性只读看板,cpolar 让远端同事自己取证
前言
群里最常见的一句话就是「我这边连不上你那个服务」。
然后就是一轮互相甩锅:你让对方 ping 一下,对方回「ping 通了」;你让他 telnet 5432,他说 Windows 没装 telnet 客户端;你截图说本机连得上,他说他那边超时。
折腾半小时,其实双方测的根本不是同一条路径。
问题不在于谁不努力,在于两个人手里没有同一份测量口径。远端同事测的是「他的电脑 → 服务」,我测的是「我的电脑 → 服务」,中间隔着不同的 NAT、不同的防火墙策略、不同的 DNS 解析结果。两个不同的起点,结果不一致是正常的。
这篇做的事很具体:在本机起一个只读看板,它读一份我手工维护的 host:port 清单,对清单里的每个目标做一次 TCP 握手并记录耗时,然后把「通不通、多久、什么时候测的」渲染成一张表。再用 cpolar 把这张表映射到公网,发给远端同事。
他打开链接看到的是「从我调用的这台机器出发,到这些目标的可达情况」。配合他自己那一侧的实测,能很快定性是网络段的问题还是服务本身的问题。
先说边界,免得误会:
- 这是主动测量,但只测清单内的目标,清单是静态文件,最多 50 条
- 不做端口扫描、不做网段遍历、不做爆破
- 页面没有任何「加目标」「改清单」「立即探测」的入口,清单只能人工改文件
- 只判断 TCP 三次握手能不能完成,不抓 banner、不做协议识别、不试凭据
如果你看过之前那篇「端口占用只读清单」(8111),这里是它的反面:8111 看的是本机谁在监听 ,是一个静态事实;这篇量的是能不能连上,是一个带时间的主动结果。两者一个回答「谁在听」,一个回答「连不连得通」,别混着用。

图 2:8111 看「谁在监听」,8115 看「能不能连上」
1 先想清楚这个看板到底解决什么
1.1 一次「连不上」的排查,缺的往往不是工具
远端同事的抱怨通常是「连不上」。这三个字背后至少四种情况:
- 目标服务没跑,端口拒绝了连接
- 服务在跑,但中间防火墙没放通,SYN 发出去没回应,最后超时
- 通了,但路径绕得远,握手要一两秒,客户端自己的超时先到了
- 目标主机域名在对方那边解析到了别的地址
这四种情况的处置方式完全不同。第一种要去看服务;第二种要去看防火墙和安全组;第三种是链路质量问题,要换线路或者调客户端超时;第四种要查 DNS。
如果只说「连不上」,就只能靠猜。而如果有人能拿出一张表,写着「从 A 机器出发,到 10.0.2.11:5432 耗时 12ms 成功,到 10.0.3.21:6379 超时 2000ms」,定性就快多了。
这张表的价值不在于它多精确,在于它把起点钉死了。起点固定,对比才有意义。

图 1:从人工清单到握手结果再到公网自查的整条链路
1.2 为什么必须是「清单驱动」
如果做成「输入框里填地址、点一下探测」,那就变成了一个开放探测服务。
部署在公网上任何一个开放探测入口,最后都会被拿来干别的事,比如测别人家内网的存活主机,或者当成一次性端口扫描器。这不是道德问题,是接口设计的问题:一个能接受任意目标参数的探测接口,本质上就是一个扫描器。
所以这里的边界必须写进代码结构里,不是靠自觉:
- 目标只能来自一个本地 JSON 文件,路径在代码里写死
- 页面不解析任何 URL 查询参数
- 清单最多 50 条,超了直接启动失败,不做截断
- 不提供任何写清单的入口
这样即使有人想拿它当扫描器,也没有可以注入目标的地方。
2 环境准备
2.1 只在 Python3 标准库上做
整篇的依赖只有 Python3 自带的 http.server、socket、json、html、threading。
用 python3 -V 确认一下版本,3.8 以上都行。不需要 pip 装任何东西。
bash
python3 -V
# Python 3.11.6 (示例输出)
之所以不用 Flask、不用 aiohttp,是因为这个服务只有三个路由、一张表,标准库完全够用。少一层依赖,就少一层「同事拿过去跑不起来」的概率。
2.2 目录结构
bash
mkdir -p ~/tcp-board
cd ~/tcp-board
# 后续两个文件放在这里:targets.json、board.py
targets.json 是清单,board.py 是服务。两个文件放在同一目录,服务启动时用相对脚本位置的绝对路径读取清单------不要用 os.getcwd(),不然从别的目录启动就会读不到。
2.3 绑定地址固定为回环
代码里写死 127.0.0.1。不监听 0.0.0.0,也不监听内网网卡地址。
这意味着局域网里别人也打不开,只有本机能访问。对外访问一律走 cpolar 隧道。这一条很关键:如果直接监听内网网卡,等于在办公室网络上又开了一个没有认证的 HTTP 服务,而走隧道的话,至少还有一个可控的入口地址可以随时关掉。
3 写清单:手工维护的 targets.json
清单长这样:
json
[
{ "label": "订单库", "host": "10.0.2.11", "port": 5432, "expect": "up" },
{ "label": "缓存主", "host": "10.0.2.12", "port": 6379, "expect": "up" },
{ "label": "报表机", "host": "10.0.3.21", "port": 8080, "expect": "up" },
{ "label": "老网关", "host": "10.0.9.9", "port": 443, "expect": "down" }
]
字段只有四个:
| 字段 | 说明 |
|---|---|
label |
人看的中文名,随便写,不参与探测 |
host |
目标主机,写 IP 或域名 |
port |
1~65535 的整数 |
expect |
up 或 down,是期望值,看板拿它做颜色判定 |
expect 这个字段容易被忽略,但它有用。有些目标本来就应该不通,比如一个已经下线但还没从清单里删掉的老网关。标成 down,页面显示超时就是「符合预期」,是灰色而不是红色,避免每次刷新都有一个红灯在那闪,看久了没人当回事。
几条维护规矩:
- 只写自己负责的内网已知服务。不要因为「顺手」把别人的网段加进来
- 不超过 50 条。超了服务直接拒绝启动,这是硬限制
- 改了文件要重启服务才生效。代码里不做热加载,因为热加载意味着有个文件监听循环,多一个会出错的地方。重启服务就一条命令,没必要省
label里不要写凭据、不要写账号,这个字段会原样出现在公网页面上
清单里出现一个不可达的目标是正常情况,不是错误。看板应该把它显示成红色的「超时」,而不是让整个页面 500。这一点后面写代码时会专门处理。
4 探测逻辑:一次握手,一个毫秒数
4.1 用 socket.create_connection 计时
核心就几行:
python
import socket, time
def probe(host, port, timeout=2.0):
start = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout):
ms = int((time.monotonic() - start) * 1000)
return {"status": "ok", "ms": ms}
except socket.timeout:
return {"status": "timeout", "ms": int(timeout * 1000)}
except ConnectionRefusedError:
return {"status": "refused", "ms": int((time.monotonic() - start) * 1000)}
except socket.gaierror:
return {"status": "dns_error", "ms": None}
except OSError:
return {"status": "other", "ms": None}
几点说明:
用 time.monotonic() 而不是 time.time()。系统时间会被 NTP 校正,跳一下就会出现负延迟。monotonic 是单调递增的,不会倒着走。
超时设 2 秒。这个值是写死的常量,不给页面改。2 秒的理由:内网正常握手一般在 100ms 以内,链路绕一下也就几百毫秒,2 秒还没回来基本可以判定为被丢包或者被防火墙静默掉了。设太长会让整页刷新变慢,设太短又容易误判慢链路。
异常分类要具体 。timeout 和 refused 是完全不同的信号:超时说明 SYN 发出去了没回音,大概率是防火墙 DROP;refused 说明对方回了 RST,端口上没有服务在听。两种情况的排查方向完全不同,不能都笼统写成「失败」。
dns_error 单独拎出来,因为域名解析失败和网络不通是两回事。
最后一个 except OSError 是兜底。网络错误种类很多,没办法穷举,兜住就行,不要让它冒到 HTTP 层变成 500。
4.2 延迟只代表握手
这里必须写清楚一件事:这个毫秒数是 TCP 握手耗时,不等于业务响应时间。
一个 HTTP 服务握手 8ms(示例值),但一个查询跑 3 秒。有人看到「8ms,通得很好」就去怀疑业务代码,方向就偏了。
反过来,一个 CDN 后面的域名,TCP 握手连到了边缘节点,非常快,但你的源站已经挂了时,这里照样显示成功。握手成功不代表业务可用。
页面上的文案统一写成「TCP 握手」,不写「响应时间」,避免误导。

图 4:延迟只覆盖握手那一段,业务耗时不在看板里
4.3 采集放在独立线程,不让页面卡住
http.server 的默认实现是单线程的。每次请求 GET / 都要串行探测最多 50 个目标,如果每个目标都超时 2 秒,一个请求就要占用 100 秒,期间其他请求全部排队。
解决办法是用后台采集线程:
- 主线程只负责渲染,读内存里的最新采集结果
- 采集线程每 30 秒跑一轮,串行探测全部目标
- 有一把锁保护结果字典,主线程读的时候拷贝一份,不长时间持锁
用 ThreadingHTTPServer 也可以,但那只是让 HTTP 请求并发,探测本身还是会拖慢响应。后台线程 + 缓存是更直接的做法。
串行探测而不是并发,是因为清单最多 50 条,2 秒超时下最坏 100 秒一轮。如果你把并发开起来,50 条同时对 50 个目标发 SYN,在别人眼里就是这个行为很可疑。串行慢一点,但对被探测方友好。
5 只读服务:路由、方法、越界
5.1 只有三个入口
| 方法 | 路径 | 返回 |
|---|---|---|
| GET | / |
看板 HTML |
| HEAD | / |
同 GET,无 body |
| GET | /healthz |
{"status":"ok","targets":4} |
就这三个。任何其他组合一律处理掉:
- 不在白名单里的路径 → 404
- 白名单路径但方法不对(PUT/POST/DELETE/PATCH)→ 405,带
Allow: GET, HEAD
注意 /healthz 只允许 GET,不允许 HEAD。严格一点,规则简单,测试也好写。
5.2 代码骨架
下面是完整可运行的骨架。标注了「骨架」的地方是结构完整但需要在你的环境里补细节的部分。
python
#!/usr/bin/env python3
# board.py ------ 清单驱动的 TCP 连通性只读看板(骨架)
import html
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
BIND_HOST = "127.0.0.1"
BIND_PORT = 8115
TARGETS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "targets.json")
PROBE_TIMEOUT = 2.0
CACHE_TTL = 30
MAX_TARGETS = 50
_state = {"collected_at": None, "rows": []}
_lock = threading.Lock()
def load_targets():
with open(TARGETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("targets.json 必须是数组")
if len(data) > MAX_TARGETS:
raise ValueError(f"清单超过上限 {MAX_TARGETS} 条")
clean = []
for item in data:
host = str(item["host"])
port = int(item["port"])
if not (1 <= port <= 65535):
raise ValueError(f"端口越界: {port}")
clean.append({
"label": str(item.get("label", host)),
"host": host,
"port": port,
"expect": "down" if item.get("expect") == "down" else "up",
})
return clean
def probe(host, port):
start = time.monotonic()
try:
with socket.create_connection((host, port), timeout=PROBE_TIMEOUT):
return {"status": "ok", "ms": int((time.monotonic() - start) * 1000)}
except socket.timeout:
return {"status": "timeout", "ms": int(PROBE_TIMEOUT * 1000)}
except ConnectionRefusedError:
return {"status": "refused",
"ms": int((time.monotonic() - start) * 1000)}
except socket.gaierror:
return {"status": "dns_error", "ms": None}
except OSError:
return {"status": "other", "ms": None}
def collector(targets):
while True:
rows = []
for t in targets:
r = probe(t["host"], t["port"])
rows.append({**t, **r})
with _lock:
_state["rows"] = rows
_state["collected_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
time.sleep(CACHE_TTL)
def render(rows, collected_at):
def verdict(r):
if r["expect"] == "up":
return "ok" if r["status"] == "ok" else "bad"
return "ok" if r["status"] != "ok" else "bad"
line = []
for r in rows:
v = verdict(r)
ms = "-" if r["ms"] is None else f'{r["ms"]} ms'
line.append(
f'<tr class="{v}"><td>{html.escape(r["label"])}</td>'
f'<td>{html.escape(r["host"])}:{r["port"]}</td>'
f'<td>{html.escape(r["status"])}</td>'
f'<td>{ms}</td><td>{html.escape(r["expect"])}</td></tr>'
)
body = "\n".join(line)
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>TCP 连通性看板</title>
<style>
body{{font-family:system-ui,sans-serif;margin:2rem}}
table{{border-collapse:collapse}}
th,td{{border:1px solid #ccc;padding:6px 10px;text-align:left}}
tr.ok{{background:#eef9ee}} tr.bad{{background:#fdeeee}}
</style></head><body>
<h1>TCP 连通性看板</h1>
<p>采集时间:{html.escape(collected_at or "未完成首轮采集")}</p>
<p>仅代表 TCP 握手可达性,不代表业务可用。</p>
<table><thead><tr><th>目标</th><th>地址</th><th>结果</th>
<th>握手耗时</th><th>期望</th></tr></thead><tbody>
{body}
</tbody></table></body></html>"""
class Handler(BaseHTTPRequestHandler):
server_version = "tcpboard"
sys_version = ""
def _send(self, code, body, ctype, extra=None):
raw = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(raw)
def _path(self):
return urlsplit(self.path).path
def _not_found(self):
self._send(404, '{"error":"not_found"}', "application/json")
def do_GET(self):
p = self._path()
if p == "/":
with _lock:
rows = list(_state["rows"])
ts = _state["collected_at"]
self._send(200, render(rows, ts), "text/html; charset=utf-8")
elif p == "/healthz":
with _lock:
n = len(_state["rows"])
self._send(200, json.dumps({"status": "ok", "targets": n}),
"application/json")
else:
self._not_found()
def do_HEAD(self):
if self._path() == "/":
with _lock:
rows = list(_state["rows"])
ts = _state["collected_at"]
self._send(200, render(rows, ts), "text/html; charset=utf-8")
else:
self._not_found()
def _deny(self):
self._send(405, '{"error":"method_not_allowed"}', "application/json",
{"Allow": "GET, HEAD"})
do_POST = do_PUT = do_DELETE = do_PATCH = do_OPTIONS = _deny
def main():
targets = load_targets()
threading.Thread(target=collector, args=(targets,), daemon=True).start()
srv = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
print(f"listening on http://{BIND_HOST}:{BIND_PORT} targets={len(targets)}")
srv.serve_forever()
if __name__ == "__main__":
main()
骨架里有几处值得单独点出来:
_path() 用的是 urlsplit(self.path).path 。查询参数被直接丢掉。所以 ?host=1.2.3.4&port=22 这种请求,p 还是 /,照常返回看板,参数完全不起作用。这就是「忽略动态目标参数」的实现方式------不是判断后拒绝,而是从结构上就没接。
do_HEAD 只对 / 放行 。/healthz 的 HEAD 会走 404,这是刻意设计的,规则简单。
_deny 挂给所有写方法 。Python 的 BaseHTTPRequestHandler 对未实现的方法默认返回 501,所以必须显式定义 do_POST、do_PUT 等,否则 POST 会返回 501 而不是 405。这里用赋值的方式一次性绑定,少写几行。
404 的响应体是固定字符串 ,不包含 self.path。如果回显请求路径,/../../etc/passwd 这样的请求就会把路径原样打回给调用方,虽然泄露不了文件内容,但会暴露服务的路径处理行为,没必要。
越界路径天然被挡住 。因为这个 handler 根本不碰文件系统------没有任何 open() 基于请求路径。/etc/passwd 是一个不在白名单里的字符串,直接 404;/../x 同理,urlsplit 不做路径归一化,它就是一个普通字符串,不匹配 / 就走 404。
这点很重要:目录穿越漏洞的前提是「用请求路径拼文件路径」。这里根本不存在这个动作,所以穿越无从谈起。
5.3 空结果的处理
如果服务刚起来,第一轮采集还没跑完,rows 是空列表。这时页面渲染出来是一张只有表头的表格,采集时间显示「未完成首轮采集」。
不要在这里返回 500,也不要返回一个假数据。空就是空,看板自己会说明状态。

图 3:三个入口、405 与 404 组成的只读骨架
6 本机自检
服务跑起来之后,先用 curl 把边界验证一遍。这一步不是为了测试而测试,是确认「只读」这件事真的落在代码里了。
6.1 正常请求
bash
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:8115/
# 200 text/html; charset=utf-8
curl -s -i http://127.0.0.1:8115/healthz
# HTTP/1.1 200 OK
# Content-Type: application/json
# {"status": "ok", "targets": 4}
curl -s -I http://127.0.0.1:8115/
# HTTP/1.1 200 OK
# Content-Type: text/html; charset=utf-8
targets 的数字应该等于清单条数。对不上就说明 load_targets() 里有条目被丢掉了,回头检查 JSON 格式。
6.2 写方法必须 405
bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8115/
# 405
curl -s -i -X PUT http://127.0.0.1:8115/healthz | grep -i "^allow:"
# allow: GET, HEAD
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:8115/
# 405
Allow 响应头必须存在且内容准确。这是 HTTP 规范要求,也是判断服务「有没有真的拒绝写操作」最直接的证据。
如果这里返回 501,说明 do_POST 那行赋值没生效,检查是不是被后面的代码覆盖了。
6.3 越界路径必须 404
bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8115/../etc/passwd
# 404
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8115/%2e%2e/etc/passwd"
# 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8115/etc/hosts
# 404
curl -s http://127.0.0.1:8115/etc/hosts
# {"error":"not_found"}
第三个和第四个才是关键:响应体里不能出现 /etc/hosts 的原文,也不能出现服务器上的真实文件路径。
顺带说明,第一条 curl 里带 ../,curl 在发请求前会自己做一次路径归一化,把 ../ 消掉。要看服务端真实行为,用 curl --path-as-is:
bash
curl --path-as-is -s -o /dev/null -w "%{http_code}\n" \
http://127.0.0.1:8115/../etc/passwd
# 404
6.4 清单里有不可达目标时不能 500
这一条是整个看板最需要验证的。把 targets.json 里某一行的 host 改成一个不存在的内网地址,比如 10.0.99.99,重启服务,刷新页面。
期望看到:
- 页面正常返回 200
- 那一行显示
timeout,耗时约2000 ms,底色是红色(如果expect是up) - 其他行不受影响,正常显示
如果这里出现 500,通常是因为异常没兜住。检查 probe() 的 except OSError 是不是排在 socket.timeout 后面------socket.timeout 是 OSError 的子类,顺序反了会全部落到兜底分支。
6.5 验证参数注入无效
bash
curl -s http://127.0.0.1:8115/?host=8.8.8.8\&port=53 | head -3
# 返回的是普通看板页,和 / 完全一样
页面内容不变,说明查询参数没有被读取。想更严格一点,可以在探测前后对目标机器抓一次包确认没有新连接,但一般看页面内容就足够判断了。
7 用 cpolar 把看板交给远端同事
7.1 安装与认证
cpolar 的安装按官方文档走,各平台都有对应的包。安装完成后在网页端拿到 authtoken,执行一次认证:
bash
cpolar authtoken <你的authtoken>
token 是账号凭据,不要贴到文章、群聊或者截图里。
7.2 起隧道
bash
cpolar http 8115
终端会输出一个公网地址,形如 https://xxxxxxxx.r9.cpolar.top,把这个地址发给远端同事就行。
他在浏览器打开,看到的就是你本机 127.0.0.1:8115 的那张表。
这里有个实际经验:地址直接发链接,不要发截图。截图的表是「我看到的」,链接的表是「他自己看到的」,他刷新一次就能拿到最新的采集结果,省掉一轮来回。
7.3 地址稳定性
免费版的随机地址在会话结束或者隧道重启后会变,24 小时内不保证稳定。也就是说,你关掉终端再起一次,地址就不一样了,之前发的链接失效。
如果这个看板要长期挂在群里让别人随时点,可以考虑固定二级子域名,这需要基础档及以上。
固定 TCP 端口和自定义域名需要专业档及以上。
具体价格和额度以官网当前页面为准,这里不做猜测。
7.4 一个必须说清楚的前提
如果远端同事在另一个内网,而且那个内网的出站策略是白名单,cpolar 的域名不在他的放通列表里,那他打不开这个链接。
这是本篇解决不了的问题。他那边要放通才能访问。别把「打不开看板」也当成看板的问题去排查。
同理,如果他的机器连 DNS 都出不去,那连域名解析都失败,更别说打开页面。这种情况先确认对方能不能访问任意公网地址。
7.5 用完就关
隧道开着就是公网可达。看完就 Ctrl+C 关掉。

图 5:cpolar 暴露方式与档位口径,用完就关
这一点不是客套:看板上虽然没有敏感数据,但里面列着你内网的主机地址和端口,属于拓扑信息的碎片。挂在公网上时间越长,被扫到的概率越高。cpolar 的随机域名不容易被猜到,但不该依赖这一点。
8 远端同事怎么配合
看板发出去之后,远端同事的操作是:打开链接,对照他自己的实际现象。
举个具体场景:
- 看板显示
10.0.2.11:5432 ok 12ms,同事说他连不上数据库 - 那问题多半不在网络段,先看连接字符串、账号权限、pg_hba 配置
反过来:
- 看板显示
10.0.3.21:8080 timeout 2000ms - 同事也说他连不上
- 两边一致,问题在服务侧或者那条链路上的防火墙,让他把现象截图,你带着结论去找防火墙负责人
还有一种:
- 看板显示
ok 8ms,同事那边超时 - 起点不同,结论就不同。这时候要看他的起点在哪,是不是网络段不同
这个看板不是要证明谁对谁错,是给两个人一份共同的参照。 参照一致,就往下查;参照不一致,就说明路径不同,得分别看。
9 隔离环境中的可执行实验提纲
下面是后续要在隔离环境里实际跑一遍的步骤。本轮没有真实探测任何目标,没有启动服务,没有起隧道,没有产生任何网络请求。
准备:
- 建一个隔离的虚拟机或者容器,配置两个可达的测试目标:一个开着端口(比如
python3 -m http.server 9000),一个不监听任何端口 - 准备
targets.json,包含四条:上面两个目标、一个不存在的内网地址10.0.99.99:443(期望down)、一个不存在的域名nonexistent.invalid:80(期望down)
执行:
- 启动
board.py,确认终端打印listening on http://127.0.0.1:8115 targets=4 - 等待一轮采集完成(约 5 秒),
curl http://127.0.0.1:8115/healthz应返回{"status":"ok","targets":4} curl打开/,逐行核对:开着的端口显示ok且耗时为几十毫秒量级;不监听端口显示refused;10.0.99.99显示timeout且耗时约 2000ms;不存在的域名显示dns_error- 连点刷新五次,观察页面上的采集时间戳------30 秒内应保持不变,证明缓存生效
- 跑
curl -X PUT /、curl -X POST /、curl -X DELETE /,全 405 且带Allow: GET, HEAD - 跑
curl --path-as-is /../etc/passwd、/%2e%2e/etc/passwd、/etc/hosts,全 404,响应体为{"error":"not_found"} - 在探测前后各抓一次目标机器的包,确认连接数等于清单条数,且没有发生重试或并发
- 起
cpolar http 8115,用另一台不同网络的机器打开公网地址,页面内容与本机一致 - 关掉 cpolar,重新起一次,确认免费版地址发生变化;对照官方说明确认固定域名所需档位
- 把
targets.json改成 51 条,重启服务,确认启动失败并报「清单超过上限 50 条」 - 全程记录:命令输出、页面截图、抓包文件、耗时数值
记录下来的数据才写进正式稿。没跑过的部分,这里只写做法,不写结论。
10 总结
现在回头看,这件事做成了什么:本机跑着一个只读网页,它读一份自己手工写的 host:port 清单,每隔 30 秒对清单里的目标做一次 TCP 握手,把成功、拒绝、超时、DNS 失败和对应耗时列成一张表。这张表通过 cpolar 有了一条公网链接,远端同事打开就能看到他那边看不到的信息------「从执行探测的这台机器出发,这些目标通不通、多快」。
这个看板的价值不在技术难度,在两个约定:
- 清单是静态的。目标只有你写进文件的那几条,页面没有任何方式让它连到别的地方去。这是它和扫描器的分界线
- 测量是定点的。起点固定在同一台机器上,所以不同时间、不同人看到的结果可以互相比较
两个约定合起来,才能让「你 ping 一下试试」这种对话变得有意义。
三件明确不做的事,再重复一遍:
- 不做端口扫描、不做网段遍历、不对端口区间做批量探测
- 不提供页面加目标、改清单、立即重探的入口,清单改动只能人工改文件后重启
- 不发起清单之外的任何连接,包括 HTTP 探测、banner 抓取、凭据尝试
想继续往下做的话,几个方向:
- 把采集结果按天落一个 CSV,攒几天就能看出某个目标是不是间歇性抖动
- 加一个
expect的时间窗口判断,比如「每天 8 点前必须通」,不通就标红,比单纯的通断更有用 - 换成系统层次的健康检查脚本调用
/healthz,把看板和监控系统对上
但这些都是后面的事。先把清单写对、把只读边界守住、把链接发给该发的人,这一版就够用了。