From d3699abb180e3275dc1d9f70342dce65f9778270 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:32:27 +0800 Subject: [PATCH] Add guarded IP certificate flow and renewal history --- .../internal/nginxconfig/nginxconfig_test.go | 7 +- backend/internal/nginxgateway/gateway.go | 82 +++++++++++++++++-- backend/internal/nginxgateway/gateway_test.go | 17 ++++ backend/internal/nginxgateway/renewal-hook.sh | 19 +++++ backend/internal/nginxgateway/renewal_hook.go | 8 ++ backend/internal/tlsservice/renewals.go | 63 ++++++++++++++ backend/internal/tlsservice/renewals_test.go | 22 +++++ backend/internal/tlsservice/tlsservice.go | 61 +++++++------- .../internal/tlsservice/tlsservice_test.go | 11 ++- docs/deploy.md | 4 +- docs/specs/SDD-008-ip-certificate-renewal.md | 60 ++++++++++++++ frontend/src/locales/en-US.json | 3 + frontend/src/locales/zh-CN.json | 3 + .../settings/components/GatewaySettings.vue | 39 ++++++--- 14 files changed, 343 insertions(+), 56 deletions(-) create mode 100644 backend/internal/nginxgateway/renewal-hook.sh create mode 100644 backend/internal/nginxgateway/renewal_hook.go create mode 100644 backend/internal/tlsservice/renewals.go create mode 100644 backend/internal/tlsservice/renewals_test.go create mode 100644 docs/specs/SDD-008-ip-certificate-renewal.md diff --git a/backend/internal/nginxconfig/nginxconfig_test.go b/backend/internal/nginxconfig/nginxconfig_test.go index 6277749..fe48206 100644 --- a/backend/internal/nginxconfig/nginxconfig_test.go +++ b/backend/internal/nginxconfig/nginxconfig_test.go @@ -111,15 +111,16 @@ func TestRenderHTTPCustomPort(t *testing.T) { } } -func TestRenderHTTPIPv4AndRejectHTTPSIPv4(t *testing.T) { +func TestRenderIPv4HTTPAndHTTPS(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") + httpSite, httpsSite, err := RenderWithOptions(opts, nil, 0) + if err != nil || !strings.Contains(httpSite, "server_name 121.4.111.208;") || !strings.Contains(httpsSite, "listen 28080 ssl;") { + t.Fatalf("HTTPS IPv4 rendering failed: %v", err) } } diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go index 795c462..b05165c 100644 --- a/backend/internal/nginxgateway/gateway.go +++ b/backend/internal/nginxgateway/gateway.go @@ -338,9 +338,61 @@ func checkSites() error { } func certificateExists(domain string) bool { - fullchain, _, _ := tlsservice.CertificateFiles(domain) - _, err := os.Stat(fullchain) - return err == nil + fullchain, privkey, _ := tlsservice.CertificateFiles(domain) + pair, err := tls.LoadX509KeyPair(fullchain, privkey) + if err != nil || len(pair.Certificate) == 0 { + return false + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + return err == nil && leaf.VerifyHostname(domain) == nil && + !time.Now().Before(leaf.NotBefore) && time.Now().Before(leaf.NotAfter) +} + +func ipCertificateArgs(domain string) ([]string, error) { + // The scheduled systemd service on supported hosts must use the same Certbot + // binary as interactive issuance, or six-day certificates may expire. + issuer, err := exec.LookPath("certbot") + if err != nil { + return nil, fmt.Errorf("未找到 Certbot: %w", err) + } + actual, err := filepath.EvalSymlinks(issuer) + if err != nil { + return nil, err + } + scheduled, err := filepath.EvalSymlinks("/usr/bin/certbot") + if err != nil || actual != scheduled { + return nil, errors.New("IP 短期证书要求定时任务与签发使用同一版本的 /usr/bin/certbot;请先配置续签任务") + } + if err := exec.Command("systemctl", "is-active", "--quiet", "certbot.timer").Run(); err != nil { + return nil, errors.New("IP 短期证书要求先启用 certbot.timer 定时续签") + } + if err := exec.Command("systemctl", "is-enabled", "--quiet", "certbot.timer").Run(); err != nil { + return nil, errors.New("certbot.timer 必须设为开机自动运行,才能续签六天的 IP 证书") + } + service, err := exec.Command("systemctl", "show", "-p", "ExecStart", "--value", "certbot.service").Output() + if err != nil || !strings.Contains(string(service), "/usr/bin/certbot ") { + return nil, errors.New("certbot.service 未配置为使用 /usr/bin/certbot 续签,请先核对定时任务") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, issuer, "--version").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("检测 Certbot 版本失败: %w", err) + } + return ipCertificateArgsForVersion(domain, string(out)) +} + +func ipCertificateArgsForVersion(domain, output string) ([]string, error) { + version := regexp.MustCompile(`certbot ([0-9]+)\.([0-9]+)`).FindStringSubmatch(output) + if len(version) != 3 { + return nil, errors.New("无法识别 Certbot 版本;IP 证书要求 5.4 或更新版本") + } + major, _ := strconv.Atoi(version[1]) + minor, _ := strconv.Atoi(version[2]) + if major < 5 || (major == 5 && minor < 4) { + return nil, fmt.Errorf("当前 Certbot %s 不支持 IP 的 webroot 签发;请升级到 5.4 或更新版本", version[0]) + } + return []string{"--preferred-profile", "shortlived", "--ip-address", domain}, nil } // Run performs a reversible HTTP bootstrap, certificate issuance, then HTTPS @@ -366,6 +418,11 @@ func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut if !tlsservice.ValidPublicHost(scheme, domain) { return errors.New("请先保存有效域名;HTTP 也支持 IPv4 地址") } + if scheme == "https" && net.ParseIP(domain) != nil && !certificateExists(domain) { + if _, err := ipCertificateArgs(domain); err != nil { + return err + } + } if !tlsservice.ValidScheme(scheme) { return errors.New("协议仅支持 http 或 https") } @@ -449,8 +506,18 @@ func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut } emit("HTTP-01 验证站点已启用,开始签发证书。\n") if !certificateExists(domain) { - if err := command(emit, 4*time.Minute, "certbot", "certonly", "--webroot", "-w", nginxsetup.Webroot, - "-d", domain, "--non-interactive", "--agree-tos", "--register-unsafely-without-email"); err != nil { + args := []string{"certonly", "--webroot", "-w", nginxsetup.Webroot} + if net.ParseIP(domain) != nil { + ipArgs, err := ipCertificateArgs(domain) + if err != nil { + return err + } + args = append(args, ipArgs...) + } else { + args = append(args, "-d", domain) + } + args = append(args, "--non-interactive", "--agree-tos", "--register-unsafely-without-email") + if err := command(emit, 4*time.Minute, "certbot", args...); err != nil { return err } } @@ -473,8 +540,9 @@ func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut return err } // Certbot runs deploy hooks only when certificates are renewed. - const hook = "#!/bin/sh\nset -eu\n/usr/sbin/nginx -t\n/bin/systemctl reload nginx\n" - if data, err := os.ReadFile(hookPath); err == nil && string(data) != hook { + const oldHook = "#!/bin/sh\nset -eu\n/usr/sbin/nginx -t\n/bin/systemctl reload nginx\n" + hook := renewalHook() + if data, err := os.ReadFile(hookPath); err == nil && string(data) != hook && string(data) != oldHook { return errors.New("续签钩子已存在且内容不同,请人工检查") } else if err != nil && !errors.Is(err, os.ErrNotExist) { return err diff --git a/backend/internal/nginxgateway/gateway_test.go b/backend/internal/nginxgateway/gateway_test.go index 862c8f5..b305947 100644 --- a/backend/internal/nginxgateway/gateway_test.go +++ b/backend/internal/nginxgateway/gateway_test.go @@ -2,6 +2,7 @@ package nginxgateway import ( "errors" + "strings" "testing" "vpn-platform/backend/internal/nginxconfig" @@ -13,6 +14,7 @@ func TestParseManagedEntry(t *testing.T) { {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}, + {Domain: "121.4.111.208", Scheme: "https", Port: 28080}, } { httpSite, httpsSite, err := nginxconfig.RenderWithOptions(want, nil, 0) if err != nil { @@ -28,6 +30,21 @@ func TestParseManagedEntry(t *testing.T) { } } +func TestIPCertificateRequiresWebrootCapableCertbot(t *testing.T) { + for _, version := range []string{"certbot 2.9.0", "certbot 5.3.1", "unknown"} { + if _, err := ipCertificateArgsForVersion("121.4.111.208", version); err == nil { + t.Fatalf("accepted unsupported version %q", version) + } + } + args, err := ipCertificateArgsForVersion("121.4.111.208", "certbot 5.4.0") + if err != nil || strings.Join(args, " ") != "--preferred-profile shortlived --ip-address 121.4.111.208" { + t.Fatalf("unexpected IP ACME flags %q: %v", args, err) + } + if !strings.Contains(renewalHook(), "RENEWED_LINEAGE") || !strings.Contains(renewalHook(), "cert-renewals.log") { + t.Fatal("deploy hook must record a successful renewal") + } +} + func TestSwapManagedSiteRestoresPreviousOnFailure(t *testing.T) { for _, failAt := range []string{"validate", "reload", "persist"} { t.Run(failAt, func(t *testing.T) { diff --git a/backend/internal/nginxgateway/renewal-hook.sh b/backend/internal/nginxgateway/renewal-hook.sh new file mode 100644 index 0000000..d66cd1b --- /dev/null +++ b/backend/internal/nginxgateway/renewal-hook.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +/usr/sbin/nginx -t +/bin/systemctl reload nginx + +# Certbot sets RENEWED_LINEAGE for successful renewals. Record only a simple +# lineage name, not certificate contents, account credentials, or key paths. +case "${RENEWED_LINEAGE:-}" in + /etc/letsencrypt/live/*) + cert_name=${RENEWED_LINEAGE##*/} + case "$cert_name" in + ''|*[!a-zA-Z0-9._-]*) exit 0 ;; + esac + /usr/bin/install -d -m 700 /var/log/opsx-vpn + /usr/bin/touch /var/log/opsx-vpn/cert-renewals.log + /bin/chmod 600 /var/log/opsx-vpn/cert-renewals.log + /usr/bin/printf '%s\t%s\n' "$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ)" "$cert_name" >> /var/log/opsx-vpn/cert-renewals.log + ;; +esac diff --git a/backend/internal/nginxgateway/renewal_hook.go b/backend/internal/nginxgateway/renewal_hook.go new file mode 100644 index 0000000..31c5551 --- /dev/null +++ b/backend/internal/nginxgateway/renewal_hook.go @@ -0,0 +1,8 @@ +package nginxgateway + +import _ "embed" + +//go:embed renewal-hook.sh +var renewalHookScript string + +func renewalHook() string { return renewalHookScript } diff --git a/backend/internal/tlsservice/renewals.go b/backend/internal/tlsservice/renewals.go new file mode 100644 index 0000000..3697bac --- /dev/null +++ b/backend/internal/tlsservice/renewals.go @@ -0,0 +1,63 @@ +package tlsservice + +import ( + "errors" + "io" + "os" + "strings" + "time" +) + +// RenewalLogPath is written only by the Certbot deploy hook after Nginx reload. +const RenewalLogPath = "/var/log/opsx-vpn/cert-renewals.log" + +type RenewalEvent struct { + Time time.Time `json:"time"` + Host string `json:"host"` +} + +// ReadRenewalEvents returns a bounded, validated view of successful renewals. +// Missing history does not imply a renewal failure: the certificate may not be due. +func ReadRenewalEvents() []RenewalEvent { + return readRenewalEvents(RenewalLogPath) +} + +func readRenewalEvents(path string) []RenewalEvent { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + const limit int64 = 65536 + info, err := f.Stat() + if err != nil { + return nil + } + start := info.Size() - limit + if start < 0 { + start = 0 + } + if _, err := f.Seek(start, io.SeekStart); err != nil { + return nil + } + data, err := io.ReadAll(io.LimitReader(f, limit)) + if err != nil && !errors.Is(err, io.EOF) { + return nil + } + lines := strings.Split(string(data), "\n") + if start > 0 && len(lines) > 0 { + lines = lines[1:] // ignore a partial line from the bounded tail + } + var events []RenewalEvent + for i := len(lines) - 1; i >= 0 && len(events) < 20; i-- { + fields := strings.Split(lines[i], "\t") + if len(fields) != 2 || (!ValidPublicHost("https", fields[1]) && !ValidDomain(fields[1])) { + continue + } + stamp, err := time.Parse(time.RFC3339, fields[0]) + if err == nil { + events = append(events, RenewalEvent{Time: stamp, Host: fields[1]}) + } + } + return events +} diff --git a/backend/internal/tlsservice/renewals_test.go b/backend/internal/tlsservice/renewals_test.go new file mode 100644 index 0000000..558e08a --- /dev/null +++ b/backend/internal/tlsservice/renewals_test.go @@ -0,0 +1,22 @@ +package tlsservice + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadRenewalEventsAcceptsOnlyBoundedValidRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "renewals.log") + content := "2026-10-26T08:00:00Z\ttest-link.appx365.com\n" + + "2026-10-26T09:00:00Z\t121.4.111.208\n" + + "invalid\t121.4.111.208\n" + + "2026-10-26T10:00:00Z\t../bad\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + events := readRenewalEvents(path) + if len(events) != 2 || events[0].Host != "121.4.111.208" || events[1].Host != "test-link.appx365.com" { + t.Fatalf("renewal events = %+v", events) + } +} diff --git a/backend/internal/tlsservice/tlsservice.go b/backend/internal/tlsservice/tlsservice.go index 396e78b..66b6ed1 100644 --- a/backend/internal/tlsservice/tlsservice.go +++ b/backend/internal/tlsservice/tlsservice.go @@ -40,29 +40,30 @@ var state struct { } type CertificateStatus struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` - ConfiguredMode string `json:"configured_mode"` - Scheme string `json:"scheme"` - ConfiguredScheme string `json:"configured_scheme"` - Domain string `json:"domain"` - ConfiguredDomain string `json:"configured_domain"` - Port int `json:"port"` - ConfiguredPort int `json:"configured_port"` - PublicBase string `json:"public_base"` - ConfiguredPublicBase string `json:"configured_public_base"` - RestartRequired bool `json:"restart_required"` - DNS []string `json:"dns"` - HTTPAddr string `json:"http_address"` - HTTPSAddr string `json:"https_address"` - Cached bool `json:"certificate_cached"` - Valid bool `json:"certificate_valid"` - CertSource string `json:"cert_source,omitempty"` - Issuer string `json:"issuer,omitempty"` - 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"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + ConfiguredMode string `json:"configured_mode"` + Scheme string `json:"scheme"` + ConfiguredScheme string `json:"configured_scheme"` + Domain string `json:"domain"` + ConfiguredDomain string `json:"configured_domain"` + Port int `json:"port"` + ConfiguredPort int `json:"configured_port"` + PublicBase string `json:"public_base"` + ConfiguredPublicBase string `json:"configured_public_base"` + RestartRequired bool `json:"restart_required"` + DNS []string `json:"dns"` + HTTPAddr string `json:"http_address"` + HTTPSAddr string `json:"https_address"` + Cached bool `json:"certificate_cached"` + Valid bool `json:"certificate_valid"` + CertSource string `json:"cert_source,omitempty"` + Issuer string `json:"issuer,omitempty"` + 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"` + RenewalEvents []RenewalEvent `json:"renewal_events"` } // DataDir returns the active TLS data directory (empty before Start). @@ -82,11 +83,11 @@ 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() + if ip.To4() == nil || ip.IsUnspecified() || ip.IsMulticast() { + return false + } + return scheme == "http" || (ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()) } return ValidDomain(host) } @@ -202,6 +203,9 @@ func configFromEnvironment(dataDir string) (configuration, error) { if domain != "" && !ValidPublicHost(scheme, domain) { return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 必须是有效域名,HTTP 模式也可使用 IPv4 地址") } + if mode == "autocert" && domain != "" && !ValidDomain(domain) { + return configuration{}, fmt.Errorf("旧 Go 自动证书模式仅支持域名;IP 证书请使用 Nginx") + } port := 0 if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" { var err error @@ -410,7 +414,8 @@ func Status() CertificateStatus { Mode: config.mode, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, - Renewal: config.mode, + Renewal: config.mode, + RenewalEvents: ReadRenewalEvents(), } if config.mode == "nginx" { listenAddress := os.Getenv("VPN_LISTEN_ADDR") diff --git a/backend/internal/tlsservice/tlsservice_test.go b/backend/internal/tlsservice/tlsservice_test.go index 0349b0a..2c30b1e 100644 --- a/backend/internal/tlsservice/tlsservice_test.go +++ b/backend/internal/tlsservice/tlsservice_test.go @@ -55,12 +55,15 @@ func TestHTTPIPv4PublicEntry(t *testing.T) { t.Fatalf("invalid HTTP host accepted: %s", host) } } - if ValidPublicHost("https", "121.4.111.208") { - t.Fatal("HTTPS IPv4 must require a domain") + if !ValidPublicHost("https", "121.4.111.208") || ValidPublicHost("https", "127.0.0.1") || ValidPublicHost("https", "192.168.1.2") { + t.Fatal("HTTPS IP certificates require a public IPv4 address") } if got := PublicBaseURL("http", "121.4.111.208", 28080); got != "http://121.4.111.208:28080" { t.Fatalf("unexpected public URL: %s", got) } + if got := PublicBaseURL("https", "121.4.111.208", 443); got != "https://121.4.111.208" { + t.Fatalf("unexpected IP HTTPS URL: %s", got) + } t.Setenv("VPN_TLS_DOMAIN", "") t.Setenv("VPN_TLS_SCHEME", "") t.Setenv("VPN_TLS_PORT", "") @@ -68,8 +71,8 @@ func TestHTTPIPv4PublicEntry(t *testing.T) { 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") + if err := ConfigurePublicEntry(dir, "https", "121.4.111.208", 443); err != nil { + t.Fatalf("HTTPS public IPv4 was rejected: %v", err) } } diff --git a/docs/deploy.md b/docs/deploy.md index f4b4b0a..a353cd3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -56,7 +56,7 @@ sudo ss -tlnp | grep ':18080' 内置方案复用管理域名与证书;先在「设置 → 飞书身份」启用身份服务并同步公网路由,Nginx 仅允许固定 `/id/` 身份路径。创建并启动唯一一条使用当前域名的 OpenVPN OIDC 测试专线后再次同步,才会把 `/oauth2/` 转发到其本机认证组件 `127.0.0.1:19090+`。未启动专线时不会开放 `/oauth2/` 路由。不要把认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。 -该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com`,由 Nginx 处理公网监听;`VPN_TLS_DOMAIN` 等环境变量优先于页面配置。保存配置并不会自动安装 Nginx、开放安全组或签发证书。HTTP 可以使用 IPv4 与自定义端口;HTTPS 签发仍要求域名。 +该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com`,由 Nginx 处理公网监听;`VPN_TLS_DOMAIN` 等环境变量优先于页面配置。保存配置并不会自动安装 Nginx、开放安全组或签发证书。HTTP 可以使用 IPv4 与自定义端口。HTTPS IPv4 证书为约六天的短期证书,需要 Certbot 5.4+(支持 IP webroot 与短期 profile)、公网 80 可达和有效定时续签;服务器现有 Ubuntu Certbot 2.9.0 不具备此能力,不能仅凭页面保存地址就认为已启用 IP HTTPS。 旧实例如果显式配置 `data/tls-mode=autocert` 或 `VPN_TLS_MODE=autocert`,仍沿用 Go 直监听 80/443 以避免升级时断网,但页面不再提供切换入口模式,也不允许在旧模式下保存新的公网入口。迁移时先备份入口模式、程序和证书,在维护窗口内准备 Nginx/Certbot 与可回退的站点配置,停用占用公网端口的 Go 管理进程,将入口模式显式改为 `nginx`,启用 Nginx 站点,再启动管理进程并核对登录、OIDC 发现文档、OpenVPN 浏览器回调和证书续签。任何验证失败都应恢复备份的配置、站点和程序。不能只修改模式文件后重启:未准备好站点会让公网入口不可用。新实例未写模式配置时默认为 `nginx`,不会自行监听公网端口。 @@ -70,6 +70,8 @@ sudo ss -tlnp | grep ':18080' 2. 点击启用操作后,服务生成 HTTP-01 站点并校验 `nginx -t`,使用 `certbot certonly --webroot` 申请证书;失败时页面展示过程与错误,并保留 HTTP 验证站点供重试。Certbot 当前使用无邮箱注册,续签异常不会发送邮件提醒。 3. 证书签出后程序自动追加 HTTPS 站点、检查配置并重载,同时安装固定续签钩子(`nginx -t` + `systemctl reload nginx`)。确认 Certbot 续签定时器运行,执行 `certbot renew --dry-run` 检查续签;生产验证时再核对 `--run-deploy-hooks` 对钩子的实际效果。证书状态页读取实际的证书到期日;不要仅以站点文件存在判断公网可用。 +新版续签钩子仅在 Certbot **成功续签并重载 Nginx 后**追加时间和证书标识到权限受限的 `/var/log/opsx-vpn/cert-renewals.log`,证书页展示最近记录;没有记录不代表续签失败,可能尚未到期。定时检查与失败原因查 `systemctl status certbot.timer`、`journalctl -u certbot.service` 和 `/var/log/letsencrypt/letsencrypt.log`。HTTP(包括 HTTP IP)不使用证书,也没有续签记录。 + 2026-10-22 生产实例已启用 `test-link.appx365.com`,证书有效至 2027-01-19,`certbot renew --dry-run --run-deploy-hooks --non-interactive` 模拟续签成功。当前管理服务仍在公网 `:18080` 监听,沿用 HTTP 地址的客户端可绕过 Nginx 的路径白名单;完成客户端地址迁移后,需限制 18080 仅回环访问。模拟续签执行钩子时 `nginx -t` 的正常 stderr 被 Certbot 记录为警告,但退出成功。 4. 确认入口模式为 Nginx,启动或重启 opsx-vpn 并设置 `VPN_LISTEN_ADDR=127.0.0.1:18080`;确认 `https://<域名>/api/health`、登录及各业务页面可用,未知路径断开,80 的挑战仍可读,然后收紧 18080 公网安全组。若现有业务必须从公网直连 18080,先迁移这些客户端。 diff --git a/docs/specs/SDD-008-ip-certificate-renewal.md b/docs/specs/SDD-008-ip-certificate-renewal.md new file mode 100644 index 0000000..0de9173 --- /dev/null +++ b/docs/specs/SDD-008-ip-certificate-renewal.md @@ -0,0 +1,60 @@ +# SDD-008:IP 证书、续签记录与证书状态 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3(ACME、证书部署与公网入口) | +| 设计状态 | 已形成 | +| 实现状态 | 已实现,待部署与兼容 Certbot 版本的真实 IP 签发验证 | +| 负责人 / 日期 | opsx-vpn / 2026-10-26 | +| 关联需求 | IPv4 自动签发/续期;域名与 IP 定时任务及每次成功续签记录;修正 HTTP IP 的证书假状态 | +| 批准状态 | 用户要求开发与核对 | + +## 1. 背景、目标与范围 + +- 当前行为:`GatewaySettings.vue` 凡有公网 Host 即产生证书行,并在 Nginx 模式标为 HTTP-01、Certbot;零值到期时间被浏览器转换为 `1/1/1`。`nginxgateway.RunEntryWithAuth` 仅为域名执行 `certbot certonly --webroot -d`;固定 deploy hook 只执行 `nginx -t` 和 reload。生产 Ubuntu Certbot 2.9.0、certbot.timer 每天两次检查,域名证书有效但尚未到期。 +- 目标:HTTP 入口只显示“未启用 HTTPS”,不能伪造证书和续签;支持 HTTPS 公网 IPv4 的短期 ACME 证书签发与自动续签,在当前客户端不具备能力时清晰报告升级要求;记录每次成功续签并在证书页展示最近记录。 +- 非目标:在生产强制切换当前 HTTPS 域名到 IP、用自签证书充数、删除已有域名证书或升级系统 Certbot 软件包。 +- 保留:运行中 OIDC 回调变更保护、Nginx 白名单、首次签发需 80 可达、既有域名证书和续签行为、OpenVPN 进程。 + +## 2. 需求与约束 + +- FR-01:公网 HTTP(含 IP)不显示虚假的证书来源、到期日和自动续签;HTTPS 仅在发现实际有效证书时标为有效,零值时间显示横线。 +- FR-02:HTTPS IPv4 允许公网单一非私网 IP;签发使用支持 `--ip-address` 与 `--preferred-profile shortlived` 的 Certbot >=5.4 webroot;不满足条件时在更改站点前报错。IP 证书路径使用与域名相同的 Certbot lineage,验证叶证书的 IP SAN,成功后复用 Nginx HTTPS 与续签钩子。 +- FR-03:Certbot 定时器仍负责定时检查;成功续签之后的 deploy hook 先检查、重载 Nginx,再以无密钥信息的时间/lineage 追加记录;页面展示最近记录,未续签不产生成功记录。 +- NFR-01:不自动安装来源不明的软件;续签记录路径和证书身份不可被未授权外部输入任意写入;既有钩子只能识别已知旧版本并安全替换;新增状态展示不宣称定时器健康,除非实际探测。 + +## 3. 架构与方案 + +`tlsservice` 按协议校验公网 Host,HTTPS IPv4 只允许真正公网单播 IP,证书校验继续使用 `x509.VerifyHostname`。`nginxgateway` 分支执行 IP 专用 Certbot 参数,向用户报告最低版本要求;沿用 webroot 80 路径及 Certbot 的 renewal 配置,由系统定时器负责续签。生成并管理固定 deploy hook:续签成功后执行 `nginx -t` 与 reload,写 root 限权的追加日志。TLS 状态 API 返回最近记录,前端按实际 `certificate_valid`、`cert_source` 和协议显示,证书页给出短期 IP 证书与升级要求提示。 + +## 4. 接口、数据与配置契约 + +- `GET /api/system/tls` 增加可选 `renewal_events` 最近成功记录(时间、证书标识);旧字段兼容。 +- `PUT /api/system/tls/domain` 接受 HTTPS 公网 IPv4;生产活跃 OIDC 回调仍可返回 409,现有站点没有目标证书时不能直接热切换。 +- 固定续签记录:`/var/log/opsx-vpn/cert-renewals.log`,仅成功执行钩子时追加;首次签发和上传应由现有审计记录区分,不误标续签。 + +## 5. 风险与验收 + +| 风险或失败场景 | 防护 / 恢复 | 可观察信号 | +|---|---|---| +| 旧 Certbot 不支持 IP webroot | 版本/参数检查前置;保留旧域名站点 | 明确报错、旧站点继续响应 | +| 六天证书无法及时续期 | certbot.timer 每天两次;部署钩子和记录;需运维监控 timer/到期时间 | 证书到期日、续签历史、systemd 日志 | +| 钩子被篡改或日志泄密 | 只替换精确识别的旧版本,安全转义证书标识,不写私钥 | root 文件权限、失败告警 | +| HTTP IP 页面误报 HTTPS | 仅从证书和实际协议渲染 | 空证书行和无续签状态 | + +| AC | 对应需求 | 操作 | 预期结果 | 验证 | 结果 | +|---|---|---|---|---|---| +| AC-01 | FR-01 | HTTP IP、HTTPS 域名无证书 | 不显示伪证书或 0001 日期 | 前端构建/代码检查 | 构建和代码检查通过;未做交互测试 | +| AC-02 | FR-02 | 验证公网 IPv4、私网、非法 IP 和 Certbot 参数 | 公网仅 HTTPS 可签,低版本明确拒绝,域名原流程不变 | Go 单测/本机渲染 | Go 单测通过;未在兼容 Certbot 上签发真实 IP 证书 | +| AC-03 | FR-03 | 模拟 deploy hook 记录 | 成功重载后追加事件;不记录失败/未续签 | Go 测试/只读检查 | Go 事件解析与 shell 语法通过;真实续签未发生 | +| AC-04 | NFR-01 | 远端部署检查 | 生产域名登录、OIDC、证书/定时器正常,未改公网模式 | 远端只读探测 | Not Run | + +## 6. 发布与恢复 + +先本地测试构建;远端备份管理二进制及旧 hook 后拉取构建,精确重启管理服务,使用已识别的旧 hook 更新续签记录能力;核对 Nginx、Certbot timer、生产证书到期时间与 OIDC/OpenVPN。保留当前 HTTPS 域名;不发起真实 IP 签发,除非服务器部署兼容客户端并另行切换公网入口。若新 hook 校验失败,恢复旧 hook、二进制,`nginx -t` 并重载。现有 `certbot` 2.9.0 必须明确标记 IP 自动证书尚未就绪。 + +## 7. 待决问题 + +| 问题 | 是否阻塞 | 推荐方案 | +|---|---|---| +| 服务器升级 Certbot 来源 | 对真实 IP 签发是 | 使用经管理员维护的 5.4+,避免自动覆盖发行版包;支持到位后再切换 | diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index bbaac32..b356d72 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -966,6 +966,9 @@ "saveEntry": "Save public entry", "hostRequired": "Enter a domain or IPv4 address", "certListTitle": "Certificates", + "httpNoCertificate": "This HTTP gateway has no TLS certificate or renewal. Switch to HTTPS to request one.", + "ipCertificateRequirement": "IP certificates last about six days. Automated issuance needs Certbot 5.4+, public port 80, and scheduled renewal. Unsupported server versions cannot issue one.", + "renewalHistory": "Recent successful renewals", "addCert": "Add certificate", "replaceCert": "Replace", "certSource": "Source", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 5da770c..caa1c3d 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -966,6 +966,9 @@ "saveEntry": "保存公网入口", "hostRequired": "请填写域名或 IPv4 地址", "certListTitle": "证书", + "httpNoCertificate": "当前是 HTTP 入口,没有 TLS 证书,也不会自动续签。切换 HTTPS 后才能申请证书。", + "ipCertificateRequirement": "IP 证书有效期约 6 天;自动申请需要 Certbot 5.4+、公网 80 端口和定时续签。当前服务器版本不足时会阻止签发。", + "renewalHistory": "最近成功续签", "addCert": "添加证书", "replaceCert": "更换", "certSource": "来源", diff --git a/frontend/src/views/settings/components/GatewaySettings.vue b/frontend/src/views/settings/components/GatewaySettings.vue index 2895a13..7dc463d 100644 --- a/frontend/src/views/settings/components/GatewaySettings.vue +++ b/frontend/src/views/settings/components/GatewaySettings.vue @@ -59,7 +59,7 @@ @@ -68,7 +68,7 @@ @@ -87,11 +87,17 @@ - + + + +
+ {{ $t('tls.renewalHistory') }} +

{{ new Date(event.time).toLocaleString() }} · {{ event.host }}

+
@@ -124,7 +130,7 @@
-
+
{{ $t('tls.methodUpload') }}
@@ -231,6 +237,7 @@ const entry = reactive({ host: '', port: 80, }) +const recentRenewals = computed(() => (tlsStatus.value?.renewal_events || []).filter(e => e.host === entry.host).slice(0, 5)) const certForm = reactive({ method: 'upload', @@ -246,20 +253,25 @@ const publicBase = computed(() => { return buildPublicBase(entry) }) +function isIPv4(host) { + const parts = String(host || '').split('.') + return parts.length === 4 && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255) +} + const certRows = computed(() => { const s = tlsStatus.value - if (!s) return [] - const host = s.configured_domain || s.domain || entry.host - if (!host && !s.certificate_valid && !s.enabled && !s.certificate_cached) return [] - const source = s.cert_source || (s.mode === 'nginx' || s.enabled ? 'http01' : (s.certificate_cached ? 'upload' : 'http01')) + if (!s || entry.scheme !== 'https' || s.configured_scheme !== 'https') return [] + const host = s.configured_domain || s.domain + if (!host || (!s.certificate_cached && !s.certificate_valid) || s.configured_public_base !== s.public_base) return [] + const source = s.cert_source || (s.enabled ? 'http01' : '') return [{ host, source, valid: !!s.certificate_valid, pending: !!s.enabled && !s.certificate_valid, - expires: s.not_after || null, + expires: s.not_after && !s.not_after.startsWith('0001-') ? s.not_after : null, renewalFailed: !!s.renewal_failed, - renewalLabel: s.enabled ? t('tls.automatic') : s.mode === 'nginx' ? t('tls.externalRenewal') : '—', + renewalLabel: source === 'http01' ? (s.enabled ? t('tls.automatic') : t('tls.externalRenewal')) : '—', }] }) @@ -268,7 +280,7 @@ function onSchemeChange() { } function resetCertForm() { - certForm.method = 'upload' + certForm.method = isIPv4(entry.host) ? 'http01' : 'upload' certForm.certPem = '' certForm.keyPem = '' certForm.certFileName = '' @@ -481,13 +493,14 @@ 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; } +.tls-renewals { margin-top: 12px; } .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; } .scheme-group :deep(.el-radio-button__inner) { width: 100%; } -.method-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } .method-card { border: 1px solid var(--el-border-color); border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, box-shadow .15s;