Support IPv4 HTTP public gateway entries
This commit is contained in:
1 parent
6f01d31f97
commit
6d5973bea3
11 files changed
+146
-32
No files matched your search
@@ -169,7 +169,7 @@ func (s *Server) setTLSDomain(c *gin.Context) {
|
|||||||
status := tlsservice.Status()
|
status := tlsservice.Status()
|
||||||
newBase := tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port)
|
newBase := tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port)
|
||||||
if newBase == "" || port < 0 || port > 65535 {
|
if newBase == "" || port < 0 || port > 65535 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "公网域名、协议或端口无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "公网地址、协议或端口无效;IPv4 地址仅支持 HTTP"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The Go listener cannot be rebound in place; Nginx owns its own sockets.
|
// The Go listener cannot be rebound in place; Nginx owns its own sockets.
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ func RenderWithOptions(opts RenderOptions, routes gin.RoutesInfo, authPort int)
|
|||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
if !tlsservice.ValidDomain(domain) {
|
if !tlsservice.ValidPublicHost(scheme, domain) {
|
||||||
return "", "", fmt.Errorf("请输入有效的单个完整域名")
|
return "", "", fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址")
|
||||||
}
|
}
|
||||||
if !tlsservice.ValidScheme(scheme) {
|
if !tlsservice.ValidScheme(scheme) {
|
||||||
return "", "", fmt.Errorf("协议仅支持 http 或 https")
|
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) {
|
func TestRenderHTTPSCustomPort(t *testing.T) {
|
||||||
routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}
|
routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}
|
||||||
httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0)
|
httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func parseManagedEntry(body string) (nginxconfig.RenderOptions, error) {
|
|||||||
}
|
}
|
||||||
domains := managedDomain.FindAllStringSubmatch(body, -1)
|
domains := managedDomain.FindAllStringSubmatch(body, -1)
|
||||||
ports := portPattern.FindStringSubmatch(body)
|
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("无法识别托管站点的公网地址")
|
return nginxconfig.RenderOptions{}, errors.New("无法识别托管站点的公网地址")
|
||||||
}
|
}
|
||||||
for _, match := range domains[1:] {
|
for _, match := range domains[1:] {
|
||||||
@@ -363,8 +363,8 @@ func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
opts.Domain, opts.Scheme = domain, scheme
|
opts.Domain, opts.Scheme = domain, scheme
|
||||||
if !tlsservice.ValidDomain(domain) {
|
if !tlsservice.ValidPublicHost(scheme, domain) {
|
||||||
return errors.New("请先保存有效域名")
|
return errors.New("请先保存有效域名;HTTP 也支持 IPv4 地址")
|
||||||
}
|
}
|
||||||
if !tlsservice.ValidScheme(scheme) {
|
if !tlsservice.ValidScheme(scheme) {
|
||||||
return errors.New("协议仅支持 http 或 https")
|
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: 443},
|
||||||
{Domain: "vpn.example.com", Scheme: "https", Port: 8443},
|
{Domain: "vpn.example.com", Scheme: "https", Port: 8443},
|
||||||
{Domain: "vpn.example.com", Scheme: "http", Port: 28080},
|
{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)
|
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(want, nil, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 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 {
|
type configuration struct {
|
||||||
domain string
|
domain string
|
||||||
@@ -72,7 +73,22 @@ func DataDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidDomain(domain string) bool {
|
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 {
|
func ValidScheme(scheme string) bool {
|
||||||
@@ -94,7 +110,7 @@ func EffectivePort(scheme string, port int) int {
|
|||||||
func PublicBaseURL(scheme, host string, port int) string {
|
func PublicBaseURL(scheme, host string, port int) string {
|
||||||
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
||||||
host = strings.ToLower(strings.TrimSpace(host))
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
if !ValidScheme(scheme) || host == "" || !ValidDomain(host) {
|
if !ValidPublicHost(scheme, host) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
eff := EffectivePort(scheme, port)
|
eff := EffectivePort(scheme, port)
|
||||||
@@ -171,9 +187,6 @@ func configFromEnvironment(dataDir string) (configuration, error) {
|
|||||||
}
|
}
|
||||||
domain = strings.ToLower(raw)
|
domain = strings.ToLower(raw)
|
||||||
}
|
}
|
||||||
if domain != "" && !ValidDomain(domain) {
|
|
||||||
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
|
|
||||||
}
|
|
||||||
schemeEnv := strings.TrimSpace(os.Getenv("VPN_TLS_SCHEME"))
|
schemeEnv := strings.TrimSpace(os.Getenv("VPN_TLS_SCHEME"))
|
||||||
scheme := normalizeScheme(schemeEnv)
|
scheme := normalizeScheme(schemeEnv)
|
||||||
if schemeEnv == "" {
|
if schemeEnv == "" {
|
||||||
@@ -186,6 +199,9 @@ func configFromEnvironment(dataDir string) (configuration, error) {
|
|||||||
if !ValidScheme(scheme) {
|
if !ValidScheme(scheme) {
|
||||||
return configuration{}, fmt.Errorf("VPN_TLS_SCHEME 仅支持 http 或 https")
|
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
|
port := 0
|
||||||
if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" {
|
if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" {
|
||||||
var err error
|
var err error
|
||||||
@@ -239,8 +255,8 @@ func ConfigurePublicEntry(dataDir, scheme, domain string, port int) error {
|
|||||||
if !ValidScheme(scheme) {
|
if !ValidScheme(scheme) {
|
||||||
return fmt.Errorf("协议仅支持 http 或 https")
|
return fmt.Errorf("协议仅支持 http 或 https")
|
||||||
}
|
}
|
||||||
if !ValidDomain(domain) {
|
if !ValidPublicHost(scheme, domain) {
|
||||||
return fmt.Errorf("请输入单个有效的完整域名")
|
return fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址")
|
||||||
}
|
}
|
||||||
if port < 0 || port > 65535 {
|
if port < 0 || port > 65535 {
|
||||||
return fmt.Errorf("端口必须是 0-65535(0 表示协议默认端口)")
|
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
|
// UseManagedNginxEntry keeps the in-process identity issuer aligned with the
|
||||||
// live Nginx site if an older version left an unapplied draft on disk.
|
// live Nginx site if an older version left an unapplied draft on disk.
|
||||||
func UseManagedNginxEntry(dataDir, scheme, domain string, port int) error {
|
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 公网地址无效")
|
return fmt.Errorf("运行中的 Nginx 公网地址无效")
|
||||||
}
|
}
|
||||||
state.Lock()
|
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) {
|
func TestFailedHTTPSBindReleasesHTTPPort(t *testing.T) {
|
||||||
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
|
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
|
||||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
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 |
|
||||||
@@ -961,13 +961,13 @@
|
|||||||
"publicEntryTitle": "Public entry",
|
"publicEntryTitle": "Public entry",
|
||||||
"publicEntryDesc": "Scheme, host, and port define the public Base URL used by the OIDC issuer, Feishu identity, and tunnel callbacks.",
|
"publicEntryDesc": "Scheme, host, and port define the public Base URL used by the OIDC issuer, Feishu identity, and tunnel callbacks.",
|
||||||
"scheme": "Scheme",
|
"scheme": "Scheme",
|
||||||
"host": "Host",
|
"host": "Domain / IPv4 address",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"publicBase": "Public Base URL",
|
"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.",
|
"httpRisk": "HTTP is plaintext. Prefer HTTPS in production; HTTP is for intranet or temporary ICP workarounds.",
|
||||||
"saveEntry": "Save public entry",
|
"saveEntry": "Save public entry",
|
||||||
"hostRequired": "Host is required",
|
"hostRequired": "Enter a domain or IPv4 address",
|
||||||
"certListTitle": "Certificates",
|
"certListTitle": "Certificates",
|
||||||
"addCert": "Add certificate",
|
"addCert": "Add certificate",
|
||||||
"replaceCert": "Replace",
|
"replaceCert": "Replace",
|
||||||
|
|||||||
@@ -961,13 +961,13 @@
|
|||||||
"publicEntryTitle": "公网入口",
|
"publicEntryTitle": "公网入口",
|
||||||
"publicEntryDesc": "协议、域名与端口决定 OIDC 发行者、飞书身份与专线对外地址;保存后各页只读继承,不再写死 https。",
|
"publicEntryDesc": "协议、域名与端口决定 OIDC 发行者、飞书身份与专线对外地址;保存后各页只读继承,不再写死 https。",
|
||||||
"scheme": "协议",
|
"scheme": "协议",
|
||||||
"host": "域名",
|
"host": "域名 / IPv4 地址",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"publicBase": "对外 Base URL",
|
"publicBase": "对外 Base URL",
|
||||||
"publicBaseHint": "飞书 / OIDC 发行者为 Base URL + /id;专线回调亦由此派生。",
|
"publicBaseHint": "自动由协议、地址和端口生成,无需填写;飞书 / OIDC 回调引用此地址。",
|
||||||
"httpRisk": "HTTP 为明文传输,仅建议内网或未备案临时绕行场景;生产环境优先 HTTPS。",
|
"httpRisk": "HTTP 为明文传输,仅建议内网或未备案临时绕行场景;生产环境优先 HTTPS。",
|
||||||
"saveEntry": "保存公网入口",
|
"saveEntry": "保存公网入口",
|
||||||
"hostRequired": "请填写域名",
|
"hostRequired": "请填写域名或 IPv4 地址",
|
||||||
"certListTitle": "证书",
|
"certListTitle": "证书",
|
||||||
"addCert": "添加证书",
|
"addCert": "添加证书",
|
||||||
"replaceCert": "更换",
|
"replaceCert": "更换",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
<el-form label-position="top" @submit.prevent="savePublicEntry">
|
<el-form label-position="top" @submit.prevent="savePublicEntry">
|
||||||
<el-form-item :label="$t('tls.scheme')">
|
<el-form-item :label="$t('tls.scheme')">
|
||||||
<el-radio-group v-model="entry.scheme" class="scheme-group" @change="onSchemeChange">
|
<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="http">HTTP</el-radio-button>
|
||||||
|
<el-radio-button value="https">HTTPS</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :xs="24" :sm="16">
|
<el-col :xs="24" :sm="16">
|
||||||
<el-form-item :label="$t('tls.host')" required>
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8">
|
<el-col :xs="24" :sm="8">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item :label="$t('tls.publicBase')">
|
<el-form-item :label="$t('tls.publicBase')">
|
||||||
<el-input :model-value="publicBase" readonly>
|
<el-input :model-value="publicBase" readonly class="derived-base">
|
||||||
<template #append>
|
<template #append>
|
||||||
<el-button @click="copyBase">{{ $t('common.copy') }}</el-button>
|
<el-button @click="copyBase">{{ $t('common.copy') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -231,10 +231,10 @@ const certDialog = ref(false)
|
|||||||
const certSubmitting = ref(false)
|
const certSubmitting = ref(false)
|
||||||
|
|
||||||
const entry = reactive({
|
const entry = reactive({
|
||||||
scheme: 'https',
|
scheme: 'http',
|
||||||
host: '',
|
host: '',
|
||||||
port: 443,
|
port: 80,
|
||||||
mode: 'autocert',
|
mode: 'nginx',
|
||||||
})
|
})
|
||||||
|
|
||||||
const certForm = reactive({
|
const certForm = reactive({
|
||||||
@@ -248,10 +248,7 @@ const certForm = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const publicBase = computed(() => {
|
const publicBase = computed(() => {
|
||||||
const local = buildPublicBase(entry)
|
return 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const certRows = computed(() => {
|
const certRows = computed(() => {
|
||||||
@@ -273,6 +270,7 @@ const certRows = computed(() => {
|
|||||||
|
|
||||||
function onSchemeChange() {
|
function onSchemeChange() {
|
||||||
entry.port = defaultPort(entry.scheme)
|
entry.port = defaultPort(entry.scheme)
|
||||||
|
if (entry.scheme === 'http') entry.mode = 'nginx'
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCertForm() {
|
function resetCertForm() {
|
||||||
@@ -372,11 +370,11 @@ async function loadTLS() {
|
|||||||
const { data } = await api.get('/system/tls')
|
const { data } = await api.get('/system/tls')
|
||||||
tlsStatus.value = data
|
tlsStatus.value = data
|
||||||
entry.host = data.configured_domain || data.domain || data.host || ''
|
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'
|
entry.scheme = scheme === 'http' ? 'http' : 'https'
|
||||||
const port = Number(data.configured_port || data.port)
|
const port = Number(data.configured_port || data.port)
|
||||||
entry.port = port > 0 ? port : defaultPort(entry.scheme)
|
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')) }
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||||
finally { tlsLoading.value = false }
|
finally { tlsLoading.value = false }
|
||||||
}
|
}
|
||||||
@@ -499,6 +497,8 @@ onMounted(() => { if (!auth.mustChange) refreshAll() })
|
|||||||
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
|
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
|
||||||
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
.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; }
|
.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; }
|
.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 { width: 100%; }
|
||||||
.scheme-group :deep(.el-radio-button) { flex: 1; }
|
.scheme-group :deep(.el-radio-button) { flex: 1; }
|
||||||
|
|||||||
Reference in new issue
Block a user