feat(openvpn): suggest available OIDC ports and add deployment assets
This commit is contained in:
1 parent
d6a9a92ad0
commit
003f755b37
14 files changed
+244
-7
No files matched your search
@@ -39,7 +39,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
|
|||||||
|
|
||||||
1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用,启用网页登录授权,在应用安全设置中登记回调、发布应用并限定可用人员。先核实飞书租户是否提供标准 OIDC discovery (`/.well-known/openid-configuration`) 与 ID Token;若没有,用 Keycloak / Authentik 等身份代理对接飞书,再把代理的 OIDC issuer 及其客户端凭据填入向导。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。
|
1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用,启用网页登录授权,在应用安全设置中登记回调、发布应用并限定可用人员。先核实飞书租户是否提供标准 OIDC discovery (`/.well-known/openid-configuration`) 与 ID Token;若没有,用 Keycloak / Authentik 等身份代理对接飞书,再把代理的 OIDC issuer 及其客户端凭据填入向导。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。
|
||||||
2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。创建时填 HTTPS issuer、Client ID/Secret、独立公网 HTTPS 入口(例如 `https://vpn-login.example.com`)以及本机服务端口(默认 `19090`)。每条 OIDC 隧道使用不同的公网入口和本机端口。
|
2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。创建时填 HTTPS issuer、Client ID/Secret、独立公网 HTTPS 入口(例如 `https://vpn-login.example.com`)以及本机服务端口(默认 `19090`)。每条 OIDC 隧道使用不同的公网入口和本机端口。
|
||||||
3. 在最终的 OIDC 身份提供方注册引导页显示的 `https://vpn-login.example.com/oauth2/callback`(直连飞书时登记在飞书应用;经代理时登记在代理的 OIDC 客户端)。为该公网入口配置 TLS 和反向代理,将**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。回调由 `openvpn-auth-oauth2` 接收,**不需要**在平台的 `:18080` 增加 `/oauth2/callback`。
|
3. 在最终的 OIDC 身份提供方注册引导页显示的 `https://vpn-login.example.com/oauth2/callback`(直连飞书时登记在飞书应用;经代理时登记在代理的 OIDC 客户端)。公网认证站点可以与管理后台部署在**同一台 opsx-vpn 服务器**:按域名将管理站点转发到 `:18080`、将认证站点的**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。当前实现由 `openvpn-auth-oauth2` 接收回调,平台的 `:18080` 不处理该路径。
|
||||||
4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。
|
4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。
|
||||||
5. 下载 `.ovpn` 并在支持 WebAuth 的客户端试连。上游[客户端兼容性说明](https://github.com/jkroepke/openvpn-auth-oauth2/wiki/OpenVPN)列出 Windows OpenVPN Community Client 2.6+、macOS Tunnelblick 4.0 beta 10+ 和 Viscosity;OpenVPN Connect v3 仅部分兼容,应先实测。客户端需要在 VPN 建立**之前**访问公网 HTTPS 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。
|
5. 下载 `.ovpn` 并在支持 WebAuth 的客户端试连。上游[客户端兼容性说明](https://github.com/jkroepke/openvpn-auth-oauth2/wiki/OpenVPN)列出 Windows OpenVPN Community Client 2.6+、macOS Tunnelblick 4.0 beta 10+ 和 Viscosity;OpenVPN Connect v3 仅部分兼容,应先实测。客户端需要在 VPN 建立**之前**访问公网 HTTPS 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
|
|||||||
|
|
||||||
### Linux 服务器部署
|
### Linux 服务器部署
|
||||||
|
|
||||||
|
现有服务器的构建、升级、备份与 OIDC 入口配置参见 [部署手册](docs/deploy.md)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 交叉编译 Linux 二进制(在 macOS 上)
|
# 1. 交叉编译 Linux 二进制(在 macOS 上)
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o opsx-vpn ./backend
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o opsx-vpn ./backend
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
auth.GET("/readiness", s.readiness)
|
auth.GET("/readiness", s.readiness)
|
||||||
auth.GET("/protocols", s.listProtocols)
|
auth.GET("/protocols", s.listProtocols)
|
||||||
auth.GET("/tunnels", s.listTunnels)
|
auth.GET("/tunnels", s.listTunnels)
|
||||||
|
auth.GET("/tunnels/openvpn/oidc-port", s.nextOIDCPort)
|
||||||
auth.GET("/tunnels/:id", s.getTunnel)
|
auth.GET("/tunnels/:id", s.getTunnel)
|
||||||
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
||||||
auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory)
|
auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nextOIDCPort suggests a free loopback listener; starting the tunnel still
|
||||||
|
// checks the port again because another process may bind it in the meantime.
|
||||||
|
func (s *Server) nextOIDCPort(c *gin.Context) {
|
||||||
|
var tunnels []model.Tunnel
|
||||||
|
if err := store.DB.Where("protocol = ?", "openvpn").Find(&tunnels).Error; err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "无法读取已配置的 OpenVPN 专线"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reserved := make(map[int]bool)
|
||||||
|
maxConfigured := 19089
|
||||||
|
for _, tunnel := range tunnels {
|
||||||
|
cfg, err := store.TunnelConfig(&tunnel)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "proto") == "tcp" || service.Str(cfg, "proto") == "tcp-server" {
|
||||||
|
reserved[int(service.Num(cfg, "port", 1194))] = true
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "auth_mode") != "oidc" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port := int(service.Num(cfg, "oidc_http_port", 19090))
|
||||||
|
reserved[port] = true
|
||||||
|
if port >= 19090 && port > maxConfigured {
|
||||||
|
maxConfigured = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start, direction := maxConfigured+1, 1
|
||||||
|
if start > 65535 {
|
||||||
|
start = 19090
|
||||||
|
}
|
||||||
|
if from := c.Query("from"); from != "" {
|
||||||
|
var err error
|
||||||
|
start, err = strconv.Atoi(from)
|
||||||
|
if err != nil || start < 1024 || start > 65535 {
|
||||||
|
c.JSON(400, gin.H{"error": "端口必须为 1024-65535"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Query("direction") == "-1" {
|
||||||
|
direction = -1
|
||||||
|
} else if c.Query("direction") != "" && c.Query("direction") != "1" {
|
||||||
|
c.JSON(400, gin.H{"error": "方向必须为 1 或 -1"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port, ok := findAvailableOIDCPort(start, direction, reserved, loopbackTCPAvailable); ok {
|
||||||
|
c.JSON(200, gin.H{"port": port})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(409, gin.H{"error": "该方向没有可用的本机认证端口,请手动选择其他起始端口"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAvailableOIDCPort(start, direction int, reserved map[int]bool, available func(int) bool) (int, bool) {
|
||||||
|
for port := start; port >= 1024 && port <= 65535; port += direction {
|
||||||
|
if !reserved[port] && available(port) {
|
||||||
|
return port, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loopbackTCPAvailable(port int) bool {
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = listener.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindAvailableOIDCPortSkipsReservedAndOccupied(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("当前环境不允许监听回环端口: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
occupied := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
if occupied < 1024 || occupied > 65533 {
|
||||||
|
t.Skip("系统分配的临时端口无法用于递增测试")
|
||||||
|
}
|
||||||
|
reserved := map[int]bool{occupied + 1: true}
|
||||||
|
if loopbackTCPAvailable(occupied) {
|
||||||
|
t.Fatal("监听中的端口必须视为占用")
|
||||||
|
}
|
||||||
|
available := func(port int) bool { return port != occupied }
|
||||||
|
port, ok := findAvailableOIDCPort(occupied, 1, reserved, available)
|
||||||
|
if !ok || port != occupied+2 {
|
||||||
|
t.Fatalf("递增应跳过已监听及已配置的端口,得到 %d (ok=%t)", port, ok)
|
||||||
|
}
|
||||||
|
port, ok = findAvailableOIDCPort(occupied+1, -1, reserved, available)
|
||||||
|
if !ok || port != occupied-1 {
|
||||||
|
t.Fatalf("递减应跳过已配置及已监听的端口,得到 %d (ok=%t)", port, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# opsx-vpn 部署与升级
|
||||||
|
|
||||||
|
本文件根据服务器上的 `/home/ubuntu/depoly.md` 整理;以代码仓库中的步骤为准,实际主机、端口、访问凭据和安全组规则按环境调整。不要将管理员密码、SQLite 数据库、`data/secret.key` 或实例私钥提交到仓库。
|
||||||
|
|
||||||
|
## 服务器准备
|
||||||
|
|
||||||
|
- Ubuntu 24.04;安装 Git、Go、Node.js、npm 和项目所需的协议软件包。运行 OpenVPN 专线还需要 `openvpn`、`easy-rsa`、`iproute2` 与 `iptables`。OIDC 专线要求 OpenVPN 2.6.2+,另需安装 `openvpn-auth-oauth2` 到 `PATH` 或 `data/bin/`。
|
||||||
|
- 在 VPN 网关上开启 IPv4 转发,按实际启用的协议开放 VPN 数据端口。管理端 `18080` 应仅供可信网络或 SSH 隧道访问。运行账户需要管理 TUN 和防火墙的权限。
|
||||||
|
- 服务在仓库目录运行;默认数据目录为 `./data`。`data/` 保存数据库、加密密钥和各实例配置,升级时不得删除或覆盖。
|
||||||
|
|
||||||
|
## 首次构建与启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitee.com/opsx-tools/opsx-vpn.git
|
||||||
|
cd opsx-vpn
|
||||||
|
cd frontend && npm ci && npm run build && cd ..
|
||||||
|
go build -ldflags="-s -w" -o opsx-vpn ./backend
|
||||||
|
sudo sh -c 'VPN_LISTEN_ADDR=:18080 setsid nohup ./opsx-vpn > /tmp/opsx-vpn.log 2>&1 < /dev/null &'
|
||||||
|
curl --fail http://127.0.0.1:18080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
前端资源在构建 Go 二进制时内嵌,修改前端后须重新执行 **两个**构建步骤。首次登录请立即修改初始密码并启用适当的管理端保护。
|
||||||
|
|
||||||
|
## 在现有服务器上升级
|
||||||
|
|
||||||
|
先确认服务器工作树干净且仓库地址、分支符合预期。以下操作以 `/home/ubuntu/opsx-vpn` 和管理端口 `18080` 为例;升级前记录已有 VPN 实例进程与健康状态。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/opsx-vpn
|
||||||
|
git status --short
|
||||||
|
curl --fail http://127.0.0.1:18080/api/health
|
||||||
|
sudo cp -p opsx-vpn "opsx-vpn.bak-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
git pull --ff-only origin master
|
||||||
|
cd frontend && npm run build && cd ..
|
||||||
|
go build -ldflags="-s -w" -o opsx-vpn.new ./backend
|
||||||
|
```
|
||||||
|
|
||||||
|
编译通过后,按监听端口核对管理程序 PID,精确停止该进程;**不要停止仍在运行的 OpenVPN 实例**。确认端口释放后替换并启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ss -tlnp | grep ':18080'
|
||||||
|
# 确认 PID 确实属于 opsx-vpn 后:sudo kill <管理程序PID>
|
||||||
|
sudo mv opsx-vpn.new opsx-vpn
|
||||||
|
sudo chmod 755 opsx-vpn
|
||||||
|
sudo sh -c 'cd /home/ubuntu/opsx-vpn && VPN_LISTEN_ADDR=:18080 setsid nohup ./opsx-vpn > /tmp/opsx-vpn.log 2>&1 < /dev/null &'
|
||||||
|
curl --fail http://127.0.0.1:18080/api/health
|
||||||
|
sudo ss -tlnp | grep ':18080'
|
||||||
|
```
|
||||||
|
|
||||||
|
再检查页面静态资源、`/tmp/opsx-vpn.log` 和原有 VPN 实例状态。若新版本无法启动,停掉新管理进程,复制最近的 `opsx-vpn.bak-*` 到 `opsx-vpn` 并按上面的方式启动;如涉及数据模型变更,先依据对应变更的迁移说明处理数据库,不能直接覆盖 `data/`。
|
||||||
|
|
||||||
|
## 飞书 / OIDC 登录入口
|
||||||
|
|
||||||
|
`http://121.4.111.208:18080` 是当前管理界面示例,**既不是 OIDC issuer,也不是 OAuth 回调地址**。当前项目在 [OpenVPN OIDC 配置](../backend/internal/service/openvpn/openvpn.go)中要求 `oidc_issuer` 和 `oidc_base_url` 均为 HTTPS。issuer 指向提供标准 OIDC discovery 和 ID Token 的身份提供方(飞书若不支持,需使用 Keycloak / Authentik 等身份代理)。
|
||||||
|
|
||||||
|
认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
||||||
|
|
||||||
|
该域名须由你控制并解析到反向代理公网地址。可以在已有反向代理面板申请 Let's Encrypt 免费证书:HTTP-01 需要公网 TCP 80 能访问验证路径 `/.well-known/acme-challenge/`,续期时也要保留该路径;443 为认证入口提供 HTTPS。无需上传已有证书,也无需在 opsx-vpn 内再开发证书申请功能。若代理与 VPN 主机分离,代理上的 `127.0.0.1` 指代理主机自身,应改用受限的私网地址,并评估认证流量在私网链路上的保护。
|
||||||
|
|
||||||
|
`/.well-known/openid-configuration` 属于发行者域名,不是认证入口上的 VPN 回调。不要只放行 `/oauth2/callback`:认证组件还需要发起登录等路由。认证入口使用独立域名,只反代给认证组件;如需按路径白名单返回 Nginx `444`,须先核对实际安装版本的完整路由,并保障 HTTP-01 验证路径。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
||||||
|
|
||||||
|
启用飞书认证前,需安装并验证 `openvpn-auth-oauth2`、准备身份提供方凭据、配置 HTTPS 反代,并用支持 WebAuth 的客户端联调。现有 LDAP 拨号用户不会自动绑定飞书用户。
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
- FR-02:OIDC 模式生成 OpenVPN 管理接口 + WebAuth 配置,客户端不再要求本地/LDAP 密码;认证服务缺失时不能退回弱认证。
|
- FR-02:OIDC 模式生成 OpenVPN 管理接口 + WebAuth 配置,客户端不再要求本地/LDAP 密码;认证服务缺失时不能退回弱认证。
|
||||||
- FR-03:使用独立的 `openvpn-auth-oauth2` 服务;浏览器通过外网可达 HTTPS 地址接收 OIDC 回调,管理接口只在本机使用。
|
- FR-03:使用独立的 `openvpn-auth-oauth2` 服务;浏览器通过外网可达 HTTPS 地址接收 OIDC 回调,管理接口只在本机使用。
|
||||||
- FR-04:身份提供方必须显式限制可访问该应用的人员;VPN 拨号用户列表仍独立,不能凭同名邮箱自动认作 LDAP 用户。
|
- FR-04:身份提供方必须显式限制可访问该应用的人员;VPN 拨号用户列表仍独立,不能凭同名邮箱自动认作 LDAP 用户。
|
||||||
|
- FR-05:新建 OIDC 专线时从 19090 起按已有配置的最大端口递增;手动加减或输入时跳过已配置及本机已监听的端口,编辑已有专线时保留原端口。
|
||||||
- NFR-01:OIDC 凭据及管理密码不可出现在隧道公开 JSON、客户端配置或普通日志,配置文件权限为 0600。
|
- NFR-01:OIDC 凭据及管理密码不可出现在隧道公开 JSON、客户端配置或普通日志,配置文件权限为 0600。
|
||||||
- NFR-02:不支持浏览器 WebAuth 的客户端应有明确兼容性提示;OpenVPN 服务端版本需满足上游最低 2.6.2 要求。
|
- NFR-02:不支持浏览器 WebAuth 的客户端应有明确兼容性提示;OpenVPN 服务端版本需满足上游最低 2.6.2 要求。
|
||||||
|
|
||||||
@@ -27,10 +28,11 @@
|
|||||||
- 在原有 `auth_mode` 增加 `oidc`,单独生成 `management-client-auth`、`auth-user-pass-optional` 和本机 Unix 管理 socket,禁用密码校验脚本;OpenVPN 基础 PKI 和 `tls-crypt` 沿用。
|
- 在原有 `auth_mode` 增加 `oidc`,单独生成 `management-client-auth`、`auth-user-pass-optional` 和本机 Unix 管理 socket,禁用密码校验脚本;OpenVPN 基础 PKI 和 `tls-crypt` 沿用。
|
||||||
- `openvpn-auth-oauth2` 使用独立 HTTPS 反代入口、飞书(或兼容身份代理)的 OIDC issuer、Client ID/Secret 与本机管理 socket。每个 OIDC 隧道使用独立回调主机或端口、管理 socket 与认证服务实例。
|
- `openvpn-auth-oauth2` 使用独立 HTTPS 反代入口、飞书(或兼容身份代理)的 OIDC issuer、Client ID/Secret 与本机管理 socket。每个 OIDC 隧道使用独立回调主机或端口、管理 socket 与认证服务实例。
|
||||||
- 在向导和编辑页面提供步骤提示,告知回调地址属于认证服务,并标明管理员应在飞书开放平台创建应用、配置登录与人员授权,确认标准 OIDC 发现文档及客户端支持。
|
- 在向导和编辑页面提供步骤提示,告知回调地址属于认证服务,并标明管理员应在飞书开放平台创建应用、配置登录与人员授权,确认标准 OIDC 发现文档及客户端支持。
|
||||||
|
- 受登录保护的端口推荐接口扫描已有 OpenVPN 配置及本机回环监听情况;仅返回推荐端口,不预留端口。保存时检查专线配置冲突,启动时再检查真实监听冲突,避免推荐与启动之间的竞争。
|
||||||
- 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。
|
- 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。
|
||||||
|
|
||||||
## 4. 接口、数据与配置契约
|
## 4. 接口、数据与配置契约
|
||||||
- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;不新增虚假的 `/oauth2/callback` 后端接口。
|
- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;新增 `GET /api/tunnels/openvpn/oidc-port`(可选 `from`、`direction`)推荐空闲本机端口;不新增虚假的 `/oauth2/callback` 后端接口。
|
||||||
- 数据模型 / 迁移:沿用隧道配置 JSON 与 Secrets 密文,无强制数据库迁移。
|
- 数据模型 / 迁移:沿用隧道配置 JSON 与 Secrets 密文,无强制数据库迁移。
|
||||||
- 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。
|
- 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。
|
||||||
- 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。
|
- 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
| 认证服务停机 / 回调不可达 | 不能绕过认证;恢复服务与反代 | 客户端连接失败、服务日志 |
|
| 认证服务停机 / 回调不可达 | 不能绕过认证;恢复服务与反代 | 客户端连接失败、服务日志 |
|
||||||
| 使用不支持 WebAuth 的客户端 | 在向导提示已验证的客户端与版本 | 客户端不弹浏览器或登录超时 |
|
| 使用不支持 WebAuth 的客户端 | 在向导提示已验证的客户端与版本 | 客户端不弹浏览器或登录超时 |
|
||||||
| 未绑定本地 LDAP 用户 | 明确由身份源应用授权控制准入;后续按稳定 subject 制定绑定设计 | 管理端用户数与实际 OIDC 会话不一致 |
|
| 未绑定本地 LDAP 用户 | 明确由身份源应用授权控制准入;后续按稳定 subject 制定绑定设计 | 管理端用户数与实际 OIDC 会话不一致 |
|
||||||
|
| 查询端口后被其他进程占用 | 推荐结果不视为预留;启动时拒绝冲突,改选端口重试 | 启动报端口占用,原有隧道继续运行 |
|
||||||
|
|
||||||
### 验收标准
|
### 验收标准
|
||||||
| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 |
|
| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 |
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
| AC-04 | FR-04 | 未获应用授权的用户登录 | 被身份提供方或代理拒绝 | 真实联调 | Not Run |
|
| AC-04 | FR-04 | 未获应用授权的用户登录 | 被身份提供方或代理拒绝 | 真实联调 | Not Run |
|
||||||
| AC-05 | NFR-01 | 创建/编辑 OIDC 专线并检查文件及 API | 密钥不公开,文件 0600 | Go 测试与文件检查 | 单元测试通过;真实 API 交互待做 |
|
| AC-05 | NFR-01 | 创建/编辑 OIDC 专线并检查文件及 API | 密钥不公开,文件 0600 | Go 测试与文件检查 | 单元测试通过;真实 API 交互待做 |
|
||||||
| AC-06 | NFR-02 | OpenVPN 版本过低或客户端无 WebAuth | 明确错误与提示 | Go 测试及手动客户端检查 | 版本单元测试通过;客户端手动检查待做 |
|
| AC-06 | NFR-02 | OpenVPN 版本过低或客户端无 WebAuth | 明确错误与提示 | Go 测试及手动客户端检查 | 版本单元测试通过;客户端手动检查待做 |
|
||||||
|
| AC-07 | FR-05 | 已配置 19090、监听 19091 后新建/加减端口 | 自动推荐 19092;编辑原有端口不变;启动前再检测冲突 | Go 端口选择测试、前端构建、真实页面手动检查 | 端口跳过单元测试与前端构建通过;真实页面待检查 |
|
||||||
|
|
||||||
## 6. 发布与恢复(S3 必填)
|
## 6. 发布与恢复(S3 必填)
|
||||||
- 前置条件:飞书开放平台应用获管理员发布与用户授权;OIDC discovery 可达;公网 HTTPS 域名/证书及反代;OpenVPN >=2.6.2、支持 WebAuth 的客户端、认证服务二进制。
|
- 前置条件:飞书开放平台应用获管理员发布与用户授权;OIDC discovery 可达;公网 HTTPS 域名/证书及反代;OpenVPN >=2.6.2、支持 WebAuth 的客户端、认证服务二进制。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>VPN 专线管理平台</title>
|
<title>VPN 专线管理平台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop stop-color="#153a75"/>
|
||||||
|
<stop offset="1" stop-color="#087ca5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="15" fill="url(#bg)"/>
|
||||||
|
<path d="M32 8 51 16v13c0 13-7.5 22-19 28C20.5 51 13 42 13 29V16Z" fill="#fff"/>
|
||||||
|
<path d="M32 13 46 19v10c0 10-5 17-14 22-9-5-14-12-14-22V19Z" fill="#0b568c"/>
|
||||||
|
<path d="M22 25h7l4 13 5-13h5" fill="none" stroke="#66e2df" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
|
||||||
|
<circle cx="22" cy="25" r="2.5" fill="#fff"/>
|
||||||
|
<circle cx="43" cy="25" r="2.5" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 692 B |
@@ -30,7 +30,11 @@
|
|||||||
<div class="field-tip">{{ $t('oidcGuide.baseTip') }}</div>
|
<div class="field-tip">{{ $t('oidcGuide.baseTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('oidcGuide.listenPort')">
|
<el-form-item :label="$t('oidcGuide.listenPort')">
|
||||||
<el-input-number v-model="config.oidc_http_port" :min="1024" :max="65535" />
|
<div class="port-controls">
|
||||||
|
<el-button :disabled="portLoading || !config.oidc_http_port || config.oidc_http_port <= 1024" @click="stepPort(-1)">−</el-button>
|
||||||
|
<el-input-number v-model="config.oidc_http_port" :controls="false" :min="1024" :max="65535" :disabled="portLoading" @change="changePort" />
|
||||||
|
<el-button :disabled="portLoading || !config.oidc_http_port || config.oidc_http_port >= 65535" @click="stepPort(1)">+</el-button>
|
||||||
|
</div>
|
||||||
<div class="field-tip">{{ $t('oidcGuide.portTip') }}</div>
|
<div class="field-tip">{{ $t('oidcGuide.portTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert type="warning" :closable="false" class="guide-alert" :title="$t('oidcGuide.callbackTitle')">
|
<el-alert type="warning" :closable="false" class="guide-alert" :title="$t('oidcGuide.callbackTitle')">
|
||||||
@@ -44,9 +48,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
|
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
|
||||||
|
const { t } = useI18n()
|
||||||
|
const portLoading = ref(false)
|
||||||
|
async function requestPort(from, direction, previous = null) {
|
||||||
|
portLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = from == null ? {} : { from, direction }
|
||||||
|
const { data } = await api.get('/tunnels/openvpn/oidc-port', { params })
|
||||||
|
props.config.oidc_http_port = data.port
|
||||||
|
} catch (error) {
|
||||||
|
if (Number.isInteger(previous)) props.config.oidc_http_port = previous
|
||||||
|
ElMessage.error(error.response?.data?.error || t('oidcGuide.portLookupFailed'))
|
||||||
|
} finally {
|
||||||
|
portLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stepPort(direction) {
|
||||||
|
const previous = Number(props.config.oidc_http_port)
|
||||||
|
requestPort(previous + direction, direction, previous)
|
||||||
|
}
|
||||||
|
function changePort(value, previous) {
|
||||||
|
if (!Number.isInteger(value) || value === previous) return
|
||||||
|
requestPort(value, value < previous ? -1 : 1, previous)
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.editing) requestPort(null, 1)
|
||||||
|
})
|
||||||
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
|
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
|
||||||
const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
||||||
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing) ? 1
|
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing) ? 1
|
||||||
@@ -61,6 +94,7 @@ const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
|||||||
.guide-alert p { margin: 8px 0 0; line-height: 1.5; }
|
.guide-alert p { margin: 8px 0 0; line-height: 1.5; }
|
||||||
.guide-alert ol { padding-left: 20px; line-height: 1.7; }
|
.guide-alert ol { padding-left: 20px; line-height: 1.7; }
|
||||||
.field-tip { margin-top: 4px; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
|
.field-tip { margin-top: 4px; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
|
||||||
|
.port-controls { display: flex; gap: 6px; align-items: center; }
|
||||||
code { overflow-wrap: anywhere; }
|
code { overflow-wrap: anywhere; }
|
||||||
@media (max-width: 720px) { .guide-steps { overflow-x: auto; justify-content: flex-start; } .guide-steps :deep(.el-step) { min-width: 120px; } }
|
@media (max-width: 720px) { .guide-steps { overflow-x: auto; justify-content: flex-start; } .guide-steps :deep(.el-step) { min-width: 120px; } }
|
||||||
</style>
|
</style>
|
||||||
@@ -248,7 +248,8 @@
|
|||||||
"baseUrl": "Public sign-in URL",
|
"baseUrl": "Public sign-in URL",
|
||||||
"baseTip": "Use a dedicated HTTPS root URL reachable before VPN connection. Give each OIDC tunnel its own endpoint.",
|
"baseTip": "Use a dedicated HTTPS root URL reachable before VPN connection. Give each OIDC tunnel its own endpoint.",
|
||||||
"listenPort": "Local auth service port",
|
"listenPort": "Local auth service port",
|
||||||
"portTip": "The service listens on 127.0.0.1 only; each tunnel needs a distinct port.",
|
"portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.",
|
||||||
|
"portLookupFailed": "Could not find an available port. Retry or enter one manually.",
|
||||||
"callbackTitle": "Register this callback with the final OIDC provider",
|
"callbackTitle": "Register this callback with the final OIDC provider",
|
||||||
"callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.",
|
"callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.",
|
||||||
"testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.",
|
"testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.",
|
||||||
|
|||||||
@@ -248,7 +248,8 @@
|
|||||||
"baseUrl": "认证入口公网地址",
|
"baseUrl": "认证入口公网地址",
|
||||||
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
|
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
|
||||||
"listenPort": "本机认证服务端口",
|
"listenPort": "本机认证服务端口",
|
||||||
"portTip": "认证服务仅监听 127.0.0.1;多条专线各用不同端口。",
|
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
|
||||||
|
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
|
||||||
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
|
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
|
||||||
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
|
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
|
||||||
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
|
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="login-left">
|
<div class="login-left">
|
||||||
<div class="brand-content fade-in">
|
<div class="brand-content fade-in">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<el-icon :size="32" color="#7dd3fc"><Lock /></el-icon>
|
<img src="/favicon.svg" alt="" width="40" height="40" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="brand-title">{{ $t('login.title') }}</h1>
|
<h1 class="brand-title">{{ $t('login.title') }}</h1>
|
||||||
<p class="brand-subtitle">{{ $t('login.subtitle') }}</p>
|
<p class="brand-subtitle">{{ $t('login.subtitle') }}</p>
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ async function save() {
|
|||||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
|
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
|
||||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||||
}
|
}
|
||||||
|
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||||
}
|
}
|
||||||
trafficMode.value = trafficMode.value
|
trafficMode.value = trafficMode.value
|
||||||
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ async function create() {
|
|||||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_base_url']) {
|
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_base_url']) {
|
||||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||||
}
|
}
|
||||||
|
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||||
}
|
}
|
||||||
trafficMode.value = trafficMode.value
|
trafficMode.value = trafficMode.value
|
||||||
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
||||||
|
|||||||
Reference in new issue
Block a user