Show OpenVPN OIDC prerequisites and exposed management port

This commit is contained in:
opsx committed 2026-10-22 08:03:08 +08:00
1 parent 64f1d89a2f
commit ea9c885a15
10 files changed
+90 -5

No files matched your search

+1
View File
@@ -285,6 +285,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
ops.POST("/binaries/install/:name", s.installBinary)
ops.GET("/binaries/install-hint/:name", s.binaryInstallHint)
ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy)
ops.GET("/tunnels/openvpn/oidc-readiness", s.openvpnOIDCReadiness)
// 批量操作 & 任务管理
ops.POST("/tunnels/batch/start", s.batchTunnels(taskmgr.ActionStart))
@@ -0,0 +1,46 @@
package api
import (
"context"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/nginxgateway"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/tlsservice"
)
var oidcVersionPattern = regexp.MustCompile(`(?m)^version: ([0-9][0-9A-Za-z.+-]{1,32})\s*$`)
// This checks local prerequisites only. It never tests credentials or claims
// that an OIDC issuer, callback domain or user authorization is configured.
func (s *Server) openvpnOIDCReadiness(c *gin.Context) {
path := filepath.Join(service.BaseDir(), "bin", "openvpn-auth-oauth2")
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
path, _ = service.LookBin("openvpn-auth-oauth2")
}
version := ""
if path != "" {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, path, "--version").Output(); err == nil {
if match := oidcVersionPattern.FindSubmatch(output); len(match) == 2 {
version = strings.TrimSpace(string(match[1]))
}
}
}
tls := tlsservice.Status()
c.JSON(http.StatusOK, gin.H{
"component_installed": path != "", "component_version": version,
"https_management_ready": tls.Mode == "nginx" && tls.Valid && nginxgateway.Stage() == "https",
"management_domain": tls.Domain,
"authentication_entry_ready": false, // Depends on a distinct callback site and a configured provider.
})
}
+13 -1
View File
@@ -51,6 +51,7 @@ type CertificateStatus struct {
NotBefore time.Time `json:"not_before,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Renewal string `json:"renewal_mode"`
DirectPortPublic bool `json:"management_direct_public"`
}
func ValidDomain(domain string) bool {
@@ -222,6 +223,15 @@ func Status() CertificateStatus {
config := state.config
state.RUnlock()
status := CertificateStatus{Enabled: config.domain != "" && config.mode == "autocert", Domain: config.domain, Mode: config.mode, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: config.mode}
if config.mode == "nginx" {
listenAddress := os.Getenv("VPN_LISTEN_ADDR")
if listenAddress == "" {
listenAddress = ":18080"
}
if host, _, err := net.SplitHostPort(listenAddress); err == nil {
status.DirectPortPublic = host == "" || host == "0.0.0.0" || host == "::"
}
}
if desired, err := configFromEnvironment(config.dataDir); err == nil {
status.ConfiguredDomain = desired.domain
status.ConfiguredMode = desired.mode
@@ -233,7 +243,9 @@ func Status() CertificateStatus {
status.Domain = ""
if config.domain != "" {
readCertificate(filepath.Join("/etc/letsencrypt/live", config.domain, "fullchain.pem"), config.domain, &status)
if status.Valid { status.Domain = config.domain }
if status.Valid {
status.Domain = config.domain
}
}
return status
}
@@ -80,7 +80,11 @@ func TestNginxModeDoesNotBindGoListeners(t *testing.T) {
t.Fatal(err)
}
status := Status()
if status.Mode != "nginx" || status.Enabled || status.Domain != "" || status.ConfiguredDomain != "test-link.appx365.com" {
if status.Mode != "nginx" || status.Enabled || status.Domain != "" || status.ConfiguredDomain != "test-link.appx365.com" || !status.DirectPortPublic {
t.Fatalf("proxy mode must not claim Go listens: %+v", status)
}
t.Setenv("VPN_LISTEN_ADDR", "127.0.0.1:18080")
if Status().DirectPortPublic {
t.Fatal("loopback management listener must not be marked public")
}
}
+7 -2
View File
@@ -4,7 +4,7 @@
|------|------|
| 版本 / 变更级别 | 0.1 / S3(身份认证、部署) |
| 设计状态 | Draft |
| 实现状态 | OpenVPN WebAuth 配置与认证组件进程管理已实现;真实飞书登录仍待 OIDC 提供方、HTTPS 入口和组件部署联调 |
| 实现状态 | OpenVPN WebAuth 配置与认证组件进程管理已实现;2026-10-22 已安装上游 openvpn-auth-oauth2 v2.3.0,真实飞书登录仍待 OIDC 提供方与独立认证入口联调 |
| 负责人 / 日期 | 待指定 / 2026-10-20 |
| 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 |
| 批准状态 | 未批准;用户已明确要求开始开发 |
@@ -32,7 +32,7 @@
- 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。
## 4. 接口、数据与配置契约
- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;新增 `GET /api/tunnels/openvpn/oidc-port`(可选 `from`、`direction`)推荐空闲本机端口;不新增虚假的 `/oauth2/callback` 后端接口。
- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;新增 `GET /api/tunnels/openvpn/oidc-port`(可选 `from`、`direction`)推荐空闲本机端口,以及只读 `GET /api/tunnels/openvpn/oidc-readiness` 展示组件版本与管理 HTTPS 前提(不代表认证入口或发行者已配置);不新增虚假的 `/oauth2/callback` 后端接口。
- 数据模型 / 迁移:沿用隧道配置 JSON 与 Secrets 密文,无强制数据库迁移。
- 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。
- 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。
@@ -77,3 +77,8 @@
- 对外回调为 `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。先用独立测试专线验证允许与拒绝、断线重连,再变更生产线路。发现失败时停止测试专线并保留原密码专线,不将缺失组件视为已完成登录联调。
- 飞书应用密钥曾出现在交互截图中,管理员应轮换后仅输入后台密钥框;仓库、文档、日志和测试均不得包含实际密钥。
### 2026-10-22 进展
- 上游 v2.3.0 Linux amd64 发布包校验 SHA-256 后安装在服务器 `data/bin/openvpn-auth-oauth2`;以管理进程相同的 root 权限执行 `--version` 显示 2.3.0,未启动任何 OIDC 专线。现有生成配置的 `http.baseurl`、`oauth2.issuer/client` 与 `openvpn.addr/password` 字段和固定版本官方配置样例一致。
- 管理域名 `test-link.appx365.com` 的 HTTPS 证书已签发、续签演练通过;它目前只转发管理页面/API,不作为认证组件的公网入口。管理页面区分“组件已安装”与“认证入口/发行者未就绪”,不得把前者当成已完成飞书登录。
+1
View File
@@ -37,3 +37,4 @@
`return 444` 是 Nginx 的断连响应,不是权限控制;合法接口仍必须由 Gin 鉴权。Go `autocert` 模式不会提供 `return 444`。不能用路径白名单阻断 ACME 续期、MFA 或 WebAuth。认证域名与管理域名若合并,需要重新设计路径分发及 Cookie 隔离,不能直接套用独立域名配置。认证域名接入仍待独立域名和真实发行者信息;Certbot 续签 dry-run 与公网联调以实际执行记录为准。
当前 `:18080` 仍监听公网,旧客户端以 HTTP 直连会绕过 Nginx 的 444 白名单;迁移客户端服务地址至 HTTPS 后应单独收紧 18080。2026-10-22 本机对域名的第二轮公网探测出现连接重置/DNS 波动,服务器回环直连 HTTPS `/settings` 和管理健康检查正常;持续公网可用性需结合实际用户网络观察。
设置页面显示这一直接端口暴露状态,防止管理员仅依据已签出的证书误认为外部访问都经过 Nginx。
+10 -1
View File
@@ -1,6 +1,7 @@
<template>
<section class="oidc-guide">
<div class="guide-title"><b>{{ $t('oidcGuide.title') }}</b><el-tag type="warning" effect="plain">{{ $t('oidcGuide.externalService') }}</el-tag></div>
<div class="guide-title"><b>{{ $t('oidcGuide.title') }}</b><el-tag :type="readiness?.component_installed ? 'success' : 'warning'" effect="plain">{{ readiness?.component_installed ? $t('oidcGuide.componentReady', { version: readiness.component_version || '—' }) : $t('oidcGuide.externalService') }}</el-tag></div>
<el-alert v-if="readiness?.https_management_ready && !readiness?.authentication_entry_ready" type="warning" :closable="false" :title="$t('oidcGuide.authEntryPending', { domain: readiness.management_domain })" class="guide-alert" />
<el-steps :active="guideStep" finish-status="success" align-center class="guide-steps">
<el-step :title="$t('oidcGuide.app')" :description="$t('oidcGuide.appShort')" />
<el-step :title="$t('oidcGuide.identity')" :description="$t('oidcGuide.identityShort')" />
@@ -58,6 +59,13 @@ import api from '../api'
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
const { t } = useI18n()
const portLoading = ref(false)
const readiness = ref(null)
async function loadReadiness() {
try {
const { data } = await api.get('/tunnels/openvpn/oidc-readiness')
readiness.value = data
} catch { /* Old servers do not provide a readiness endpoint. */ }
}
async function requestPort(from, direction, previous = null) {
portLoading.value = true
try {
@@ -80,6 +88,7 @@ function changePort(value, previous) {
requestPort(value, value < previous ? -1 : 1, previous)
}
onMounted(() => {
loadReadiness()
if (!props.editing) requestPort(null, 1)
})
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
+3
View File
@@ -228,6 +228,8 @@
"oidcGuide": {
"title": "Feishu / OIDC browser authentication",
"externalService": "Requires openvpn-auth-oauth2",
"componentReady": "Authentication component installed v{version}",
"authEntryPending": "Management TLS is ready at {domain}; OpenVPN authentication still requires a separate HTTPS hostname and an OIDC issuer.",
"app": "Create app",
"appShort": "Feishu developer console",
"identity": "Enter identity settings",
@@ -762,6 +764,7 @@
"nginxHint": "Save the Nginx mode and domain, restart the service, then enable the site and request a certificate. Public ports 80/443 must be open.",
"externalRenewal": "Managed by Certbot", "previewNginx": "Preview Nginx configuration", "previewOnly": "Configuration preview; HTTP handles issuance and HTTPS starts after the certificate exists.", "httpSite": "HTTP-01 site", "httpsSite": "HTTPS management site",
"siteStage": "Site stage", "stage_none": "Inactive", "stage_http": "HTTP-01 ready; certificate pending", "stage_https": "HTTPS configuration enabled", "activateSite": "Enable site and issue certificate", "activateHint": "Checks existing sites and ports first. Certbot registration uses no email address, so renewal failures cannot be reported by email.", "siteReady": "HTTPS site and certificate are ready", "siteFailed": "Gateway or certificate setup failed; see output",
"directPortWarning": "The management service still listens publicly on port 18080, bypassing Nginx path rules. Migrate clients using the old HTTP address, then bind the service to 127.0.0.1:18080.",
"setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server",
"commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled"
},
+3
View File
@@ -228,6 +228,8 @@
"oidcGuide": {
"title": "飞书 / OIDC 浏览器认证配置",
"externalService": "需要 openvpn-auth-oauth2",
"componentReady": "认证组件已安装 v{version}",
"authEntryPending": "管理域名 {domain} 的证书已就绪;OpenVPN 认证入口仍需独立 HTTPS 域名与 OIDC 发行者。",
"app": "创建应用",
"appShort": "飞书开放平台",
"identity": "填写身份信息",
@@ -762,6 +764,7 @@
"nginxHint": "选择 Nginx 模式并保存域名后重启服务,再点击启用站点申请证书。端口 80/443 需允许公网访问。",
"externalRenewal": "由 Certbot 管理", "previewNginx": "预览 Nginx 配置", "previewOnly": "以下为配置预览;HTTP 站点用于签发,HTTPS 站点在证书存在后启用。", "httpSite": "HTTP-01 站点", "httpsSite": "HTTPS 管理站点",
"siteStage": "站点阶段", "stage_none": "未启用", "stage_http": "HTTP-01 已准备,证书待签发", "stage_https": "HTTPS 配置已启用", "activateSite": "启用站点并申请证书", "activateHint": "启用前会检查现有站点及端口;冲突时停止并提示。证书采用 Certbot 无邮箱注册,续签失败时不会收到邮件通知。", "siteReady": "HTTPS 站点与证书已就绪", "siteFailed": "站点或证书配置失败,请查看输出",
"directPortWarning": "管理服务仍直接监听公网 18080,访问该端口会绕过 Nginx 的路径限制。请先迁移使用旧 HTTP 地址的客户端,再将管理服务改为仅监听 127.0.0.1:18080。",
"setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续",
"commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启"
},
+1
View File
@@ -89,6 +89,7 @@
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
</el-form>
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>