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 @@
{{ new Date(event.time).toLocaleString() }} · {{ event.host }}
+