Featured image of post 给内网穿透FRP套上坚固的盾牌

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

背景

有一天,我突然发现无法从外部连接家里的NAS了。我开始慌了,预感到不妙。莫非是公网IP被运营商回收了?我也成为一个大内网用户了。所幸已经有不少成熟的方案,而FRP就是其中之一。它开源免费,易上手,并且支持的协议还比较多(当然,部署服务器的费用得另算)。晚上回到家,我决定面对现实,好好折腾一番。虽然网上现有的FRP教程多数只完成了‘能用’的第一步,但距离‘好用易用’还有点距离。

本文简要描述一下我使用FRP的过程,并且看一下我们如何给FRP套上坚固的盾牌,配上易用的武器。我假定你已经知道FRP是什么,并且最基本的FRP使用已经了解。不了解也没关系,继续看你也大概能懂。

虽然咱数据或许对别人而言也没那么重要,但自我保护意识也不可松懈。

目标制定

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

为了方便迁移和管理,在使用FRP时我首推容器化方案。不过似乎没看到官方的镜像,但dockerhub上有一个社区广泛使用且下载量很高的镜像,大抵错不了:snowdreamtech/frpcsnowdreamtech/frps

FRP是分客户端和服务端的,需要在不同的机器分别配置。frpc一般部署在内网,用于将内部需要对外暴露的服务定义出来。而frps一般部署在有公网IP的服务器上,用于接收外部连接并转发到内部服务。这里有几个安全事项需要关注:

  1. 内网frpc和公网frps之间需要建立安全的连接。
  2. 公网frps暴露的端口需要进一步限制连接来源。
  3. 公网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不慎泄露,没有匹配的证书也无法建立连接。接下来,我们在此基础上更进一步,结合云主机的防火墙(安全组)策略,实现更精细的访问控制。我们希望达成:

  • 仅允许必要的端口放通
  • 仅允许必要的IP连接

相信很多人都明白这个道理,但手动操作实在繁琐,所以我们需要一个工具来帮忙。在我看来,最便捷的莫过于再次祭出alfred workflow来实现。于是我抽空写了一个,并开源在github上,感兴趣的可以看这里:alfred-workflow-sg-manager

我们基于frpc.toml中的一部分配置[[proxies]],来动态开启与关闭相关的端口。一点点前置工作还需要你做的,便是将你的服务器绑定一个安全组,至于安全组后续规则添加维护等就交给这个工具了。 比如先查看一下当前列表: frp list 你上面可以看到这些内网服务还未开放,我们选择一个,比如想从外部访问家里的Mac mini了,我们在alfred框中输入frp open可以看到可开放的列表: frp open 选中Mac mini,然后回车,这个通路便打开了。再次查看可以看到它开放并且绑定了本机的出口IP: frp list

现在你可以开心的从外部VNC到你家内网的Mac mini了,并且安全得多了。 用过之后想关闭的一些端口,比如Mac mini的VNC端口,我们输入frp close,然后选择对应的要关闭的服务回车即可: 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的路线呢?可是我却放不下这份安全了呢。

注:题图来自于互联网,我觉得画得挺棒,若有侵权请联系我删除。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号