feat(openvpn): suggest available OIDC ports and add deployment assets

This commit is contained in:
opsx committed 2026-10-21 01:19:49 +08:00
1 parent d6a9a92ad0
commit 003f755b37
14 files changed
+244 -7

No files matched your search

+3 -1
View File
@@ -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
+1
View File
@@ -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)
+84
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+62
View File
@@ -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 拨号用户不会自动绑定飞书用户。
+5 -1
View File
@@ -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 的客户端、认证服务二进制。
+1
View File
@@ -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>
+14
View File
@@ -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

+36 -2
View File
@@ -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>
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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 部分兼容,须实测。",
+1 -1
View File
@@ -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>
+1
View File
@@ -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
+1
View File
@@ -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