Refresh tunnel actions promptly and clarify Feishu OIDC setup
This commit is contained in:
1 parent
41fb9d5631
commit
6a1bace3e9
10 files changed
+136
-32
No files matched your search
@@ -39,7 +39,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
|
|||||||
|
|
||||||
在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。
|
在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。
|
||||||
|
|
||||||
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 OIDC 客户端的 ID/Secret**填入向导。飞书 App ID/Secret 只配置在代理的飞书上游,不能直接当成代理签发的 OIDC 客户端凭据。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。OpsX VPN 当前不提供 OIDC discovery、签名密钥及 ID Token 签发能力,管理地址不能充当 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 客户端)。公网认证站点可以与管理后台部署在**同一台 opsx-vpn 服务器**:按域名将管理站点转发到 `:18080`、将认证站点的**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。当前实现由 `openvpn-auth-oauth2` 接收回调,平台的 `:18080` 不处理该路径。
|
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 或本地密码。
|
||||||
|
|||||||
@@ -1040,12 +1040,14 @@ func (s *Server) startTunnel(c *gin.Context) {
|
|||||||
t.State = "error"
|
t.State = "error"
|
||||||
t.LastError = err.Error()
|
t.LastError = err.Error()
|
||||||
store.DB.Save(t)
|
store.DB.Save(t)
|
||||||
|
s.mgr.RemoveStatus(t.ID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.State = "running"
|
t.State = "running"
|
||||||
t.LastError = ""
|
t.LastError = ""
|
||||||
store.DB.Save(t)
|
store.DB.Save(t)
|
||||||
|
s.mgr.RemoveStatus(t.ID) // the monitor's previous snapshot can lag behind this transition
|
||||||
store.Audit(c.GetString("username"), "start_tunnel", t.Name, "")
|
store.Audit(c.GetString("username"), "start_tunnel", t.Name, "")
|
||||||
c.JSON(200, gin.H{"ok": true, "state": "running"})
|
c.JSON(200, gin.H{"ok": true, "state": "running"})
|
||||||
}
|
}
|
||||||
@@ -1068,6 +1070,7 @@ func (s *Server) stopTunnel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
t.State = "stopped"
|
t.State = "stopped"
|
||||||
store.DB.Save(t)
|
store.DB.Save(t)
|
||||||
|
s.mgr.RemoveStatus(t.ID)
|
||||||
store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "")
|
store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "")
|
||||||
c.JSON(200, gin.H{"ok": true, "state": "stopped"})
|
c.JSON(200, gin.H{"ok": true, "state": "stopped"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/core"
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stateTestProtocol struct{}
|
||||||
|
|
||||||
|
func (stateTestProtocol) Name() string { return "state-test" }
|
||||||
|
func (stateTestProtocol) DisplayName() string { return "state-test" }
|
||||||
|
func (stateTestProtocol) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
func (stateTestProtocol) Create(*model.Tunnel) error { return nil }
|
||||||
|
func (stateTestProtocol) Start(*model.Tunnel) error { return nil }
|
||||||
|
func (stateTestProtocol) Stop(*model.Tunnel) error { return nil }
|
||||||
|
func (stateTestProtocol) Status(*model.Tunnel) (core.Status, error) { return core.Status{}, nil }
|
||||||
|
func (stateTestProtocol) GenerateClientConfig(*model.Tunnel) (string, error) { return "", nil }
|
||||||
|
func (stateTestProtocol) Delete(*model.Tunnel) error { return nil }
|
||||||
|
func (stateTestProtocol) Available() (bool, string) { return true, "" }
|
||||||
|
|
||||||
|
func TestStartStopDropsStaleRuntimeSnapshot(t *testing.T) {
|
||||||
|
oldDB := store.DB
|
||||||
|
t.Cleanup(func() { store.DB = oldDB })
|
||||||
|
if err := store.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
line := model.Tunnel{Name: "snapshot", Protocol: "state-test", State: "running", Config: "{}"}
|
||||||
|
if err := store.DB.Create(&line).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgr := core.NewManager()
|
||||||
|
mgr.Register(stateTestProtocol{})
|
||||||
|
s := NewServer(mgr)
|
||||||
|
for _, tc := range []struct{ operation, oldState, wantState string }{
|
||||||
|
{"stop", "running", "stopped"},
|
||||||
|
{"start", "stopped", "running"},
|
||||||
|
} {
|
||||||
|
mgr.SetStatus(line.ID, core.Status{State: tc.oldState})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: strconv.FormatUint(uint64(line.ID), 10)}}
|
||||||
|
if tc.operation == "stop" {
|
||||||
|
s.stopTunnel(c)
|
||||||
|
} else {
|
||||||
|
s.startTunnel(c)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", tc.operation, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := mgr.GetStatus(line.ID); ok {
|
||||||
|
t.Fatalf("%s left the old runtime status cached", tc.operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
listResponse := httptest.NewRecorder()
|
||||||
|
listContext, _ := gin.CreateTestContext(listResponse)
|
||||||
|
s.listTunnels(listContext)
|
||||||
|
var rows []struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Runtime *core.Status `json:"runtime"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listResponse.Body.Bytes(), &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].State != tc.wantState || rows[0].Runtime != nil {
|
||||||
|
t.Fatalf("%s list returned stale state: %+v", tc.operation, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
|
|||||||
cfg["oidc_client_secret"] = old["oidc_client_secret"]
|
cfg["oidc_client_secret"] = old["oidc_client_secret"]
|
||||||
}
|
}
|
||||||
if service.Str(cfg, "oidc_client_secret") == "" {
|
if service.Str(cfg, "oidc_client_secret") == "" {
|
||||||
return fmt.Errorf("oidc_client_secret 必填;请在飞书应用凭据中复制 Client Secret")
|
return fmt.Errorf("oidc_client_secret 必填;请从 OIDC 发行者对应的客户端凭据中复制 Client Secret")
|
||||||
}
|
}
|
||||||
for _, key := range []string{"oidc_http_secret", "oidc_management_password"} {
|
for _, key := range []string{"oidc_http_secret", "oidc_management_password"} {
|
||||||
cfg[key] = old[key] // internal secrets are never supplied by the API caller
|
cfg[key] = old[key] // internal secrets are never supplied by the API caller
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ sudo ss -tlnp | grep ':18080'
|
|||||||
|
|
||||||
## 飞书 / OIDC 登录入口
|
## 飞书 / 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 等身份代理)。
|
`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 等身份代理)。OpsX VPN 当前不签发 OIDC 令牌;经代理接入时,飞书 App ID/Secret 配在代理的飞书上游,向导的 Client ID/Secret 则来自代理中为 VPN 创建的 OIDC 客户端。
|
||||||
|
|
||||||
认证入口另设可供 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-浏览器认证)。
|
认证入口另设可供 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-浏览器认证)。
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 版本 / 变更级别 | 0.1 / S3(身份认证、部署) |
|
| 版本 / 变更级别 | 0.1 / S3(身份认证、部署) |
|
||||||
| 设计状态 | Draft |
|
| 设计状态 | Draft |
|
||||||
| 实现状态 | 已实现本地集成;待 Linux + 飞书环境联调 |
|
| 实现状态 | OpenVPN WebAuth 配置与认证组件进程管理已实现;真实飞书登录仍待 OIDC 提供方、HTTPS 入口和组件部署联调 |
|
||||||
| 负责人 / 日期 | 待指定 / 2026-10-20 |
|
| 负责人 / 日期 | 待指定 / 2026-10-20 |
|
||||||
| 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 |
|
| 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 |
|
||||||
| 批准状态 | 未批准;用户已明确要求开始开发 |
|
| 批准状态 | 未批准;用户已明确要求开始开发 |
|
||||||
@@ -70,3 +70,10 @@
|
|||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| 飞书租户是否公开标准 OIDC discovery / ID token | 阻塞真实联调,不阻塞本地实现 | 先从飞书应用或身份代理获取 issuer 并校验 discovery;不兼容时使用 Keycloak/Authentik 代理 |
|
| 飞书租户是否公开标准 OIDC discovery / ID token | 阻塞真实联调,不阻塞本地实现 | 先从飞书应用或身份代理获取 issuer 并校验 discovery;不兼容时使用 Keycloak/Authentik 代理 |
|
||||||
| 是否需要按 LDAP 用户逐一授权 OIDC | 不阻塞第一版 | 首版仅信任身份源应用的可用范围;逐一绑定必须引入稳定 subject 的独立授权方案 |
|
| 是否需要按 LDAP 用户逐一授权 OIDC | 不阻塞第一版 | 首版仅信任身份源应用的可用范围;逐一绑定必须引入稳定 subject 的独立授权方案 |
|
||||||
|
|
||||||
|
### 2026-10-21 接入核查与发布边界
|
||||||
|
|
||||||
|
- 飞书应用的 App ID/Secret 是**飞书上游**应用凭据。若使用 Keycloak / Authentik,先在代理中配置飞书上游和其专用回调,再在代理中创建供 `openvpn-auth-oauth2` 使用的 OIDC 客户端;向导中的 issuer、Client ID/Secret 全部从代理获取。仅当飞书本身确实发布可用的 OIDC discovery、JWKS 与 ID Token 时,才能直接使用飞书提供的 issuer 和对应的客户端凭据。OpsX VPN 当前不提供签名密钥、OIDC discovery 或 ID Token,`http://<管理主机>:18080` 不能充当 issuer。
|
||||||
|
- 对外回调为 `https://<认证入口>/oauth2/callback`,由 `openvpn-auth-oauth2` 处理;代理方案还要把**代理提供给飞书的上游回调**登记在飞书应用,两条回调不能互换。认证入口可与管理后台同机不同域名部署,需要 HTTPS 与完整认证组件路由转发,`127.0.0.1:19090` 不对公网开放。
|
||||||
|
- 2026-10-21 服务器检查:OpenVPN 2.6.19 已安装;`openvpn-auth-oauth2`、Nginx/Certbot 和 80/443 监听均未检测到。未安装组件、准备可访问的 issuer、TLS 证书、飞书应用发布与授权之前,不切换现有运行中的 OpenVPN 专线到 OIDC。先用独立测试专线验证允许与拒绝、断线重连,再变更生产线路。发现失败时停止测试专线并保留原密码专线,不将缺失组件视为已完成登录联调。
|
||||||
|
- 飞书应用密钥曾出现在交互截图中,管理员应轮换后仅输入后台密钥框;仓库、文档、日志和测试均不得包含实际密钥。
|
||||||
@@ -14,12 +14,14 @@
|
|||||||
<p>{{ $t('oidcGuide.scopeTip') }}</p>
|
<p>{{ $t('oidcGuide.scopeTip') }}</p>
|
||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
|
||||||
<el-form-item :label="$t('oidcGuide.issuer')" required>
|
<el-form-item :label="$t('oidcGuide.issuer')" required>
|
||||||
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
|
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
|
||||||
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
|
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('oidcGuide.clientId')" required>
|
<el-form-item :label="$t('oidcGuide.clientId')" required>
|
||||||
<el-input v-model.trim="config.oidc_client_id" />
|
<el-input v-model.trim="config.oidc_client_id" />
|
||||||
|
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
|
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
|
||||||
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
|
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
|
||||||
|
|||||||
@@ -241,16 +241,18 @@
|
|||||||
"appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.",
|
"appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.",
|
||||||
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
|
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
|
||||||
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
|
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
|
||||||
"scopeTip": "Explicitly restrict who can use the app. Existing LDAP dial-in users are not automatically linked to Feishu identities. If your tenant lacks a standard OIDC discovery endpoint, use a compatible Keycloak / Authentik broker.",
|
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
|
||||||
|
"providerTip": "A Feishu app alone is not an OIDC issuer. Verify that your provider publishes standard discovery and ID tokens; otherwise use an identity broker such as Keycloak or Authentik. OpsX VPN does not currently issue OIDC identity tokens.",
|
||||||
"issuer": "OIDC issuer URL",
|
"issuer": "OIDC issuer URL",
|
||||||
"issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.",
|
"issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.",
|
||||||
"clientId": "OIDC Client ID",
|
"clientId": "OIDC Client ID",
|
||||||
|
"clientIdTip": "Get the credentials from the provider named by the issuer. With a broker, create a VPN client in the broker; the Feishu App ID/Secret belong in its upstream Feishu configuration.",
|
||||||
"clientSecret": "OIDC Client Secret",
|
"clientSecret": "OIDC Client Secret",
|
||||||
"copySecret": "Copy from the app; hidden after saving",
|
"copySecret": "Copy from the OIDC provider; hidden after saving",
|
||||||
"keepSecret": "Leave blank to retain current secret",
|
"keepSecret": "Leave blank to retain current secret",
|
||||||
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
|
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
|
||||||
"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 public HTTPS root URL, not the current HTTP admin address. The auth component handles browser callbacks; its entry site may share the admin server.",
|
||||||
"listenPort": "Local auth service port",
|
"listenPort": "Local auth service 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.",
|
"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.",
|
"portLookupFailed": "Could not find an available port. Retry or enter one manually.",
|
||||||
|
|||||||
@@ -241,16 +241,18 @@
|
|||||||
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
|
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
|
||||||
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
||||||
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
||||||
"scopeTip": "在飞书侧明确设置应用可用人员与授权范围。现有 LDAP 拨号用户不会自动关联飞书身份;如飞书未提供兼容的标准 OIDC 发现地址,请先部署 Keycloak / Authentik 身份代理。",
|
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
|
||||||
|
"providerTip": "这张飞书应用页面只证明已创建应用,不能作为 OIDC 发行者。先确认飞书是否提供标准发现文档和 ID Token;否则需要 Keycloak / Authentik 等身份代理。当前 opsx-vpn 不签发 OIDC 身份令牌。",
|
||||||
"issuer": "OIDC 发行者地址",
|
"issuer": "OIDC 发行者地址",
|
||||||
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
||||||
"clientId": "OIDC Client ID",
|
"clientId": "OIDC Client ID",
|
||||||
|
"clientIdTip": "从上方 issuer 对应的 OIDC 提供方获取:使用身份代理时填代理中创建的 VPN 客户端凭据,飞书 App ID/Secret 只填在代理的飞书上游配置中。",
|
||||||
"clientSecret": "OIDC Client Secret",
|
"clientSecret": "OIDC Client Secret",
|
||||||
"copySecret": "从应用凭据复制,保存后不回显",
|
"copySecret": "从 OIDC 提供方复制,保存后不回显",
|
||||||
"keepSecret": "留空保持原密钥",
|
"keepSecret": "留空保持原密钥",
|
||||||
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
|
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
|
||||||
"baseUrl": "认证入口公网地址",
|
"baseUrl": "认证入口公网地址",
|
||||||
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
|
"baseTip": "填写可公网访问的 HTTPS 域名根地址;不能用当前 HTTP 管理地址。浏览器回调由认证组件处理,入口站点可与管理后台同机部署。",
|
||||||
"listenPort": "本机认证服务端口",
|
"listenPort": "本机认证服务端口",
|
||||||
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
|
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
|
||||||
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
|
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
|
||||||
|
|||||||
@@ -75,24 +75,24 @@
|
|||||||
<el-table-column :label="$t('tunnels.node')" width="110">
|
<el-table-column :label="$t('tunnels.node')" width="110">
|
||||||
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
|
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('common.operation')" width="285" fixed="right">
|
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain @click="start(row)">{{ $t('tunnels.start') }}</el-button>
|
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||||||
<el-button v-else type="warning" size="small" plain @click="stop(row)">{{ $t('tunnels.stop') }}</el-button>
|
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||||||
<el-button size="small" link @click="showDetail(row)">{{ $t('tunnels.detail') }}</el-button>
|
|
||||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||||||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<el-button type="danger" size="small" link @click="remove(row)">{{ $t('common.delete') }}</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
@@ -251,6 +251,8 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const changingIds = ref(new Set())
|
||||||
|
let loadRevision = 0
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const protocolFilter = ref('')
|
const protocolFilter = ref('')
|
||||||
const stateFilter = ref('')
|
const stateFilter = ref('')
|
||||||
@@ -330,6 +332,7 @@ function peerOnline(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const revision = ++loadRevision
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||||
@@ -337,13 +340,15 @@ async function load() {
|
|||||||
if (stateFilter.value) {
|
if (stateFilter.value) {
|
||||||
result = result.filter(r => stateOf(r) === stateFilter.value)
|
result = result.filter(r => stateOf(r) === stateFilter.value)
|
||||||
}
|
}
|
||||||
list.value = result
|
if (revision === loadRevision) list.value = result
|
||||||
} finally { loading.value = false }
|
} finally { if (revision === loadRevision) loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更多操作下拉命令分发
|
// 更多操作下拉命令分发
|
||||||
function onMoreCommand(cmd, row) {
|
function onMoreCommand(cmd, row) {
|
||||||
if (cmd === 'download') download(row)
|
if (cmd === 'detail') showDetail(row)
|
||||||
|
else if (cmd === 'delete') remove(row)
|
||||||
|
else if (cmd === 'download') download(row)
|
||||||
else if (cmd === 'qrcode') showQr(row)
|
else if (cmd === 'qrcode') showQr(row)
|
||||||
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
|
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
|
||||||
else if (cmd === 'logs') openLogStream(row)
|
else if (cmd === 'logs') openLogStream(row)
|
||||||
@@ -446,23 +451,27 @@ async function savePolicy(tunnelId) {
|
|||||||
} finally { policySaving.value = false }
|
} finally { policySaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(row) {
|
async function changeState(row, action) {
|
||||||
|
if (changingIds.value.has(row.id)) return
|
||||||
|
changingIds.value = new Set([...changingIds.value, row.id])
|
||||||
|
++loadRevision // discard a poll started before this operation
|
||||||
try {
|
try {
|
||||||
await api.post(`/tunnels/${row.id}/start`)
|
const { data } = await api.post(`/tunnels/${row.id}/${action}`)
|
||||||
ElMessage.success(t('tunnels.started'))
|
row.state = data.state
|
||||||
|
row.runtime = null // discard the monitor's older snapshot immediately
|
||||||
|
if (stateFilter.value && stateFilter.value !== data.state) {
|
||||||
|
list.value = list.value.filter(item => item.id !== row.id)
|
||||||
|
}
|
||||||
|
ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e.response?.data?.error || t('tunnels.startFailed'))
|
ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed'))
|
||||||
|
} finally {
|
||||||
|
try { await load() } finally {
|
||||||
|
const next = new Set(changingIds.value)
|
||||||
|
next.delete(row.id)
|
||||||
|
changingIds.value = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load()
|
|
||||||
}
|
|
||||||
async function stop(row) {
|
|
||||||
try {
|
|
||||||
await api.post(`/tunnels/${row.id}/stop`)
|
|
||||||
ElMessage.success(t('tunnels.stopped'))
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.response?.data?.error || t('tunnels.stopFailed'))
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
}
|
||||||
async function remove(row) {
|
async function remove(row) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user