Support IPv4 HTTP public gateway entries

This commit is contained in:
Codex committed 2026-10-26 14:53:52 +08:00
1 parent 6f01d31f97
commit 6d5973bea3
11 files changed
+146 -32

No files matched your search

+1 -1
View File
@@ -169,7 +169,7 @@ func (s *Server) setTLSDomain(c *gin.Context) {
status := tlsservice.Status()
newBase := tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port)
if newBase == "" || port < 0 || port > 65535 {
c.JSON(http.StatusBadRequest, gin.H{"error": "公网域名、协议或端口无效"})
c.JSON(http.StatusBadRequest, gin.H{"error": "公网地址、协议或端口无效;IPv4 地址仅支持 HTTP"})
return
}
// The Go listener cannot be rebound in place; Nginx owns its own sockets.
+2 -2
View File
@@ -68,8 +68,8 @@ func RenderWithOptions(opts RenderOptions, routes gin.RoutesInfo, authPort int)
if scheme == "" {
scheme = "https"
}
if !tlsservice.ValidDomain(domain) {
return "", "", fmt.Errorf("请输入有效的单个完整域名")
if !tlsservice.ValidPublicHost(scheme, domain) {
return "", "", fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址")
}
if !tlsservice.ValidScheme(scheme) {
return "", "", fmt.Errorf("协议仅支持 http 或 https")
@@ -111,6 +111,18 @@ func TestRenderHTTPCustomPort(t *testing.T) {
}
}
func TestRenderHTTPIPv4AndRejectHTTPSIPv4(t *testing.T) {
opts := RenderOptions{Domain: "121.4.111.208", Scheme: "http", Port: 28080}
site, httpsSite, err := RenderWithOptions(opts, nil, 0)
if err != nil || httpsSite != "" || !strings.Contains(site, "listen 28080;") || !strings.Contains(site, "server_name 121.4.111.208;") {
t.Fatalf("HTTP IPv4 rendering failed: %v, %q, %q", err, site, httpsSite)
}
opts.Scheme = "https"
if _, _, err := RenderWithOptions(opts, nil, 0); err == nil {
t.Fatal("HTTPS IPv4 must be rejected")
}
}
func TestRenderHTTPSCustomPort(t *testing.T) {
routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}
httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0)
+3 -3
View File
@@ -58,7 +58,7 @@ func parseManagedEntry(body string) (nginxconfig.RenderOptions, error) {
}
domains := managedDomain.FindAllStringSubmatch(body, -1)
ports := portPattern.FindStringSubmatch(body)
if len(domains) == 0 || len(ports) != 2 || !tlsservice.ValidDomain(domains[0][1]) {
if len(domains) == 0 || len(ports) != 2 || !tlsservice.ValidPublicHost(scheme, domains[0][1]) {
return nginxconfig.RenderOptions{}, errors.New("无法识别托管站点的公网地址")
}
for _, match := range domains[1:] {
@@ -363,8 +363,8 @@ func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut
scheme = "https"
}
opts.Domain, opts.Scheme = domain, scheme
if !tlsservice.ValidDomain(domain) {
return errors.New("请先保存有效域名")
if !tlsservice.ValidPublicHost(scheme, domain) {
return errors.New("请先保存有效域名;HTTP 也支持 IPv4 地址")
}
if !tlsservice.ValidScheme(scheme) {
return errors.New("协议仅支持 http 或 https")
@@ -12,6 +12,7 @@ func TestParseManagedEntry(t *testing.T) {
{Domain: "vpn.example.com", Scheme: "https", Port: 443},
{Domain: "vpn.example.com", Scheme: "https", Port: 8443},
{Domain: "vpn.example.com", Scheme: "http", Port: 28080},
{Domain: "121.4.111.208", Scheme: "http", Port: 28080},
} {
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(want, nil, 0)
if err != nil {
+24 -8
View File
@@ -21,6 +21,7 @@ import (
)
var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
var numericHostPattern = regexp.MustCompile(`^[0-9.]+$`)
type configuration struct {
domain string
@@ -72,7 +73,22 @@ func DataDir() string {
}
func ValidDomain(domain string) bool {
return len(domain) <= 253 && domainPattern.MatchString(domain) && net.ParseIP(domain) == nil
return len(domain) <= 253 && domainPattern.MatchString(domain) && !numericHostPattern.MatchString(domain)
}
// ValidPublicHost allows a literal IPv4 address only for an HTTP gateway.
// Certificate issuance and HTTPS remain restricted to DNS names.
func ValidPublicHost(scheme, host string) bool {
if !ValidScheme(scheme) {
return false
}
if scheme == "https" {
return ValidDomain(host)
}
if ip := net.ParseIP(host); ip != nil {
return ip.To4() != nil && !ip.IsUnspecified() && !ip.IsMulticast()
}
return ValidDomain(host)
}
func ValidScheme(scheme string) bool {
@@ -94,7 +110,7 @@ func EffectivePort(scheme string, port int) int {
func PublicBaseURL(scheme, host string, port int) string {
scheme = strings.ToLower(strings.TrimSpace(scheme))
host = strings.ToLower(strings.TrimSpace(host))
if !ValidScheme(scheme) || host == "" || !ValidDomain(host) {
if !ValidPublicHost(scheme, host) {
return ""
}
eff := EffectivePort(scheme, port)
@@ -171,9 +187,6 @@ func configFromEnvironment(dataDir string) (configuration, error) {
}
domain = strings.ToLower(raw)
}
if domain != "" && !ValidDomain(domain) {
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
}
schemeEnv := strings.TrimSpace(os.Getenv("VPN_TLS_SCHEME"))
scheme := normalizeScheme(schemeEnv)
if schemeEnv == "" {
@@ -186,6 +199,9 @@ func configFromEnvironment(dataDir string) (configuration, error) {
if !ValidScheme(scheme) {
return configuration{}, fmt.Errorf("VPN_TLS_SCHEME 仅支持 http 或 https")
}
if domain != "" && !ValidPublicHost(scheme, domain) {
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 必须是有效域名,HTTP 模式也可使用 IPv4 地址")
}
port := 0
if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" {
var err error
@@ -239,8 +255,8 @@ func ConfigurePublicEntry(dataDir, scheme, domain string, port int) error {
if !ValidScheme(scheme) {
return fmt.Errorf("协议仅支持 http 或 https")
}
if !ValidDomain(domain) {
return fmt.Errorf("请输入单个有效的完整域名")
if !ValidPublicHost(scheme, domain) {
return fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址")
}
if port < 0 || port > 65535 {
return fmt.Errorf("端口必须是 0-65535(0 表示协议默认端口)")
@@ -304,7 +320,7 @@ func AdoptNginxConfiguration(dataDir string) error {
// UseManagedNginxEntry keeps the in-process identity issuer aligned with the
// live Nginx site if an older version left an unapplied draft on disk.
func UseManagedNginxEntry(dataDir, scheme, domain string, port int) error {
if !ValidScheme(scheme) || !ValidDomain(domain) || port < 1 || port > 65535 {
if !ValidPublicHost(scheme, domain) || port < 1 || port > 65535 {
return fmt.Errorf("运行中的 Nginx 公网地址无效")
}
state.Lock()
@@ -43,6 +43,35 @@ func TestDomainValidationAndRestart(t *testing.T) {
}
}
func TestHTTPIPv4PublicEntry(t *testing.T) {
for _, host := range []string{"121.4.111.208", "127.0.0.1", "vpn.example.com"} {
if !ValidPublicHost("http", host) {
t.Fatalf("valid HTTP host rejected: %s", host)
}
}
for _, host := range []string{"999.4.111.208", "121.4.111.208:28080", "0.0.0.0", "224.0.0.1", "::1", "123.456", "121.4.111.208/evil"} {
if ValidPublicHost("http", host) {
t.Fatalf("invalid HTTP host accepted: %s", host)
}
}
if ValidPublicHost("https", "121.4.111.208") {
t.Fatal("HTTPS IPv4 must require a domain")
}
if got := PublicBaseURL("http", "121.4.111.208", 28080); got != "http://121.4.111.208:28080" {
t.Fatalf("unexpected public URL: %s", got)
}
t.Setenv("VPN_TLS_DOMAIN", "")
t.Setenv("VPN_TLS_SCHEME", "")
t.Setenv("VPN_TLS_PORT", "")
dir := t.TempDir()
if err := ConfigurePublicEntry(dir, "http", "121.4.111.208", 28080); err != nil {
t.Fatal(err)
}
if err := ConfigurePublicEntry(dir, "https", "121.4.111.208", 443); err == nil {
t.Fatal("HTTPS IPv4 was persisted")
}
}
func TestFailedHTTPSBindReleasesHTTPPort(t *testing.T) {
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
occupied, err := net.Listen("tcp", "127.0.0.1:0")
@@ -0,0 +1,56 @@
# SDD-006:HTTP 公网入口支持 IPv4 与派生地址
| 字段 | 内容 |
|---|---|
| 版本 / 变更级别 | 0.1 / S3(公网认证入口地址) |
| 设计状态 | 已形成 |
| 实现状态 | 已实现并完成本地验证 |
| 负责人 / 日期 | opsx-vpn / 2026-10-26 |
| 关联需求 | 默认选 HTTP;输入 IPv4 121.4.111.208 和端口 28080;Base URL 自动生成并明显不可编辑 |
| 批准状态 | 用户要求修改 |
## 1. 背景、目标与范围
- 当前行为:`GatewaySettings.vue` 的表单状态初始为 HTTPS:443,Base URL 是 `computed(buildPublicBase(entry))` 的 readonly 输入;`tlsservice.ValidDomain`、`nginxconfig.RenderWithOptions` 和托管站点解析均拒绝 IP。
- 目标:没有已保存域名/地址的新表单预选 HTTP:80;管理员可在 HTTP 下填 IPv4 + 自定义端口;Base URL 派生为 `http://IP:port`,呈灰色只读、可复制。已保存配置(特别是当前生产 HTTPS)加载时优先保持原值。
- 非目标:给 IP 自动签发 HTTP-01 域名证书,或将当前生产入口自动切换为 HTTP。
- 保留:管理服务仍监听 18080;Nginx 白名单及 OIDC 运行中回调保护继续生效;HTTPS 地址仍必须是有效完整域名。
## 2. 需求与约束
- FR-01:无已配置入口时预选 HTTP:80;加载既有入口时显示其真实协议/端口,不能静默改写。
- FR-02:HTTP 支持单个 IPv4 或完整域名;保存、运行状态、Nginx 预览、托管站点识别以及 OIDC URL 均使用同一地址校验与拼接规则。
- FR-03:Base URL 随协议、地址和端口实时更新,仅展示和复制,不单独保存。
- NFR-01:拒绝无效 IP、IPv6、伪 IP 与包含 URI/端口的地址;HTTPS 维持现有域名与证书要求,HTTP 警示明文风险。
## 3. 架构与方案
在 `tlsservice` 保留严格 `ValidDomain` 用于证书/ACME,新增按协议验证公网 Host 的函数:HTTP 可用 IPv4 或域名、HTTPS 仅域名;配置读取、持久化、URL 构造、Nginx 渲染及站点读取共用该函数。UI 初始显示 HTTP,但加载到已保存地址时服从实际配置。Base URL 用 disabled/read-only 风格展示并保留复制按钮。
## 4. 接口、数据与配置契约
`PUT /api/system/tls/domain` 的 `domain` 字段在 HTTP 下也可存 IPv4;响应 `public_base` 为实际地址。原 `data/tls-domain` 仍存 Host,不新增表或迁移;旧 HTTPS 配置保持兼容。Go autocert 模式仍仅支持 HTTPS 域名。
## 5. 风险与验收
| 风险或失败场景 | 防护 / 恢复 | 可观察信号 |
|---|---|---|
| HTTP 登录明文 | 页面提示;HTTPS 现有配置不自动切换 | 表单及生产 HTTPS 探测 |
| IP 被误送至 Certbot | HTTPS/Go 模式拒绝 IP,HTTP Nginx 不申请证书 | 400/预览错误及单测 |
| 活跃 OIDC 回调引用旧 URL | 复用保存时冲突检测 | 409,旧站点保留 |
| AC | 对应需求 | 前置/操作 | 预期结果 | 验证方式 | 结果 |
|---|---|---|---|---|---|
| AC-01 | FR-01 | 无配置加载、已有 HTTPS 加载 | 分别显示 HTTP:80 与原 HTTPS 配置 | 前端构建/逻辑检查 | 已通过代码检查与构建;未做交互测试 |
| AC-02 | FR-02, NFR-01 | 用 121.4.111.208:28080 渲染/保存 HTTP,尝试伪 IP 和 HTTPS IP | HTTP 成功;错误输入被拒绝 | Go 单测 | 已通过 Go 单测;未在生产保存入口 |
| AC-03 | FR-03 | 修改协议、IP、端口 | 灰色只读 Base URL 自动变化且无单独提交字段 | 前端构建/代码检查 | 已通过代码检查与构建;未做交互测试 |
## 6. 发布与恢复
本地后端测试及前端构建;部署时保留当前 HTTPS:443 和 OIDC 专线,备份程序、数据库及 Nginx 托管站点,再替换管理二进制。验收 `/api/health`、OIDC issuer、登录页、监听端口及 VPN 进程;不在生产执行 HTTP 切换。异常恢复旧二进制和站点,不修改用户的实际公网设置。
## 7. 待决问题
| 问题 | 是否阻塞 | 推荐方案 |
|---|---|---|
| HTTPS IP 证书支持 | 否 | 另行设计证书类型与 IP SAN 验证;本次仅支持 HTTP IPv4 |
+3 -3
View File
@@ -961,13 +961,13 @@
"publicEntryTitle": "Public entry",
"publicEntryDesc": "Scheme, host, and port define the public Base URL used by the OIDC issuer, Feishu identity, and tunnel callbacks.",
"scheme": "Scheme",
"host": "Host",
"host": "Domain / IPv4 address",
"port": "Port",
"publicBase": "Public Base URL",
"publicBaseHint": "OIDC issuer is Base URL + /id; tunnel callbacks inherit the same base.",
"publicBaseHint": "Generated from scheme, address and port; no input needed. Feishu / OIDC callbacks use this address.",
"httpRisk": "HTTP is plaintext. Prefer HTTPS in production; HTTP is for intranet or temporary ICP workarounds.",
"saveEntry": "Save public entry",
"hostRequired": "Host is required",
"hostRequired": "Enter a domain or IPv4 address",
"certListTitle": "Certificates",
"addCert": "Add certificate",
"replaceCert": "Replace",
+3 -3
View File
@@ -961,13 +961,13 @@
"publicEntryTitle": "公网入口",
"publicEntryDesc": "协议、域名与端口决定 OIDC 发行者、飞书身份与专线对外地址;保存后各页只读继承,不再写死 https。",
"scheme": "协议",
"host": "域名",
"host": "域名 / IPv4 地址",
"port": "端口",
"publicBase": "对外 Base URL",
"publicBaseHint": "飞书 / OIDC 发行者为 Base URL + /id;专线回调亦由此派生。",
"publicBaseHint": "自动由协议、地址和端口生成,无需填写;飞书 / OIDC 回调引用此地址。",
"httpRisk": "HTTP 为明文传输,仅建议内网或未备案临时绕行场景;生产环境优先 HTTPS。",
"saveEntry": "保存公网入口",
"hostRequired": "请填写域名",
"hostRequired": "请填写域名或 IPv4 地址",
"certListTitle": "证书",
"addCert": "添加证书",
"replaceCert": "更换",
@@ -13,8 +13,8 @@
<el-form label-position="top" @submit.prevent="savePublicEntry">
<el-form-item :label="$t('tls.scheme')">
<el-radio-group v-model="entry.scheme" class="scheme-group" @change="onSchemeChange">
<el-radio-button value="https">HTTPS</el-radio-button>
<el-radio-button value="http">HTTP</el-radio-button>
<el-radio-button value="https">HTTPS</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
@@ -27,7 +27,7 @@
<el-row :gutter="12">
<el-col :xs="24" :sm="16">
<el-form-item :label="$t('tls.host')" required>
<el-input v-model.trim="entry.host" placeholder="vpn.example.com" maxlength="253" />
<el-input v-model.trim="entry.host" placeholder="vpn.example.com / 121.4.111.208" maxlength="253" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
@@ -37,7 +37,7 @@
</el-col>
</el-row>
<el-form-item :label="$t('tls.publicBase')">
<el-input :model-value="publicBase" readonly>
<el-input :model-value="publicBase" readonly class="derived-base">
<template #append>
<el-button @click="copyBase">{{ $t('common.copy') }}</el-button>
</template>
@@ -231,10 +231,10 @@ const certDialog = ref(false)
const certSubmitting = ref(false)
const entry = reactive({
scheme: 'https',
scheme: 'http',
host: '',
port: 443,
mode: 'autocert',
port: 80,
mode: 'nginx',
})
const certForm = reactive({
@@ -248,10 +248,7 @@ const certForm = reactive({
})
const publicBase = computed(() => {
const local = buildPublicBase(entry)
const remote = tlsStatus.value?.configured_public_base || tlsStatus.value?.public_base || ''
// Prefer live form preview while editing; fall back to server after load.
return local || remote
return buildPublicBase(entry)
})
const certRows = computed(() => {
@@ -273,6 +270,7 @@ const certRows = computed(() => {
function onSchemeChange() {
entry.port = defaultPort(entry.scheme)
if (entry.scheme === 'http') entry.mode = 'nginx'
}
function resetCertForm() {
@@ -372,11 +370,11 @@ async function loadTLS() {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
entry.host = data.configured_domain || data.domain || data.host || ''
const scheme = data.configured_scheme || data.scheme || 'https'
const scheme = entry.host ? (data.configured_scheme || data.scheme || 'https') : 'http'
entry.scheme = scheme === 'http' ? 'http' : 'https'
const port = Number(data.configured_port || data.port)
entry.port = port > 0 ? port : defaultPort(entry.scheme)
entry.mode = data.configured_mode || data.mode || 'autocert'
entry.mode = entry.host ? (data.configured_mode || data.mode || 'autocert') : 'nginx'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
@@ -499,6 +497,8 @@ onMounted(() => { if (!auth.mustChange) refreshAll() })
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; margin-top: 6px; }
.derived-base :deep(.el-input__wrapper) { background: var(--el-fill-color-light); }
.derived-base :deep(.el-input__inner) { color: var(--el-text-color-secondary); cursor: default; }
.nginx-preview { max-height: 280px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; margin-top: 8px; }
.scheme-group { width: 100%; }
.scheme-group :deep(.el-radio-button) { flex: 1; }