AWS Client VPN:自建 CA + Lambda 签发的完整方案

背景

我厂在 AWS 上有 tooling、dev、qa、preprod 四个环境,VPC 之间通过 peering 互通。日常运维需要从办公网络访问这些 VPC 内的资源,走的是 AWS Client VPN。

Client VPN 的认证方式我们选了证书双向认证(mutual TLS)。AWS 也支持 Active Directory 或 SAML SSO 认证,但我们的场景比较简单——十来个人用,不想为此多维护一套认证基础设施。证书认证的好处是纯客户端侧,不依赖额外的认证服务,配好 .ovpn 文件就能连。

架构概览

整体结构:

1
2
3
4
5
6
7
8
9
办公网络
└── 用户(持 .ovpn 配置 = 客户端证书 + 私钥)
│
▼
AWS Client VPN Endpoint(ap-southeast-1)
├── 认证:mutual TLS(自建 Root CA 签发的证书)
├── 关联子网:tooling VPC private subnet
├── Split tunnel:仅授权网段走 VPN
└── 路由:tooling VPC + dev/qa/preprod 通过 peering

证书这一层,我们用自建 CA 替代了 AWS 的 ACM Private CA。原因后面会讲,但先说整体的证书体系:

1
2
3
4
5
6
7
8
9
Self-managed Root CA
私钥 → Secrets Manager(只有 Lambda 能读)
CA cert → S3 + ACM
│
├── Server Cert → 导入 ACM,endpoint 使用
│
└── Per-User Client Certs
用户生成私钥 + CSR → Lambda 签发 → 返回证书
Lambda 不接触用户私钥

用户自持私钥,Lambda 只接收 CSR 返回签好的证书。CA 私钥锁在 Secrets Manager 里,包括我自己在内没有人能把它下载到本地。

为什么不用 ACM PCA

最初我们用的是 ACM Private CA 来管证书。ACM PCA 是 AWS 的托管 CA 服务,开箱即用,签发、吊销都是 API 调用。

问题是它每个 CA 收 $400/月。十来个人的 VPN,一年下来将近五千美金,就为了 issue-certificate 和 revoke-certificate 两个 API——这两件事 openssl 都能做。

换自建 CA 之后,CA 私钥存 Secrets Manager 大约 $0.80/月,Lambda 和 S3 在 free tier 范围内,基本等于零成本。每月省下来的钱,一年够买几台不错的显示器了。

当然省钱只是一方面。自建 CA 还让我们可以做一些 PCA 做不了(或者做起来很别扭)的事情,比如后面要讲的签发人追踪。

Lambda 签发服务

Lambda 是整个方案的核心,做四件事:

issue — 接收 base64 编码的 CSR,从 Secrets Manager 取 CA 私钥,签发客户端证书(有效期 365 天),更新 S3 上的 index.txt 和 serial,返回签好的证书 PEM。

revoke — 按 CN、序列号或签发人 ARN 吊销证书。吊销后自动生成新的 CRL,然后调 ec2:ImportClientVpnClientCertificateRevocationList 直接推给 VPN endpoint。这比 PCA 的方式更靠谱——PCA 的 CRL 只是落到 S3,并不会自动导入到 endpoint,等于吊销了但没生效。

list — 列出所有证书的状态(有效/已吊销),合并签发人信息。

refresh-crl — 重新签发 CRL 以延长有效期,导入 endpoint,并推 CloudWatch 自定义指标 CRLDaysRemaining。

Lambda 设了 reserved_concurrent_executions = 1,一次只跑一个实例。index.txt 和 serial 这些文件是读改写的,不能并发。十来个人的 VPN,排队签发不会有性能问题。

用户怎么签发证书

通过 Lambda Function URL 调用,用 curl 的 --aws-sigv4 做 SigV4 签名:

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
# 设置环境变量
export AWS_PROFILE=your-profile
export AWS_REGION=ap-southeast-1
export FUNC_URL="https://xxxxxxxxxx.lambda-url.ap-southeast-1.on.aws/"
export CLIENT_NAME="your.name"

# 生成私钥(妥善保管,不要分享)
openssl genrsa -out "${CLIENT_NAME}.key" 2048

# 生成 CSR
openssl req -new \
-key "${CLIENT_NAME}.key" \
-out "${CLIENT_NAME}.csr" \
-subj "/CN=${CLIENT_NAME}"

# 提交 CSR 给 Lambda 签发
CSR_B64=$(base64 -i "${CLIENT_NAME}.csr" | tr -d '\n')
curl "$FUNC_URL" \
--aws-sigv4 "aws:amz:${AWS_REGION}:lambda" \
--user "$(aws configure get aws_access_key_id --profile $AWS_PROFILE):$(aws configure get aws_secret_access_key --profile $AWS_PROFILE)" \
-H "x-amz-security-token: $(aws configure get aws_session_token --profile $AWS_PROFILE)" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"action\":\"issue\",\"csr_b64\":\"${CSR_B64}\"}" \
-o "${CLIENT_NAME}-response.json"

# 提取证书
python3 -c "import json; print(json.load(open('${CLIENT_NAME}-response.json'))['certificate_pem'])" > "${CLIENT_NAME}.crt"

组装 .ovpn 配置文件

拿到证书之后,还需要把它和 VPN 的连接参数、CA 证书、用户私钥组装成一份完整的 .ovpn 文件,才能导入客户端使用。

首先需要一份”基础配置”,里面包含 endpoint 的连接参数和 CA 证书,但不含个人的客户端证书和私钥。这份基础配置可以从 AWS 控制台导出,也可以用 CLI:

1
2
3
aws ec2 export-client-vpn-client-configuration \
--client-vpn-endpoint-id "$ENDPOINT_ID" \
--output text | tr -d '\r' > base.ovpn

我们把这份基础配置提交到了 Git 仓库里,同事直接下载就行,不需要有 AWS 导出权限。

然后在 base.ovpn 上追加空闲断开策略和个人的证书、私钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cat >> base.ovpn <<EOF

inactive 7200 128

<cert>
$(cat "${CLIENT_NAME}.crt")
</cert>

<key>
$(cat "${CLIENT_NAME}.key")
</key>
EOF

mv base.ovpn "${CLIENT_NAME}-tooling-client-vpn.ovpn"

inactive 7200 128 是客户端侧的空闲断开——连续 2 小时几乎没有入站流量就自动断开。服务端另有最长会话 8 小时的限制,到点也会强制断开。

组装完的 .ovpn 文件里应该包含三个关键块:<ca>(基础配置自带)、<cert>(刚签发的证书)、<key>(用户私钥)。可以简单验证一下:

1
2
grep -E '^<(ca|cert|key)>' "${CLIENT_NAME}-tooling-client-vpn.ovpn"
# 应该看到 <ca> <cert> <key> 三行

最后导入 AWS VPN Client(macOS / Windows / Linux 都有),选择这个 .ovpn 文件,Connect 就行了。

注意:这个 .ovpn 文件含私钥,等同于密码。 不要提交 Git,不要发群聊,不要放到任何公开的地方。如果是管理员代签的场景(帮没有 Lambda 权限的同事签发),组装好 .ovpn 后通过安全渠道(比如私聊)发给本人。

员工离职怎么办

禁用 SSO 账号不会断开已有的 VPN 连接。Client VPN 用证书认证,和 SSO 没有关系。离职必须吊销证书:

1
2
3
4
5
6
7
8
9
10
11
12
# 按 CN 吊销
curl "$FUNC_URL" \
--aws-sigv4 "aws:amz:${AWS_REGION}:lambda" \
--user "..." \
-H "x-amz-security-token: ..." \
-H "Content-Type: application/json" \
-d '{"action":"revoke","cn":"离职员工的CN"}'

# 或按签发人 ARN 批量吊销(吊销此人签的所有证书)
curl "$FUNC_URL" \
... \
-d '{"action":"revoke","issued_by":"离职员工的IAM ARN"}'

吊销后 Lambda 自动生成新 CRL 并导入 endpoint,通常数分钟内生效。

签发人追踪

这是做完基本方案之后追加的一个设计,源于一个很现实的问题:证书的 CN 是 CSR 里带过来的,用户随便写。你没有任何办法通过一张证书来反推”实际使用者是谁”。

Client VPN 的连接日志里能看到 CN,但 CN 写的是 test 还是 zhangsan,你不知道实际连接者是谁。离职的时候你想吊销他的证书,但你不确定他用的是哪一张——尤其是管理员代签的场景,好几个人的证书可能 CN 写的都很随意。

想来想去,结论是:没法让证书的使用者对自己负责(CN 不可信),只能让签发者对他签的证书负责。

所以我需要记录”每张证书是谁签的”,而且这个”谁”必须是 AWS 验证过的身份,不能靠调用者自己上报。

做法是把 Lambda 的入口换成 Function URL(authorization_type = AWS_IAM)。调用者必须用 SigV4 签名,Lambda 在 event.requestContext.authorizer.iam.userArn 里能拿到 AWS 验证过的调用者身份。这个值是 AWS 认证层提供的,调用者改不了。

每次签发,Lambda 自动在 issuance-log.jsonl 里追加一条记录:

1
{"serial":"07","cn":"zhangsan","issued_by":"arn:aws:sts::123456789012:assumed-role/my-ops-role/[email protected]","timestamp":"2025-04-10T13:22:01Z"}

openssl 标准的 index.txt 只记录 CN、serial、状态这些,不记录签发人。签发人信息单独存在 issuance-log.jsonl 里,两个文件通过 serial 关联。

这样在离职场景下,可以先用 list 接口按 issued_by 过滤出某人签过的所有证书,确认后一把 revoke 掉。

CRL:不续就全员断连

这是整个方案里最需要注意的地方。Client VPN 一旦 import 过 CRL,就会持续校验 CRL 的有效期。CRL 过期不是”吊销功能失效”,而是所有连接都会被拒绝——无论证书本身是否有效。

所以 CRL 的续期是刚需,不是可选项。

我们用 EventBridge 每周触发一次 Lambda 的 refresh-crl,把 CRL 有效期续到当前时间 +90 天,然后重新 import 到 endpoint。Lambda 同时推一个 CloudWatch 自定义指标 CRLDaysRemaining,配了告警——剩余不到 14 天就报。Lambda 自身的执行失败也有另一个告警兜底。

90 天有效期配合每周刷新,意味着即使 EventBridge 连续挂掉两个多月没人管,CRL 也不会过期。两层告警,容错空间足够。

Function URL 权限的坑

这里踩了一个坑,记录一下。

2025 年 10 月以后,AWS 要求调用 Lambda Function URL 同时具备 lambda:InvokeFunctionUrl 和 lambda:InvokeFunction 两个权限。只有其中一个会 403。

我们同事用的是公司统一维护的开发者角色,这个角色有 lambda:InvokeFunction 但没有 lambda:InvokeFunctionUrl。角色策略由上级团队管,我不能也不该直接改。

解法是在 Lambda 上加 resource-based policy,给特定角色补上 lambda:InvokeFunctionUrl。identity-based policy 提供 InvokeFunction,resource-based policy 提供 InvokeFunctionUrl,两边凑齐。

这里要注意:不要用 principal = "*" 加 source_account 条件。这种写法在同账号下会绕过 identity-based policy 的访问控制,相当于对账号内所有人无差别授权。必须指定具体的角色 ARN。

迁移过程

从 ACM PCA 迁移到自建 CA,不是一步到位,而是分三个阶段做的:

准备阶段——生成新 CA、签发 server cert 导入 ACM、部署 Lambda。所有操作不影响现有 VPN。Terraform 里用 coalesce() 做开关——变量留 null 用旧 PCA 证书,填入新 ARN 用新证书。

切换阶段——先给所有人签发新证书并分发,然后写入 ARN、apply。验证通过后 import 初始 CRL、测试吊销、启用定时刷新。

清理阶段——从 Terraform state 移除旧 PCA 资源,从代码删除,最后删除 PCA 本身(7 天冷却期后永久销毁,$400/月的计费即刻停止)。

在清理阶段执行 PCA 删除之前,旧方案一直可以回滚——把 tfvars 里的 ARN 改回 null,apply 就行。PCA 删除是唯一不可逆的操作,所以放在最后,观察了两天稳定性才执行。

迁移过程中踩了一个雷:自建 CA 签的 server cert 忘了加 Key Usage 扩展(digitalSignature + keyEncipherment),OpenVPN 报 VERIFY KU ERROR。PCA 时代这不是问题,因为 PCA 的证书模板自动带这些扩展。自建 CA 一切得自己来,openssl 签发的时候要显式指定:

1
2
3
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth

S3 状态文件与版本管理

CA 的状态文件都存在一个 S3 桶里:

文件 说明
ca.crt Root CA 证书
index.txt openssl 标准的证书注册表
serial 下一张证书的序列号
crlnumber 下一个 CRL 编号
crl.pem 当前 CRL
issuance-log.jsonl 签发审计日志(含调用者 ARN)

这些文件都是几 KB 级别的,但一旦损坏就没有托管服务帮你兜底了。Lambda 写入时已经用并发限制防冲突,但万一代码 bug 写了脏数据,需要有回退能力。

S3 开了版本管理,旧版本保留 365 天。存储成本可以忽略不计,但出问题时能从历史版本恢复。365 天足够长——客户端证书有效期就是 365 天,任何在有效期内的证书信息都能在历史版本里找到。

多说几句

整个方案从提出到完成大约三天。改动涉及 Lambda 代码、Terraform 模块、IAM 策略、README,量不小但每一步都有验证点和回滚路径。

几个值得记住的教训:

ACM PCA 对于小团队来说性价比极差。Client VPN 的证书管理需求本质上就是签发、吊销、续期,openssl 全能做。把 CA 私钥锁进 Secrets Manager、用 Lambda 包一层签发服务,就能保持”私钥不暴露”的安全模型,同时省掉 $400/月。

CRL 过期会全员断连,这个很多人不知道。一旦 import 过 CRL,Client VPN 就会持续校验有效期。定时续期不是可选项,是必须的。

证书认证和 SSO 是两回事。禁了 SSO 不等于断了 VPN。离职必须吊销证书,而 CN 又是自己写的不可信——所以需要从签发侧做追踪,记录”谁签的”而不是”谁用的”。

curl --aws-sigv4 是个好东西。curl 7.75 以后自带 SigV4 签名支持,不用装 awscurl 之类的第三方工具。macOS Ventura 以后和 Ubuntu 22.04 以后自带的 curl 版本都够。