「我这边连不上你那个服务」—— 用清单驱动的 TCP 连通性只读看板,cpolar 让远端同事自己取证

「我这边连不上你那个服务」------ 用清单驱动的 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 一次「连不上」的排查,缺的往往不是工具

远端同事的抱怨通常是「连不上」。这三个字背后至少四种情况:

  1. 目标服务没跑,端口拒绝了连接
  2. 服务在跑,但中间防火墙没放通,SYN 发出去没回应,最后超时
  3. 通了,但路径绕得远,握手要一两秒,客户端自己的超时先到了
  4. 目标主机域名在对方那边解析到了别的地址

这四种情况的处置方式完全不同。第一种要去看服务;第二种要去看防火墙和安全组;第三种是链路质量问题,要换线路或者调客户端超时;第四种要查 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 隔离环境中的可执行实验提纲

下面是后续要在隔离环境里实际跑一遍的步骤。本轮没有真实探测任何目标,没有启动服务,没有起隧道,没有产生任何网络请求。

准备:

  1. 建一个隔离的虚拟机或者容器,配置两个可达的测试目标:一个开着端口(比如 python3 -m http.server 9000),一个不监听任何端口
  2. 准备 targets.json,包含四条:上面两个目标、一个不存在的内网地址 10.0.99.99:443(期望 down)、一个不存在的域名 nonexistent.invalid:80(期望 down)

执行:

  1. 启动 board.py,确认终端打印 listening on http://127.0.0.1:8115 targets=4
  2. 等待一轮采集完成(约 5 秒),curl http://127.0.0.1:8115/healthz 应返回 {"status":"ok","targets":4}
  3. curl 打开 /,逐行核对:开着的端口显示 ok 且耗时为几十毫秒量级;不监听端口显示 refused;10.0.99.99 显示 timeout 且耗时约 2000ms;不存在的域名显示 dns_error
  4. 连点刷新五次,观察页面上的采集时间戳------30 秒内应保持不变,证明缓存生效
  5. 跑 curl -X PUT /、curl -X POST /、curl -X DELETE /,全 405 且带 Allow: GET, HEAD
  6. 跑 curl --path-as-is /../etc/passwd、/%2e%2e/etc/passwd、/etc/hosts,全 404,响应体为 {"error":"not_found"}
  7. 在探测前后各抓一次目标机器的包,确认连接数等于清单条数,且没有发生重试或并发
  8. 起 cpolar http 8115,用另一台不同网络的机器打开公网地址,页面内容与本机一致
  9. 关掉 cpolar,重新起一次,确认免费版地址发生变化;对照官方说明确认固定域名所需档位
  10. 把 targets.json 改成 51 条,重启服务,确认启动失败并报「清单超过上限 50 条」
  11. 全程记录:命令输出、页面截图、抓包文件、耗时数值

记录下来的数据才写进正式稿。没跑过的部分,这里只写做法,不写结论。

10 总结

现在回头看,这件事做成了什么:本机跑着一个只读网页,它读一份自己手工写的 host:port 清单,每隔 30 秒对清单里的目标做一次 TCP 握手,把成功、拒绝、超时、DNS 失败和对应耗时列成一张表。这张表通过 cpolar 有了一条公网链接,远端同事打开就能看到他那边看不到的信息------「从执行探测的这台机器出发,这些目标通不通、多快」。

这个看板的价值不在技术难度,在两个约定:

  • 清单是静态的。目标只有你写进文件的那几条,页面没有任何方式让它连到别的地方去。这是它和扫描器的分界线
  • 测量是定点的。起点固定在同一台机器上,所以不同时间、不同人看到的结果可以互相比较

两个约定合起来,才能让「你 ping 一下试试」这种对话变得有意义。

三件明确不做的事,再重复一遍:

  • 不做端口扫描、不做网段遍历、不对端口区间做批量探测
  • 不提供页面加目标、改清单、立即重探的入口,清单改动只能人工改文件后重启
  • 不发起清单之外的任何连接,包括 HTTP 探测、banner 抓取、凭据尝试

想继续往下做的话,几个方向:

  • 把采集结果按天落一个 CSV,攒几天就能看出某个目标是不是间歇性抖动
  • 加一个 expect 的时间窗口判断,比如「每天 8 点前必须通」,不通就标红,比单纯的通断更有用
  • 换成系统层次的健康检查脚本调用 /healthz,把看板和监控系统对上

但这些都是后面的事。先把清单写对、把只读边界守住、把链接发给该发的人,这一版就够用了。

相关推荐
云和数据.ChenGuang1 小时前
LangGraph 异步 Node 的设计与写法实践
开发语言·人工智能·python·机器学习·php
天诰2 小时前
SA-K-11 检索会消失吗与四个未解问题
java·服务器·人工智能·python·机器学习·ai编程
benchmark_cc3 小时前
用 QuantDash 读取美股行情:AAPL.US 的运行前检查、排错顺序与数据校验清单
python·数据分析·pandas·量化交易·股票数据·quantdash
羲云网络6 小时前
GPT Image 2.6 接入指南:Flare、Sunburst 怎么选,编辑失败如何排查
人工智能·python·aigc·图像生成·acedatacloud
橙橙笔记9 小时前
具身智能的学习 - 完成一个机械臂 第1节
python·学习·机械臂·具身智能
小小张说故事10 小时前
Python 的 class 语句到底执行了什么?拆开 __prepare__、metaclass.__new__ 和那个被讲反的 __set_name__ 顺序
python
天天被压力12 小时前
【A股分钟级高频数据完全手册 #07】北交所历史分钟K线接口:京市Pro版分时批量
java·人工智能·python
天天被压力12 小时前
【A股分钟级高频数据完全手册 #08】股票日、分时数据接口对比:普通分时与Pro版差异实测
java·人工智能·python
特立独行的猫A12 小时前
AI 时代,我为什么推荐用 cj-tauri 开发桌面应用
java·后端·python