Skip to content

隧道与穿透:配置受限隧道与访问路径 · 前置、依赖与输入至产品配置与操作契约 ​

返回分篇目录

本篇以隧道两端配置为例;前置是监听端、控制通道、目标服务与回程。源码与输入与下文配置共用同一份文件,示例未执行。

隧道与穿透:配置受限隧道与访问路径 ​

本篇以隧道两端配置为例;前置是监听端、控制通道、目标服务与回程。源码与输入与下文配置共用同一份文件,示例未执行。

前置、依赖与输入 ​

隧道与穿透完整示例是离线证据处理器,示例未执行。需要Python3.11以上标准库,第三方依赖为零,不需要服务、管理员或网络。输入JSON只含虚构记录,程序不调用系统命令、不建立连接、不修改主机。完整文件位于examples/m5-next-tunnels,必须一起阅读README、lab.py和case.json。

完整源码与配置 ​

下面包括完整源码与全部输入;这不是产品配置验证器,也不执行协议、密码学或系统策略。判断只依据输入声明,不能用输出证明真实环境。

python
"""Offline teaching model. No network, subprocesses, or system mutations."""
import json
from pathlib import Path
import sys


def require(value, schema):
    if type(value) is not dict or set(value) != set(schema):
        raise ValueError("unexpected or missing fields")
    for key, expected in schema.items():
        if type(value[key]) is not expected:
            raise ValueError("wrong field type: " + key)


def decide(row):
    require(row, {"name": str, "listener": str, "approved_listener": str, "destination": str, "approved_destination": str, "return_route": bool})
    import ipaddress
    listener = ipaddress.ip_address(row["listener"])
    approved = ipaddress.ip_address(row["approved_listener"])
    destination = ipaddress.ip_address(row["destination"])
    target = ipaddress.ip_address(row["approved_destination"])
    if listener != approved:
        return "DENY_LISTENER_SCOPE"
    if destination != target:
        return "DENY_DESTINATION"
    if not row["return_route"]:
        return "CHECK_RETURN_ROUTE"
    return "BOUNDARY_PLAN_ACCEPTED"


def main(args):
    if len(args) != 1:
        print("usage: python lab.py case.json", file=sys.stderr)
        return 2
    try:
        source = Path(args[0]).read_text(encoding="utf-8")
        data = json.loads(source)
        if type(data) is not list or not data:
            raise ValueError("nonempty list required")
        # Validate every row before publishing any decisions.
        results = [(row, decide(row)) for row in data]
        for row, result in results:
            print(row["name"] + ": " + result)
        return 0
    except (OSError, ValueError, KeyError, TypeError, OverflowError):
        print("invalid input", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
json
[
  {
    "name": "loopback",
    "listener": "127.0.0.1",
    "approved_listener": "127.0.0.1",
    "destination": "192.0.2.10",
    "approved_destination": "192.0.2.10",
    "return_route": true
  },
  {
    "name": "wildcard",
    "listener": "0.0.0.0",
    "approved_listener": "127.0.0.1",
    "destination": "192.0.2.10",
    "approved_destination": "192.0.2.10",
    "return_route": true
  },
  {
    "name": "missing-return",
    "listener": "127.0.0.1",
    "approved_listener": "127.0.0.1",
    "destination": "192.0.2.10",
    "approved_destination": "192.0.2.10",
    "return_route": false
  }
]

读者自跑、成功和失败 ​

隧道与穿透离线推演先复制三个文件到独立目录。Windows PowerShell使用python,Linux Bash使用python3调用lab.py case.json;命令在README完整列出。正常输入输出逐行判定并以0结束;缺字段、非法类型或JSON损坏输出invalid input并以2结束。预期拒绝是业务判定,不等同输入错误,仍以0结束。

恢复、清理与边界 ​

恢复时从仓库还原case.json,重新核对变更前后字段。程序不写结果文件,不产生系统资源,不需要关闭端口或停止服务。清理只删除读者自己复制的练习目录;不要删除仓库示例。修改输入不会改变真实授权、证书或路由。

综合练习与分开的解答 ​

练习:先不看参考答案,解释输入中每个正常和失败判定,修改一项条件让一个判定改变,再写出该变化仍不能证明的现实条件。

参考解答

loopback监听与目标都等于批准值且声明回程存在,得到BOUNDARY_PLAN_ACCEPTED。wildcard的0.0.0.0不等于批准的127.0.0.1,优先DENY_LISTENER_SCOPE。missing-return只声明缺回程,输出CHECK_RETURN_ROUTE;不自动添加NAT或路由。

具体变体:只把 wildcard 的 listener 从 0.0.0.0 改为 127.0.0.1,其余字段不变,将由 DENY_LISTENER_SCOPE 变为 BOUNDARY_PLAN_ACCEPTED。真实监听、目标身份和返回路由没有被程序观察,不能把JSON修改当作收紧真实入口。

产品配置与操作契约 ​

以下为与离线模型分开的完整产品配置,示例未执行。产品二进制、真实密钥与账户均需读者按前置另行提供;静态解析不等于产品验证。

toml
# frp v0.61.1; replace documentation address and all secrets before isolated use.
bindAddr = "192.0.2.10"
bindPort = 7000
proxyBindAddr = "127.0.0.1"
allowPorts = [{ single = 6000 }]
maxPortsPerClient = 1
detailedErrorsToClient = false
auth.method = "token"
auth.token = "READER_MUST_REPLACE_WITH_SHARED_RANDOM_SECRET"
auth.additionalScopes = ["HeartBeats", "NewWorkConns"]
transport.tls.force = true
transport.tls.certFile = "./secrets/frps.crt"
transport.tls.keyFile = "./secrets/frps.key"
log.to = "console"
log.level = "info"
toml
# frp v0.61.1; this maps existing SSH on the client to loopback on the server.
serverAddr = "192.0.2.10"
serverPort = 7000
auth.method = "token"
auth.token = "READER_MUST_REPLACE_WITH_SHARED_RANDOM_SECRET"
auth.additionalScopes = ["HeartBeats", "NewWorkConns"]
transport.tls.enable = true
transport.tls.trustedCaFile = "./secrets/root.crt"
transport.tls.serverName = "frps.example.invalid"
log.to = "console"
log.level = "info"

[[proxies]]
name = "lab-ssh"
type = "tcp"
localIP = "127.0.0.1"
localPort = 22
remotePort = 6000

完整FRP两端配置(未执行) ​

frps.toml与frpc.toml按v0.61.1固定字段编写。读者需已有该版本二进制、获批两台隔离主机及客户端本机既有SSH服务。192.0.2.10必须替换为服务端实际地址;只允许客户端到服务端7000的必要网络路径,禁止将示例文档地址部署为公网地址。

两端auth.token必须替换成同一份独立随机秘密,不能使用仓库里的明显占位值。服务端secrets/frps.crt及frps.key由读者自供,证书SAN须含frps.example.invalid(实际部署也可统一改成获批域名);客户端secrets/root.crt是对应已核验CA。不得关闭trustedCaFile或名称校验以通过连接。私钥与token文件只允许相应进程身份读取。

proxyBindAddr限制映射入口在服务端回环6000,allowPorts只允许6000,管理面板不启用。frpc的localIP属于客户端,因此连接的是客户端127.0.0.1:22。7000是控制/工作连接入口,6000是被映射入口。客户端到代理的TLS不替代被映射SSH服务的主机验证与用户授权。

读者获得部署授权后,在各自包含配置及secrets的隔离目录分别运行:

bash
./frps -c ./frps.toml
bash
./frpc -c ./frpc.toml

预期客户端注册lab-ssh,服务端仅回环6000可作为SSH入口;服务端以外主机不能直接连接6000。令牌不同预期注册拒绝,证书不可信或名称不匹配预期TLS拒绝,客户端SSH未监听则注册可能成功但业务连接失败。此处均为预期。

SSH本地转发的完整命令(未执行) ​

前置:独立核验并预置192.0.2.10主机公钥、提供有效student账户私钥,服务端允许到其自身127.0.0.1:6000的转发。这里直接连接FRP主机,不使用限制目的地为2222的SSH课程跳板配置。

bash
ssh -N -T -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes -o ForwardAgent=no -i /srv/tunnel-lab/client_ed25519 -L 127.0.0.1:16000:127.0.0.1:6000 student@192.0.2.10

本机16000只监听回环,转发目标127.0.0.1:6000属于SSH服务端。ExitOnForwardFailure能报告初始转发建立失败,但不保证之后FRP或目标服务始终可用。经此入口再连接SSH仍须核验最终被映射主机的身份。

恢复时撤销本次映射或还原已批准端口范围,保留原管理入口;清理只结束本次前台frpc/frps/转发会话,删除分发的实验秘密并核查相应授权已撤销。不终止同名其他进程。本项目未创建这些进程。WireGuard的独立两端模型见下节,不能与FRP端口映射混为同一机制。

继续阅读