diff --git a/README.md b/README.md index ce93ee3..9eebf76 100644 --- a/README.md +++ b/README.md @@ -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。 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/-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 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。 @@ -56,6 +56,8 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交 ### Linux 服务器部署 +现有服务器的构建、升级、备份与 OIDC 入口配置参见 [部署手册](docs/deploy.md)。 + ```bash # 1. 交叉编译 Linux 二进制(在 macOS 上) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o opsx-vpn ./backend diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 6dd2897..db9e814 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -230,6 +230,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { auth.GET("/readiness", s.readiness) auth.GET("/protocols", s.listProtocols) auth.GET("/tunnels", s.listTunnels) + auth.GET("/tunnels/openvpn/oidc-port", s.nextOIDCPort) auth.GET("/tunnels/:id", s.getTunnel) auth.GET("/tunnels/:id/status", s.tunnelStatus) auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory) diff --git a/backend/internal/api/oidc_port.go b/backend/internal/api/oidc_port.go new file mode 100644 index 0000000..8f59b1e --- /dev/null +++ b/backend/internal/api/oidc_port.go @@ -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 +} diff --git a/backend/internal/api/oidc_port_test.go b/backend/internal/api/oidc_port_test.go new file mode 100644 index 0000000..efd7ff5 --- /dev/null +++ b/backend/internal/api/oidc_port_test.go @@ -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) + } +} diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..5ee3a3b --- /dev/null +++ b/docs/deploy.md @@ -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 拨号用户不会自动绑定飞书用户。 diff --git a/docs/specs/SDD-001-openvpn-oidc.md b/docs/specs/SDD-001-openvpn-oidc.md index 8bc07a7..ec2477c 100644 --- a/docs/specs/SDD-001-openvpn-oidc.md +++ b/docs/specs/SDD-001-openvpn-oidc.md @@ -20,6 +20,7 @@ - FR-02:OIDC 模式生成 OpenVPN 管理接口 + WebAuth 配置,客户端不再要求本地/LDAP 密码;认证服务缺失时不能退回弱认证。 - FR-03:使用独立的 `openvpn-auth-oauth2` 服务;浏览器通过外网可达 HTTPS 地址接收 OIDC 回调,管理接口只在本机使用。 - FR-04:身份提供方必须显式限制可访问该应用的人员;VPN 拨号用户列表仍独立,不能凭同名邮箱自动认作 LDAP 用户。 +- FR-05:新建 OIDC 专线时从 19090 起按已有配置的最大端口递增;手动加减或输入时跳过已配置及本机已监听的端口,编辑已有专线时保留原端口。 - NFR-01:OIDC 凭据及管理密码不可出现在隧道公开 JSON、客户端配置或普通日志,配置文件权限为 0600。 - 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` 沿用。 - `openvpn-auth-oauth2` 使用独立 HTTPS 反代入口、飞书(或兼容身份代理)的 OIDC issuer、Client ID/Secret 与本机管理 socket。每个 OIDC 隧道使用独立回调主机或端口、管理 socket 与认证服务实例。 - 在向导和编辑页面提供步骤提示,告知回调地址属于认证服务,并标明管理员应在飞书开放平台创建应用、配置登录与人员授权,确认标准 OIDC 发现文档及客户端支持。 +- 受登录保护的端口推荐接口扫描已有 OpenVPN 配置及本机回环监听情况;仅返回推荐端口,不预留端口。保存时检查专线配置冲突,启动时再检查真实监听冲突,避免推荐与启动之间的竞争。 - 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。 ## 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 密文,无强制数据库迁移。 - 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。 - 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。 @@ -44,6 +46,7 @@ | 认证服务停机 / 回调不可达 | 不能绕过认证;恢复服务与反代 | 客户端连接失败、服务日志 | | 使用不支持 WebAuth 的客户端 | 在向导提示已验证的客户端与版本 | 客户端不弹浏览器或登录超时 | | 未绑定本地 LDAP 用户 | 明确由身份源应用授权控制准入;后续按稳定 subject 制定绑定设计 | 管理端用户数与实际 OIDC 会话不一致 | +| 查询端口后被其他进程占用 | 推荐结果不视为预留;启动时拒绝冲突,改选端口重试 | 启动报端口占用,原有隧道继续运行 | ### 验收标准 | AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | @@ -54,6 +57,7 @@ | AC-04 | FR-04 | 未获应用授权的用户登录 | 被身份提供方或代理拒绝 | 真实联调 | Not Run | | AC-05 | NFR-01 | 创建/编辑 OIDC 专线并检查文件及 API | 密钥不公开,文件 0600 | Go 测试与文件检查 | 单元测试通过;真实 API 交互待做 | | AC-06 | NFR-02 | OpenVPN 版本过低或客户端无 WebAuth | 明确错误与提示 | Go 测试及手动客户端检查 | 版本单元测试通过;客户端手动检查待做 | +| AC-07 | FR-05 | 已配置 19090、监听 19091 后新建/加减端口 | 自动推荐 19092;编辑原有端口不变;启动前再检测冲突 | Go 端口选择测试、前端构建、真实页面手动检查 | 端口跳过单元测试与前端构建通过;真实页面待检查 | ## 6. 发布与恢复(S3 必填) - 前置条件:飞书开放平台应用获管理员发布与用户授权;OIDC discovery 可达;公网 HTTPS 域名/证书及反代;OpenVPN >=2.6.2、支持 WebAuth 的客户端、认证服务二进制。 diff --git a/frontend/index.html b/frontend/index.html index b40bf5a..25b8111 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + VPN 专线管理平台 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..a79b736 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/components/OpenVPNOIDCGuide.vue b/frontend/src/components/OpenVPNOIDCGuide.vue index b3af088..0eb7714 100644 --- a/frontend/src/components/OpenVPNOIDCGuide.vue +++ b/frontend/src/components/OpenVPNOIDCGuide.vue @@ -30,7 +30,11 @@
{{ $t('oidcGuide.baseTip') }}
- +
+ − + + + +
{{ $t('oidcGuide.portTip') }}
@@ -44,9 +48,38 @@