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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user