Add guarded IP certificate flow and renewal history

This commit is contained in:
Codex committed 2026-10-26 15:32:27 +08:00
1 parent dd94a4cc84
commit d3699abb18
14 files changed
+343 -56

No files matched your search

+63
View File
@@ -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)
}
}
+33 -28
View File
@@ -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)
}
}