背景
有一天,我突然发现无法从外部连接家里的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
镜像。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 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
文件内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 服务端连接信息
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文件内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| 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
,同时预留一些端口用于后续我们的服务暴露。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| 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
文件中定义:
1
2
3
4
5
6
7
8
9
10
11
12
| # 仪表盘访问凭证
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文件内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| 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证书。以下提供了一段脚本,方便一键生成我们需要的证书。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
| #!/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"
|
使用方式:
1
| ./generate-frp-certs.sh --domain=<your-domain> --ip=<your-ip>
|
这样我们的服务器与客户端双向认证,谁都不可被冒充。
安全加固
到这里,你的FRP连接已经通过TLS和双向认证得到了很好的保护,即使token
不慎泄露,没有匹配的证书也无法建立连接。接下来,我们在此基础上更进一步,结合云主机的防火墙(安全组)策略,实现更精细的访问控制。我们希望达成:
相信很多人都明白这个道理,但手动操作实在繁琐,所以我们需要一个工具来帮忙。在我看来,最便捷的莫过于再次祭出alfred workflow
来实现。于是我抽空写了一个,并开源在github上,感兴趣的可以看这里:alfred-workflow-sg-manager。
我们基于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的路线呢?可是我却放不下这份安全了呢。
注:题图来自于互联网,我觉得画得挺棒,若有侵权请联系我删除。
我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风
