给内网穿透FRP套上坚固的盾牌

背景
有一天,我突然发现无法从外部连接家里的NAS了。我开始慌了,预感到不妙。莫非是公网IP被运营商回收了?我也成为一个大内网用户了。所幸已经有不少成熟的方案,而FRP就是其中之一。它开源免费,易上手,并且支持的协议还比较多(当然,部署服务器的费用得另算)。晚上回到家,我决定面对现实,好好折腾一番。虽然网上现有的FRP教程多数只完成了‘能用’的第一步,但距离‘好用易用’还有点距离。
本文简要描述一下我使用FRP的过程,并且看一下我们如何给FRP套上坚固的盾牌,配上易用的武器。我假定你已经知道FRP是什么,并且最基本的FRP使用已经了解。不了解也没关系,继续看你也大概能懂。
虽然咱数据或许对别人而言也没那么重要,但自我保护意识也不可松懈。
目标制定
frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。
为了方便迁移和管理,在使用FRP时我首推容器化方案。不过似乎没看到官方的镜像,但dockerhub上有一个社区广泛使用且下载量很高的镜像,大抵错不了:snowdreamtech/frpc和snowdreamtech/frps。
FRP是分客户端和服务端的,需要在不同的机器分别配置。frpc一般部署在内网,用于将内部需要对外暴露的服务定义出来。而frps一般部署在有公网IP的服务器上,用于接收外部连接并转发到内部服务。这里有几个安全事项需要关注:
-
内网frpc和公网frps之间需要建立安全的连接。
-
公网frps暴露的端口需要进一步限制连接来源。
-
公网frps暴露的端口仅在必要时开放。
很多分享的方案里基本不启用TLS,也对暴露的端口没有进一步的限制,这其实是不安全的。秉承这些目标,我们开始行动吧。
部署服务
内网部署frpc端
这里我直接使用docker-compose部署,并使用snowdreamtech/frpc镜像。
version: '3.8'
services:
frpc:
image: snowdreamtech/frpc:debian
container_name: frpc
restart: always
network_mode: host
volumes:
- ./frpc.toml:/etc/frp/frpc.toml
- ./client.crt:/etc/frp/client.crt
- ./client.key:/etc/frp/client.key
- ./ca.crt:/etc/frp/ca.crt
environment:
- TZ=Asia/Shanghai
env_file:
- ./.env这里需要的TLS证书一会我们再生成。这里的.env文件内容如下:
# 服务端连接信息
FRPC_SERVER_ADDR=<your-server-address>
FRPC_SERVER_PORT=<your-server-port> # 服务器域名
FRPC_SERVER_NAME="<your-domain-for-tls>" # 认证令牌 - 必须与服务端一致
FRPC_AUTH_TOKEN=<your-auth-token> # 客户端仪表盘配置
FRPC_DASHBOARD_USER=admin
FRPC_DASHBOARD_PWD=admin为了方便修改和对齐,我们将frpc.toml文件中的一部分配置放在.env文件中定义。而frpc.toml文件内容如下:
user = "kevin" serverAddr = "{{ .Envs.FRPC_SERVER_ADDR }}"
serverPort = {{ .Envs.FRPC_SERVER_PORT }} loginFailExit = true log.to = "./frpc.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false auth.method = "token"
auth.token = "{{ if .Envs.FRPC_AUTH_TOKEN }}{{ .Envs.FRPC_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}" transport.poolCount = 5
transport.protocol = "tcp"
transport.connectServerLocalIP = "0.0.0.0" transport.tls.enable = true
transport.tls.certFile = "/etc/frp/client.crt"
transport.tls.keyFile = "/etc/frp/client.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt"
transport.tls.serverName = "{{ .Envs.FRPC_SERVER_NAME }}" udpPacketSize = 1500 webServer.addr = "127.0.0.1"
webServer.port = 7400
webServer.user = "{{ .Envs.FRPC_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPC_DASHBOARD_PWD }}" [[proxies]]
name = "router-web"
type = "tcp"
localIP = "192.168.1.1"
localPort = 80
remotePort = 17603 [[proxies]]
name = "external-http"
type = "tcp"
localIP = "192.168.50.96"
localPort = 8080
remotePort = 9443公网部署frps端
我们的服务端一般是部署在一台有公网IP的服务器上,用于我们从任何地方通过它连接回家里内网。这个服务器可以从国内国外各种云上买一台或者找机会白嫖一台。我是在腾讯云上有一台机器,安装好docker以及docker-compose后,使用snowdreamtech/frps镜像部署。部署在公网的服务,基于安全性考虑,我们希望即使frps被攻击,其它服务也是安全的,所以除了放在容器中,把网络也隔离出来。这里便不再使用network_mode: host,而是使用默认的network_mode: bridge,同时预留一些端口用于后续我们的服务暴露。
version: '3.8'
services:
frps:
image: snowdreamtech/frps:debian
container_name: frps
restart: always
ports:
- "9443:9443"
- "17600-17610:17600-17610" # TCP/UDP 代理端口范围 (allowPorts),视你需要开放一些端口
volumes:
- ./frps.toml:/etc/frp/frps.toml
- ./server.crt:/etc/frp/server.crt
- ./server.key:/etc/frp/server.key
- ./ca.crt:/etc/frp/ca.crt
environment:
- TZ=Asia/Shanghai
env_file:
- ./.env同样的,为了配置修改方便,我们将frps.toml文件中的一部分配置放在.env文件中定义:
# 仪表盘访问凭证
FRPS_DASHBOARD_USER=<your-dashboard-username>
FRPS_DASHBOARD_PWD=<your-dashboard-password> # 认证令牌 - 必须与客户端一致
FRPS_AUTH_TOKEN=<your-auth-token> # 绑定地址和端口配置
FRPS_BIND_ADDR=0.0.0.0
FRPS_BIND_PORT=17600
FRPS_KCP_BIND_PORT=17600
FRPS_DASHBOARD_PORT=17601而frps.toml文件内容如下:
bindAddr = "{{ .Envs.FRPS_BIND_ADDR }}"
bindPort = {{ .Envs.FRPS_BIND_PORT }} kcpBindPort = {{ .Envs.FRPS_KCP_BIND_PORT }} transport.maxPoolCount = 5
transport.tls.force = true
transport.tls.certFile = "/etc/frp/server.crt"
transport.tls.keyFile = "/etc/frp/server.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt" webServer.addr = "0.0.0.0"
webServer.port = {{ .Envs.FRPS_DASHBOARD_PORT }}
webServer.user = "{{ .Envs.FRPS_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPS_DASHBOARD_PWD }}"
webServer.pprofEnable = false # 开放的端口范围,这里可以配置适大一些,更多的映射(限制)在docker-compose中
allowPorts = [
{ start = 17000, end = 17999 },
{ single = 9443 }
] enablePrometheus = true log.to = "./frps.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false detailedErrorsToClient = true auth.method = "token"
auth.token = "{{ if .Envs.FRPS_AUTH_TOKEN }}{{ .Envs.FRPS_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}"
auth.oidc.issuer = ""
auth.oidc.audience = ""
auth.oidc.skipExpiryCheck = false
auth.oidc.skipIssuerCheck = false maxPortsPerClient = 0
udpPacketSize = 1500
natholeAnalysisDataReserveHours = 168生成TLS证书
为了让FRP的连接更安全,我们使用TLS证书来加密连接。它确保我们的frpc和frps之间的连接是安全的,并且防止中间人攻击。我们使用自签名证书来生成TLS证书。以下提供了一段脚本,方便一键生成我们需要的证书。
#!/bin/bash
# 脚本用于生成 FRP 配置所需的自签名证书 set -e # 任何命令失败立即退出 echo "开始生成 FRP 通信证书..." # 设置默认值,但要求用户至少提供一个
SERVER_DOMAIN=""
SERVER_IP="" # 仅重新生成服务器证书的选项
REGENERATE_SERVER_ONLY=false # 显示帮助信息
show_help() {
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " --server-only 仅重新生成服务器证书,保留现有CA证书"
echo " --domain=<域名> 指定服务器域名"
echo " --ip=<IP地址> 指定服务器IP地址"
echo " --help 显示此帮助信息"
echo ""
echo "至少需要指定域名或IP地址中的一个"
exit 1
} # 解析命令行参数
while [[ $# -gt 0 ]]; do
case $1 in
--server-only)
REGENERATE_SERVER_ONLY=true
shift
;;
--domain=*)
SERVER_DOMAIN="${1#*=}"
shift
;;
--ip=*)
SERVER_IP="${1#*=}"
shift
;;
--help)
show_help
;;
*)
echo "未知选项: $1"
show_help
;;
esac
done # 检查是否提供了至少一个参数
if [ -z "$SERVER_DOMAIN" ] && [ -z "$SERVER_IP" ]; then
echo "错误: 必须至少指定域名(--domain)或IP地址(--ip)中的一个"
show_help
fi # 显示配置信息
echo "证书配置:"
if [ -n "$SERVER_DOMAIN" ]; then
echo "- 域名: $SERVER_DOMAIN"
fi
if [ -n "$SERVER_IP" ]; then
echo "- IP地址: $SERVER_IP"
fi if [ "$REGENERATE_SERVER_ONLY" = false ]; then
# 1. 生成 CA 根证书
echo "生成 CA 根证书..."
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
-subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frp-ca"
else
echo "跳过 CA 证书生成,使用现有 CA 证书..."
# 检查CA证书是否存在
if [ ! -f "ca.key" ] || [ ! -f "ca.crt" ]; then
echo "错误: CA证书文件不存在。请先运行不带 --server-only 参数的脚本生成完整证书集。"
exit 1
fi
fi # 2. 生成服务端证书
echo "生成服务端证书..."
openssl genrsa -out frps/server.key 4096 # 设置默认CN
CN_VALUE="${SERVER_DOMAIN}"
if [ -z "$CN_VALUE" ]; then
CN_VALUE="frps"
fi # 创建OpenSSL配置文件
cat > frps/openssl.cnf << EOF
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn [ dn ]
C = CN
ST = Beijing
L = Beijing
O = FRP-Private
OU = DevOps
CN = ${CN_VALUE} [ req_ext ]
subjectAltName = @alt_names [ alt_names ]
EOF # 动态添加DNS和IP到配置文件
DNS_COUNT=1
if [ -n "$SERVER_DOMAIN" ]; then
echo "DNS.${DNS_COUNT} = ${SERVER_DOMAIN}" >> frps/openssl.cnf
DNS_COUNT=$((DNS_COUNT+1))
fi
echo "DNS.${DNS_COUNT} = localhost" >> frps/openssl.cnf IP_COUNT=1
if [ -n "$SERVER_IP" ]; then
echo "IP.${IP_COUNT} = ${SERVER_IP}" >> frps/openssl.cnf
IP_COUNT=$((IP_COUNT+1))
fi
echo "IP.${IP_COUNT} = 127.0.0.1" >> frps/openssl.cnf # 使用配置文件生成CSR
openssl req -new -key frps/server.key -out frps/server.csr -config frps/openssl.cnf # 创建扩展配置文件 - 正确的格式
cat > frps/v3.ext << EOF
subjectAltName = @alt_names
[alt_names]
EOF # 添加DNS和IP到扩展配置
if [ -n "$SERVER_DOMAIN" ]; then
echo "DNS.1 = ${SERVER_DOMAIN}" >> frps/v3.ext
echo "DNS.2 = localhost" >> frps/v3.ext
else
echo "DNS.1 = localhost" >> frps/v3.ext
fi if [ -n "$SERVER_IP" ]; then
echo "IP.1 = ${SERVER_IP}" >> frps/v3.ext
echo "IP.2 = 127.0.0.1" >> frps/v3.ext
else
echo "IP.1 = 127.0.0.1" >> frps/v3.ext
fi # 签署证书,应用SAN扩展
openssl x509 -req -in frps/server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out frps/server.crt -days 3650 -sha256 -extfile frps/v3.ext if [ "$REGENERATE_SERVER_ONLY" = false ]; then
# 3. 生成客户端证书
echo "生成客户端证书..."
openssl genrsa -out frpc/client.key 4096
openssl req -new -key frpc/client.key -out frpc/client.csr \
-subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frpc"
openssl x509 -req -in frpc/client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out frpc/client.crt -days 3650 -sha256 # 4. 分发 CA 证书到 frps 和 frpc 目录
echo "分发 CA 证书到各个目录..."
cp ca.crt frps/ca.crt
cp ca.crt frpc/ca.crt
else
echo "跳过客户端证书生成,仅更新服务器证书..."
fi # 5. 清理中间文件
echo "清理临时文件..."
rm -f frps/server.csr ca.srl frps/openssl.cnf frps/v3.ext
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
rm -f frpc/client.csr
fi echo "设置文件权限..."
chmod 600 ca.key frps/server.key
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
chmod 600 frpc/client.key
fi echo "证书生成完成!"
if [ "$REGENERATE_SERVER_ONLY" = true ]; then
echo "已使用现有CA证书重新生成服务器证书"
else
echo "注意:ca.key 是敏感文件,请妥善保管,建议不要上传到代码仓库。"
fi # 输出证书信息和提示
echo ""
echo "===== 证书信息 ====="
if [ -n "$SERVER_DOMAIN" ]; then
echo "服务器证书包含域名: ${SERVER_DOMAIN}"
echo "如果使用域名连接,请在frpc.toml中设置: transport.tls.serverName = \"${SERVER_DOMAIN}\""
fi
if [ -n "$SERVER_IP" ]; then
echo "服务器证书包含IP地址: ${SERVER_IP}"
fi # 提示下一步操作
echo ""
echo "===== 下一步操作 ====="
echo "1. 复制证书文件到相应位置"
echo "2. 更新frpc.toml中的服务器地址和相关配置"
echo "3. 使用docker-compose restart重启服务" # 验证证书内容
echo ""
echo "===== 验证证书 ====="
echo "查看证书内容(包括SAN扩展):"
openssl x509 -in frps/server.crt -text -noout | grep -A1 "Subject Alternative Name"使用方式:
./generate-frp-certs.sh --domain=<your-domain> --ip=<your-ip>这样我们的服务器与客户端双向认证,谁都不可被冒充。
安全加固
到这里,你的FRP连接已经通过TLS和双向认证得到了很好的保护,即使token不慎泄露,没有匹配的证书也无法建立连接。接下来,我们在此基础上更进一步,结合云主机的防火墙(安全组)策略,实现更精细的访问控制。我们希望达成:
-
仅允许必要的端口放通
-
仅允许必要的IP连接
相信很多人都明白这个道理,但手动操作实在繁琐,所以我们需要一个工具来帮忙。在我看来,最便捷的莫过于再次祭出alfred workflow来实现。于是我抽空写了一个,并开源在github上,感兴趣的可以看这里:alfred-workflow-sg-manager[1]。
我们基于frpc.toml中的一部分配置[[proxies]],来动态开启与关闭相关的端口。一点点前置工作还需要你做的,便是将你的服务器绑定一个安全组,至于安全组后续规则添加维护等就交给这个工具了。 比如先查看一下当前列表:
你上面可以看到这些内网服务还未开放,我们选择一个,比如想从外部访问家里的Mac mini了,我们在alfred框中输入frp open可以看到可开放的列表:
选中Mac mini,然后回车,这个通路便打开了。再次查看可以看到它开放并且绑定了本机的出口IP:

现在你可以开心的从外部VNC到你家内网的Mac mini了,并且安全得多了。 用过之后想关闭的一些端口,比如Mac mini的VNC端口,我们输入frp close,然后选择对应的要关闭的服务回车即可:

现在咱是不是对于FRP的安全使用更有信心了。有些人可能会说:何苦呢,有谁会看中咱攻击咱呢?或许可能Maybe:我们就是控制欲作祟而已:)
一些技巧
还有两个小窍门我也想让你知道,看在这么认真的份上小手点点赞不过份吧!
第一:如上面截图出现了external-http,我们可以借助于将内网服务统一在一个ingress服务(反向代理)下,然后通过这个ingress服务进一步路由,这样我们只需要穿透一个端口即可访问内网的各种服务,免去了配置FRP的手续。也通过让ingress服务走TLS,或者后端服务对接OAuth2等更安全的认证方式,可以进一步保护我们的内网服务。
第二:FRPS除了默认有Dashboard外,还支持Prometheus。我们可以复用这种生态。Prometheus用于收集监控数据,Grafana用于可视化数据和配置告警。比如我们可监控FRP的连接访问情况,并且添加告警,这样某个敏感服务有连接,我们便可以及时收到通知。
后记
本文整体到这里就结束了。我们尝试用docker来部署了FRP的客户端和服务端,并且基于安全的考虑,我们启用了TLS和创建了一个便捷的工具来快速修改云上的安全策略。这犹如一块坚固的盾牌,避免我们可能受到的攻击。
当我折腾好FRP,并且安全地将它保护起来后。有一天,我查看我家的外网IP,发现它居然是一个公网IP。我的天啦,我这折腾一番可是为了啥!我要不要重新回归到公网IP的路线呢?可是我却放不下这份安全了呢。
感谢阅读,如果你觉得文章对你有启发,欢迎点赞、分享,有疑问也欢迎留言探讨。你们的互动是我继续产出的最大动力!期待下篇文章再见。
注:题图来自于互联网,我觉得画得挺棒,若有侵权请联系我删除。
我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风
参考资料
[1]
alfred-workflow-sg-manager: https://github.com/kevin1sMe/alfred-workflow-sg-manager