Add guarded IP certificate flow and renewal history
This commit is contained in:
1 parent
dd94a4cc84
commit
d3699abb18
14 files changed
+343
-56
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
package nginxgateway
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed renewal-hook.sh
|
||||
var renewalHookScript string
|
||||
|
||||
func renewalHook() string { return renewalHookScript }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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,先迁移这些客户端。
|
||||
|
||||
|
||||
@@ -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+,避免自动覆盖发行版包;支持到位后再切换 |
|
||||
@@ -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",
|
||||
|
||||
@@ -966,6 +966,9 @@
|
||||
"saveEntry": "保存公网入口",
|
||||
"hostRequired": "请填写域名或 IPv4 地址",
|
||||
"certListTitle": "证书",
|
||||
"httpNoCertificate": "当前是 HTTP 入口,没有 TLS 证书,也不会自动续签。切换 HTTPS 后才能申请证书。",
|
||||
"ipCertificateRequirement": "IP 证书有效期约 6 天;自动申请需要 Certbot 5.4+、公网 80 端口和定时续签。当前服务器版本不足时会阻止签发。",
|
||||
"renewalHistory": "最近成功续签",
|
||||
"addCert": "添加证书",
|
||||
"replaceCert": "更换",
|
||||
"certSource": "来源",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<b>{{ $t('tls.certListTitle') }}</b>
|
||||
<el-button type="primary" :disabled="legacyMode" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
|
||||
<el-button type="primary" :disabled="legacyMode || entry.scheme === 'http'" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="certRows" size="default" v-loading="tlsLoading">
|
||||
@@ -68,7 +68,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('tls.certSource')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.source === 'upload' ? 'info' : 'success'">{{ $t(row.source === 'upload' ? 'tls.sourceUpload' : 'tls.sourceHttp01') }}</el-tag>
|
||||
<el-tag size="small" :type="row.source === 'upload' ? 'info' : 'success'">{{ row.source === 'upload' ? $t('tls.sourceUpload') : row.source === 'http01' ? $t('tls.sourceHttp01') : '—' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('tls.certificate')" width="110">
|
||||
@@ -87,11 +87,17 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operation')" width="120" fixed="right">
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="openAddCert">{{ $t('tls.replaceCert') }}</el-button>
|
||||
<el-button link type="primary" :disabled="legacyMode" @click="openAddCert">{{ $t('tls.replaceCert') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="$t('tls.noCerts')" /></template>
|
||||
<template #empty><el-empty :description="$t(entry.scheme === 'http' ? 'tls.httpNoCertificate' : 'tls.noCerts')" /></template>
|
||||
</el-table>
|
||||
<el-alert v-if="entry.scheme === 'http'" type="info" :closable="false" :title="$t('tls.httpNoCertificate')" style="margin-top:12px" />
|
||||
<el-alert v-if="entry.scheme === 'https' && isIPv4(entry.host)" type="warning" :closable="false" :title="$t('tls.ipCertificateRequirement')" style="margin-top:12px" />
|
||||
<div v-if="recentRenewals.length && entry.scheme === 'https'" class="tls-renewals">
|
||||
<b>{{ $t('tls.renewalHistory') }}</b>
|
||||
<p v-for="event in recentRenewals" :key="`${event.time}-${event.host}`" class="tls-hint">{{ new Date(event.time).toLocaleString() }} · {{ event.host }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Nginx 准备(折叠) -->
|
||||
@@ -124,7 +130,7 @@
|
||||
<!-- 添加证书弹窗 -->
|
||||
<el-dialog v-model="certDialog" :title="$t('tls.addCert')" width="640px" destroy-on-close @closed="resetCertForm">
|
||||
<div class="method-cards">
|
||||
<div class="method-card" :class="{ active: certForm.method === 'upload' }" @click="certForm.method = 'upload'">
|
||||
<div v-if="!isIPv4(entry.host)" class="method-card" :class="{ active: certForm.method === 'upload' }" @click="certForm.method = 'upload'">
|
||||
<el-radio v-model="certForm.method" value="upload">{{ $t('tls.methodUpload') }}</el-radio>
|
||||
</div>
|
||||
<div class="method-card" :class="{ active: certForm.method === 'http01' }" @click="certForm.method = 'http01'">
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user