
端口被谁占了、这台机上开了哪些监听?本机起一个端口占用只读清单,cpolar 给异地同事远程看谁在监听
同事在群里问:"你们那台测试机 8080 是不是被占了?我这边拉不起来。"
我回了一句"我看看",然后 SSH 上去 lsof -iTCP -sTCP:LISTEN,输出一大坨,复制到聊天窗口,缩进全没了,同事还是看不出来哪一行是 8080。来回好几轮,问题没解决,人先烦了。
真正的矛盾其实很清晰:同事要远程确认一台机上哪些监听端口被占了、分别归哪个进程,但他不该拿到这台机的 SSH,更不该拥有杀进程的权限。
所以我想要的不是一条命令,而是一个入口:把本机的 TCP 监听端口和占用进程元信息列出来,渲染成一张只读清单页,绑在本机回环地址,用 cpolar 让异地同事打开链接自己看。只读是关键------看得到谁在监听,但点不了任何 kill、重启、改配置。
先把话说在前面:本文是一份"隔离环境中的可执行实验提纲" 。本轮我没有采集任何真实端口、没有启动服务、没有起隧道,文中出现的端口号、进程名、PID 全是示例/模拟值 ,用来演示清单页上长什么样。真跑起来的时候,这些数字来自你自己机器上 lsof/ss 的真实输出。
1 这套东西解决什么问题
先把边界划清楚,免得写着写着变成"进程管理平台选型"。
核心痛点就一句话:要远程确认一台机上哪些端口被监听、归谁占用,但不该把 SSH 交出去,更不该给 kill 权限。
lsof、ss 只在本机终端能看,异地同事用不了。把账号给他,权限太大;让他自己回忆"我这台 8080 是不是nginx",又不可靠。
所以缺的不是一个命令,而是一个只读 + 元信息的查看入口:
- 只读:只能看,不能 kill、不能重启、不能改配置、不能暂停
- 元信息:只看端口号、协议、绑定地址、进程名、PID 这些归属信息
- 不越界:不暴露进程的完整命令行参数、环境变量、凭据
分工是这样的:
- Python3 标准库负责调起本机的监听端口采集命令,并解析输出
- 一个极小的 HTTP 服务把解析结果渲染成只读 HTML 清单
- cpolar 负责把只在本机可达的
127.0.0.1:8111,变成异地同事能打开的地址
有一点必须先强调:这个页面只读。 它只呈现端口和进程元信息,不提供 kill、重启、改配置任何入口,也不吐进程的启动参数和环境变量。它不是进程管理器,就是个临时给同事看端口占用的只读展示页。
2 环境准备
需要的东西很少:
- 一台 macOS 或 Linux 机器
- Python 3(macOS 自带
python3,Linux 装完系统一般也有) - 采集命令:macOS 用
lsof,Linux 用ss或netstat(下面会讲怎么选) - 一个 cpolar 账号(第 6 节讲怎么装和登录)
先确认 Python 版本正常:
bash
python3 --version
能看到 Python 3.x.x 就行。这篇只用了 subprocess、html、http.server、urllib.parse、platform 这些标准库模块,不需要 pip install 任何东西。
敲黑板: 有些系统上 python 还是 Python 2,命令一律用 python3。下面所有示例都按 python3 来。
建一个放脚本的工作目录:
bash
mkdir -p ~/port-listen-viewer
cd ~/port-listen-viewer
2.1 采集命令怎么选:macOS 用 lsof,Linux 优先 ss
采集 TCP 监听端口这件事,不同系统命令不一样,硬套一个命令要么没输出,要么报错:
- macOS :用
lsof -nP -iTCP -sTCP:LISTEN -n不做 DNS 反查、-P不把端口换算成服务名,这样输出快、端口是数字,好解析-iTCP只看 TCP,-sTCP:LISTEN只筛出监听态- Linux :优先用
ss -lntp -l监听、-n数字端口、-tTCP、-p显示进程- 有些精简系统没装
ss,退回netstat -lntp - Windows :这篇不覆盖,
netstat -ano输出格式和上面两套都不一样,留给以后单独写
这里别想着写一个"万能命令" :lsof 和 ss 的输出格式差得很远,宁可判断系统分别走两条解析分支,也别硬凑一个正则同时兼容两边------凑出来的正则该漏的漏、该错配的错配。
3 写一个最小端口占用只读清单服务
这段是本文核心,拆成三块:选命令、解析输出、HTTP 渲染。
3.1 先定只读边界:只看元信息,不碰进程详情
在动手解析之前,先把"要什么、不要什么"定死:
- 要:端口号、协议(TCP)、绑定地址、进程名、PID
- 不要 :进程完整命令行参数(
lsof的COMMAND加-c那些)、环境变量、工作目录、打开的文件
为什么要专门强调"不要命令行参数"?因为一行 lsof 的完整输出里,进程的启动参数里常带着密钥、token、连接串。只读清单只该回答"谁在监听",不该顺手把进程的隐私也抖出去。
python
# ---------- 只读字段白名单(骨架示例) ----------
# 只呈现下列字段;不采集、不渲染进程命令行参数与环境变量。
FIELD_PORT = "port" # 端口号
FIELD_PROTO = "proto" # 协议,这里只涉及 TCP
FIELD_ADDR = "bind_addr" # 绑定地址
FIELD_PNAME = "proc_name" # 进程名
FIELD_PID = "pid" # 进程 PID
# 渲染上限:最多展示多少条监听记录,防止清单被拉爆
MAX_ROWS = 300
3.2 选命令:按系统分支调起采集
不能用 shell 拼接字符串的方式拼命令,容易出注入和引号问题。标准库 subprocess.run 传列表参数,天然规避:
python
import os
import shutil
import platform
import subprocess
def pick_listen_command(system):
"""
按操作系统选择采集 TCP 监听端口的命令,返回参数列表。
- macOS: lsof -nP -iTCP -sTCP:LISTEN
- Linux: 优先 ss -lntp,缺失时退回 netstat -lntp
返回 None 表示当前系统不支持。
"""
if system == "Darwin": # macOS
return ["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"]
if system == "Linux":
# 优先 ss;ss 不在就退回 netstat
if shutil.which("ss"):
return ["ss", "-lntp"]
if shutil.which("netstat"):
return ["netstat", "-lntp"]
return None
return None
def collect_listen_rows():
"""
调起采集命令并解析成只读清单行。
返回 (rows, source),rows 是 dict 列表,source 是所用命令名。
- 不改动系统状态,只读取监听信息;
- 采集失败时返回空列表,不抛异常打断页面。
"""
system = platform.system()
cmd = pick_listen_command(system)
if cmd is None:
return [], "unsupported"
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10, # 采集超时上限,避免卡死
)
except (subprocess.SubprocessError, FileNotFoundError):
return [], "error"
rows = parse_output(system, proc.stdout)
return rows, os.path.basename(cmd[0])
这里有两个点值得说:
第一,subprocess.run 传列表,不传字符串。 传列表时命令和参数是分开的,没有 shell 介入,不会出现把参数当命令执行的注入问题。
第二,采集失败不抛异常。 命令不存在、超时、没权限,都退回空列表,页面照常渲染,只是显示"无数据"。一个只读查看页,不该因为采集命令抽风就整个白屏。
导入那几行里,os 和 shutil 下面解析和渲染部分也会用到,这里一起写上,省得你后面拼代码时到处找不到 NameError。
3.3 解析输出:把 lsof / ss / netstat 三套格式拆开
这是最容易写错的地方。三套输出格式不同,得分开解析。下面锚定各自的典型行格式:
python
# ---------- 三套输出的典型行格式(示例/模拟值) ----------
# lsof (macOS):
# nginx 1234 admin 6u IPv4 0xabc 0t0 TCP 127.0.0.1:80 (LISTEN)
# ^进程名 ^PID ^用户 ... ^协议 ^绑定地址 ^状态
# nginx 1234 admin 7u IPv6 0xdef 0t0 TCP *:443 (LISTEN)
#
# ss (Linux):
# LISTEN 0 128 127.0.0.1:8111 0.0.0.0:* users:(("python3",pid=4321,fd=3))
# ^状态 ... ^本地地址 ^对端 ^进程名与PID
#
# netstat (Linux):
# tcp 0 0 127.0.0.1:8111 0.0.0.0:* LISTEN 4321/python3
# ^协议 ^本地地址 ^对端 ^状态 ^PID/进程名
对应三套解析函数:
python
import os
import re
import shutil
# 匹配 "地址:端口",兼容 IPv4(127.0.0.1:80)、IPv6([::1]:80)、通配(*:443)
ADDR_PORT_RE = re.compile(r"^(?P<addr>.+):(?P<port>\d+)$")
def _split_addr_port(text):
"""把 '127.0.0.1:8111' / '[::1]:80' / '*:443' 拆成 (addr, port)。"""
text = text.strip()
m = ADDR_PORT_RE.match(text)
if not m:
return None
try:
port = int(m.group("port"))
except ValueError:
return None
return m.group("addr"), port
def parse_lsof(text):
"""解析 macOS 的 lsof -nP -iTCP -sTCP:LISTEN 输出。"""
rows = []
for line in text.splitlines():
if not line.strip() or line.startswith("COMMAND"):
continue
# 用空白切分;lsof 的 TCP 行里协议列就是 "TCP"
parts = line.split()
if "TCP" not in parts:
continue
idx = parts.index("TCP")
# 协议列之后是 "绑定地址:端口",再往后是 "(LISTEN)"
if idx + 1 >= len(parts):
continue
ap = _split_addr_port(parts[idx + 1])
if ap is None:
continue
addr, port = ap
rows.append({
"port": port,
"proto": "TCP",
"bind_addr": addr,
"proc_name": parts[0],
"pid": parts[1] if len(parts) > 1 else "-",
})
return rows
def parse_ss(text):
"""解析 Linux 的 ss -lntp 输出。"""
rows = []
for line in text.splitlines():
if not line.strip() or line.startswith("State"):
continue
parts = line.split()
# ss 行: State Recv-Q Send-Q Local Peer Process
if len(parts) < 5 or parts[0] != "LISTEN":
continue
ap = _split_addr_port(parts[3]) # 本地地址列
if ap is None:
continue
addr, port = ap
pname, pid = "-", "-"
# 进程信息在行尾 users:(("python3",pid=4321,fd=3)),一条监听可对应多个进程
m = re.search(r'\("(?P<name>[^"]+)",pid=(?P<pid>\d+)', line)
if m:
pname, pid = m.group("name"), m.group("pid")
rows.append({
"port": port,
"proto": "TCP",
"bind_addr": addr,
"proc_name": pname,
"pid": pid,
})
return rows
def parse_netstat(text):
"""解析 Linux 的 netstat -lntp 输出。"""
rows = []
for line in text.splitlines():
parts = line.split()
# netstat 行: Proto Recv-Q Send-Q Local Foreign State PID/Program
if len(parts) < 4 or parts[0] not in ("tcp", "tcp6"):
continue
ap = _split_addr_port(parts[3]) # 本地地址列
if ap is None:
continue
addr, port = ap
pname, pid = "-", "-"
if len(parts) >= 7 and "/" in parts[6]:
pid, pname = parts[6].split("/", 1)
rows.append({
"port": port,
"proto": "TCP",
"bind_addr": addr,
"proc_name": pname,
"pid": pid,
})
return rows
def parse_output(system, text):
"""按系统选择解析器。"""
if system == "Darwin":
return parse_lsof(text)
# Linux:先按 ss 解析,行数太少再尝试 netstat 格式
rows = parse_ss(text)
if not rows:
rows = parse_netstat(text)
return rows
解析这里最容易踩的坑,是绑定地址里的冒号。 IPv6 的地址长这样 [::1]:80,如果拿 line.split(":") 硬切,会被切成好几段,端口就取错了。所以这里用正则从右边 匹配最后一段数字当端口(^(?P<addr>.+):(?P<port>\d+)$),. 是贪婪的,会把最后那个冒号留给端口。
第二个坑是 *:443 这种通配地址。 它是监听在所有网卡上的意思,不是"绑在一个叫 * 的地址上"。清单里原样呈现就行,别自作主张替换成 0.0.0.0,免得读者以为是绑定的意思变了。
3.4 HTTP 服务:只允许 GET,越界路径 404,写请求 405
接下来是服务部分。核心是三条只读规则:
- 只监听
127.0.0.1(绑回环地址,本地才可达,再靠 cpolar 转发) - 只允许 GET/HEAD,其他方法一律
405 - 非白名单路径一律
404
python
import html
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PAGE_CSS = """
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
margin:24px;color:#222}
h1{font-size:20px}
table{border-collapse:collapse;margin-top:16px;font-size:14px}
th,td{border:1px solid #ddd;padding:6px 10px;text-align:left}
th{background:#f5f5f5}
.note{color:#888;font-size:13px;margin-top:12px}
"""
# 只读路径白名单:只有这些路径可达,其余一律 404
ALLOWED_PATHS = {"/", "/index.html", "/listen"}
def render_page(rows, source):
body = "".join(
"<tr>"
f"<td>{r['port']}</td>"
f"<td>{html.escape(r['proto'])}</td>"
f"<td>{html.escape(r['bind_addr'])}</td>"
f"<td>{html.escape(r['proc_name'])}</td>"
f"<td>{html.escape(str(r['pid']))}</td>"
"</tr>"
for r in rows
)
if not body:
body = '<tr><td colspan="5">(未采集到监听端口,或采集命令不可用)</td></tr>'
return f"""<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<title>端口占用只读清单</title><style>{PAGE_CSS}</style></head>
<body>
<h1>端口占用只读清单</h1>
<p class="note">采集来源:{html.escape(source)} | 仅展示端口与进程元信息</p>
<table>
<tr><th>端口</th><th>协议</th><th>绑定地址</th><th>进程名</th><th>PID</th></tr>
{body}
</table>
<p class="note">本页只读:只展示监听端口与占用进程元信息,不提供 kill / 重启 / 改配置入口,不暴露进程启动参数与环境变量。</p>
</body></html>"""
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="text/html; charset=utf-8"):
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
def _handle_read(self):
parsed = urllib.parse.urlparse(self.path)
# 非白名单路径一律 404
if parsed.path not in ALLOWED_PATHS:
self._send(404, "<h1>404 未找到</h1>")
return
rows, source = collect_listen_rows()
rows = rows[:MAX_ROWS]
self._send(200, render_page(rows, source))
def do_GET(self):
self._handle_read()
def do_HEAD(self):
self._handle_read()
# 写操作一律 405:服务是只读的,不是"没实现"
def do_POST(self):
self._send(405, "<h1>405 只读服务,不支持写操作</h1>")
def do_PUT(self):
self._send(405, "<h1>405 只读服务,不支持写操作</h1>")
def do_DELETE(self):
self._send(405, "<h1>405 只读服务,不支持写操作</h1>")
def main():
host, port = "127.0.0.1", 8111
httpd = ThreadingHTTPServer((host, port), Handler)
print(f"端口占用只读清单服务已启动:http://{host}:{port}")
print("按 Ctrl+C 停止")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n已停止")
httpd.server_close()
if __name__ == "__main__":
main()
两个容易被忽略的点:
第一,路径白名单是"集合判断",不是"拼路径"。 请求路径只拿去做 in ALLOWED_PATHS 判断,命中了才渲染。这样请求 /../etc/passwd 之类的根本不在集合里,直接 404,路径拼接类的越界从根上就不存在。
第二,写方法全部显式实现并返回 405。 不实现的话,BaseHTTPRequestHandler 对未知方法默认返回 501。显式写 405,语义才对------这是只读服务,不是"功能没做"。
4 先造一份示例监听记录,把链路跑通
这套东西不能拿真实机器直接开测,先用示例数据把链路跑通。示例/模拟值如下:
| 端口 | 协议 | 绑定地址 | 进程名 | PID |
|---|---|---|---|---|
| 8111 | TCP | 127.0.0.1 | python3 | 4321 |
| 22 | TCP | * | sshd | 700 |
| 443 | TCP | * | nginx | 1234 |
上面这张表里的端口、进程名、PID 全是示例/模拟值,用来演示清单页渲染成什么样,不是任何真实机器的实测结果。
想在本地看渲染效果,可以先把 collect_listen_rows() 临时换成一个返回固定示例列表的桩函数,确认页面排版正常:
python
def collect_listen_rows():
# 本地演示用桩数据(示例/模拟值);真实部署时改回真实采集
demo = [
{"port": 8111, "proto": "TCP", "bind_addr": "127.0.0.1", "proc_name": "python3", "pid": "4321"},
{"port": 22, "proto": "TCP", "bind_addr": "*", "proc_name": "sshd", "pid": "700"},
{"port": 443, "proto": "TCP", "bind_addr": "*", "proc_name": "nginx", "pid": "1234"},
]
return demo, "demo"
MAX_ROWS = 300
然后启动服务:
bash
python3 port_listen_server.py
终端会打印:
端口占用只读清单服务已启动:http://127.0.0.1:8111
按 Ctrl+C 停止
浏览器打开 http://127.0.0.1:8111,就能看到上面那张三行的清单。这一步做完先在本地核对一遍: 切回真实采集模式后,页面里 8111 这一行应该指向你自己刚起的这个 Python 进程。对不上,先检查解析器有没有把 IPv6 地址的端口切错。
这里提醒一句: 换回真实采集后,lsof/ss 在部分系统上要看别的用户进程得用 sudo。别为了看全就无脑加 sudo 跑成 root------只读清单能看自己进程的监听就够了,真要看全局,也先确认这台机上没有敏感进程参数会被顺带读出来。
5 安装 cpolar 并登录
现在清单只在本机 127.0.0.1:8111 可达,异地同事打不开。接下来用 cpolar 把只读清单暴露出去。
5.1 安装
macOS 上可以用 Homebrew:
bash
brew install cpolar
Linux 用官方一键脚本:
bash
curl -L https://www.cpolar.com/static/downloads/install-release-cpolar.sh | sudo bash
装完之后 cpolar 会起一个本地管理服务。确认一下它在跑:
bash
# macOS
brew services list | grep cpolar
# Linux
sudo systemctl status cpolar
装完可以打开本机管理面板看一眼:http://127.0.0.1:9200。能打开就说明 cpolar 本地服务正常。
别跳过这一步。 很多人后面隧道起不来,其实是本地 cpolar 服务根本没起来,结果一路去怀疑网络。
5.2 登录绑定账号
在 cpolar 后台"验证"页面能看到自己的 authtoken。拿到之后命令行执行:
bash
cpolar authtoken 你的authtoken
或者直接在 http://127.0.0.1:9200 的 Web 界面里登录账号,也会自动写入 token。
6 用 cpolar http 8111 暴露只读清单
这次要暴露的是一个 HTTP 页面,所以用 HTTP 隧道:
bash
cpolar http 8111
执行后终端会打印一块 Forwarding 信息,里面有一条 https://xxxx.cpolar.cn 这样的公网地址,指向本地的 8111 端口。把这条地址发给异地同事,他打开就能看到那张只读端口清单。
划重点:免费版的这个地址是随机地址,会话结束就失效,重启隧道地址就变了,24 小时内都不保证稳定。 临时让同事看一次,免费用就够了。
但端口占用这件事,经常不是"看一次"就完事------今天问一次、明天还要再核对。所以我更建议这次直接上固定二级子域名,让地址稳定下来,同事存成书签,下次直接打开还是同一个链接。
按官方规则:
- 固定二级子域名:需基础套餐及以上
- 固定 TCP 端口:需专业套餐及以上
- 自定义域名:需专业套餐及以上
配好固定二级子域名之后,地址就不再随会话变化。同事把链接存下来,隔几天回来核对,还是那个地址。
安全提醒: 隧道暴露出去后,任何拿到地址的人都能访问这张只读清单。它虽然不是写入口,但会把你这台机的监听端口和进程名露出来------这份信息对排查有用,对扫描的人也有用。真要长期给同事用,建议配合 cpolar 的访问控制,或者至少别把地址发到公开场合。


7 验证只读边界:越界路径 404、写请求 405
只读这个承诺,得验证过才算数。下面三条自检,建议每个人跑完都过一遍。
7.1 白名单以外的路径必须 404
请求一个不在白名单里的路径:
bash
curl -i "http://127.0.0.1:8111/../etc/passwd"
curl -i "http://127.0.0.1:8111/admin/kill?pid=1234"
预期都返回 404,而不是把清单以外的信息吐出来。注意这里和路径拼接类漏洞的区别: 路径只做集合判断,请求里带 ../ 或者别的路径都不会命中,服务端直接 404。
7.2 写请求必须 405
模拟一次 kill 请求:
bash
curl -i -X POST "http://127.0.0.1:8111/listen" -d "action=kill&pid=1234"
预期返回 405 Method Not Allowed。同理试试 PUT、DELETE:
bash
curl -i -X PUT "http://127.0.0.1:8111/listen"
curl -i -X DELETE "http://127.0.0.1:8111/listen"
都应该是 405。
7.3 经隧道访问结果一致
把上面两条里的 127.0.0.1:8111 换成 cpolar 给你的公网地址,再各跑一次,确认异地访问的返回码和本机一致------404 还是 404,405 还是 405。
如果经隧道访问时越界路径没有返回 404,先关掉隧道再排查。这说明暴露出来的入口比预期的大,不能放着不管。
8 异地同事核对清单
把链接发给同事后,他那边应该能看到:
- 一张端口清单表,列出端口、协议、绑定地址、进程名、PID
- 只看得到监听态的 TCP 端口,看不到进程的启动参数和环境变量
- 任何 kill、重启、改配置都做不了(本来就没有入口)
同事要做的事很简单:打开链接、找到他在意的那个端口、看它归哪个进程。这一步不是为了测试而测试,而是确认"本机采集到的监听清单 = 异地看到的清单"这条链路已经打通。 两边对不上,先检查同事打开的是不是同一个隧道地址、页面是不是拉到最新一次采集。
9 收尾与清理
验证完把服务停掉,别让它一直跑着:
bash
# 停掉 cpolar 隧道:回到 cpolar 那个终端按 Ctrl+C
# 停掉清单服务:回到 port_listen_server.py 那个终端按 Ctrl+C
养成这个习惯:临时暴露的服务,用完就关。端口清单页也别忘了它暴露的是这台机真实的监听面。
10 总结
走到这一步,你手上已经有一条完整链路:本机 Python3 调起 lsof -nP -iTCP -sTCP:LISTEN(macOS)或 ss -lntp / netstat -lntp(Linux)采集 TCP 监听端口,解析出端口、协议、绑定地址、进程名、PID,渲染成只读端口占用清单,绑在 127.0.0.1:8111,再用 cpolar http 8111 把只读入口给到异地同事看谁在监听。整条链路不装任何第三方库,macOS 和 Linux 直接能跑。
几个关键点拎出来:
- 三套命令的输出格式不同,解析分成
parse_lsof/parse_ss/parse_netstat三条分支,别硬凑一个正则 - 绑定地址带冒号(尤其 IPv6 的
[::1]:80),端口要从右边正则匹配,不能用split(":")硬切 - 只读边界写死在代码里:写方法一律
405,非白名单路径一律404,采集字段只留元信息、不碰命令行参数与环境变量 - cpolar 负责把本机只读清单变成异地可访问入口,地址经常要复用就配固定二级子域名(需基础套餐及以上),只是临时看一次用免费随机地址就够
想继续扩展的话,自然的下一步是给清单加个"只看某个端口号"的过滤、把协议从 TCP 扩到 UDP、或者按进程名分组。但不管怎么扩,只读这条线不要破------给同事看端口占用的页面,永远不该变成能 kill、能重启、能改配置的入口。
说明:本文是隔离环境中的可执行实验提纲 。本轮未实际采集真实端口、未启动服务、未起隧道,文中所有端口号、进程名、PID 均为示例/模拟值 ,不代表任何真实机器的实测结果。实际部署时,请以你自己机器上
lsof/ss/netstat的真实输出和 cpolar 返回的真实地址为准。