chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ImportInfo struct {
|
||||
Primary string
|
||||
Domains []string
|
||||
Wildcard bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
|
||||
cert, err := parseCertificate(certPEM)
|
||||
if err != nil {
|
||||
return ImportInfo{}, err
|
||||
}
|
||||
key, err := parsePrivateKey(keyPEM)
|
||||
if err != nil {
|
||||
return ImportInfo{}, err
|
||||
}
|
||||
if !publicKeysMatch(cert.PublicKey, key) {
|
||||
return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
|
||||
}
|
||||
domains, err := ExtractCertDomains(cert)
|
||||
if err != nil {
|
||||
return ImportInfo{}, err
|
||||
}
|
||||
return ImportInfo{
|
||||
Primary: primaryDomain(domains),
|
||||
Domains: domains,
|
||||
Wildcard: hasWildcardDomain(domains),
|
||||
ExpiresAt: cert.NotAfter.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
|
||||
info, err := InspectImport(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return info.ExpiresAt, nil
|
||||
}
|
||||
|
||||
func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var domains []string
|
||||
add := func(name string) error {
|
||||
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||
if name == "" || net.ParseIP(name) != nil {
|
||||
return nil
|
||||
}
|
||||
if seen[name] {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") {
|
||||
if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := validateDomainLabel(name); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[name] = true
|
||||
domains = append(domains, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, name := range cert.DNSNames {
|
||||
if err := add(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := add(cert.Subject.CommonName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
return nil, fmt.Errorf("证书中未包含有效域名")
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
func primaryDomain(domains []string) string {
|
||||
for _, domain := range domains {
|
||||
if !strings.HasPrefix(domain, "*.") {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(domains[0], "*.")
|
||||
}
|
||||
|
||||
func hasWildcardDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateDomainLabel(domain string) error {
|
||||
if domain == "" {
|
||||
return fmt.Errorf("域名格式无效")
|
||||
}
|
||||
parts := strings.Split(domain, ".")
|
||||
for _, part := range parts {
|
||||
if part == "" || len(part) > 63 {
|
||||
return fmt.Errorf("域名格式无效")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
|
||||
rest := certPEM
|
||||
for {
|
||||
block, remaining := pem.Decode(rest)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("证书 PEM 格式无效")
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析证书失败:%w", err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
rest = remaining
|
||||
if len(rest) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
|
||||
}
|
||||
|
||||
func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("私钥 PEM 格式无效")
|
||||
}
|
||||
switch block.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
return x509.ParseECPrivateKey(block.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
return x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
|
||||
switch pub := certKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
priv, ok := privateKey.(*rsa.PrivateKey)
|
||||
return ok && pub.N.Cmp(priv.N) == 0
|
||||
case *ecdsa.PublicKey:
|
||||
priv, ok := privateKey.(*ecdsa.PrivateKey)
|
||||
return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePEM(v string) []byte {
|
||||
return []byte(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func ResolvePEMSource(content, path string) ([]byte, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
return ReadPEMFile(path)
|
||||
}
|
||||
data := NormalizePEM(content)
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("内容不能为空")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ReadPEMFile(path string) ([]byte, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("文件路径不能为空")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s 是目录,不是文件", path)
|
||||
}
|
||||
if info.Size() > 1<<20 {
|
||||
return nil, fmt.Errorf("文件过大(最大 1MB)")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败:%w", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, fmt.Errorf("文件内容为空")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadPEMFile(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert("example.com")
|
||||
certPath := t.TempDir() + "/cert.pem"
|
||||
keyPath := t.TempDir() + "/key.pem"
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
readCert, err := ReadPEMFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(readCert) != string(certPEM) {
|
||||
t.Fatal("cert content mismatch")
|
||||
}
|
||||
|
||||
got, err := ResolvePEMSource("", certPath)
|
||||
if err != nil || string(got) != string(certPEM) {
|
||||
t.Fatalf("ResolvePEMSource path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectImport(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCertWithSANs([]string{"example.com", "*.example.com", "nas.example.com"})
|
||||
info, err := InspectImport(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Primary != "example.com" {
|
||||
t.Fatalf("primary: %s", info.Primary)
|
||||
}
|
||||
if !info.Wildcard {
|
||||
t.Fatal("expected wildcard")
|
||||
}
|
||||
if len(info.Domains) != 3 {
|
||||
t.Fatalf("domains: %v", info.Domains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePEM(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert("example.com")
|
||||
|
||||
expiresAt, err := ValidatePEM(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePEM: %v", err)
|
||||
}
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Fatalf("expected future expiry, got %v", expiresAt)
|
||||
}
|
||||
|
||||
_, err = ValidatePEM(certPEM, []byte("not a key"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key")
|
||||
}
|
||||
|
||||
_, wrongKeyPEM := generateTestCert("other.example.com")
|
||||
_, err = ValidatePEM(certPEM, wrongKeyPEM)
|
||||
if err == nil {
|
||||
t.Fatal("expected mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func generateTestCert(domain string) ([]byte, []byte) {
|
||||
return generateTestCertWithSANs([]string{domain})
|
||||
}
|
||||
|
||||
func generateTestCertWithSANs(domains []string) ([]byte, []byte) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cn := domains[0]
|
||||
if strings.HasPrefix(cn, "*.") {
|
||||
cn = strings.TrimPrefix(cn, "*.")
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: domains,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Wildcard bool `json:"wildcard"`
|
||||
ACMECA string `json:"acme_ca,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastRenewAt *time.Time `json:"last_renew_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
|
||||
expires_at, last_renew_at, status, COALESCE(last_error, '')
|
||||
FROM certificates ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []Record
|
||||
for rows.Next() {
|
||||
rec, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(ctx context.Context, domain string, wildcard bool, domainsJSON, acmeCA, certPath, keyPath string, expiresAt time.Time, status string, lastError string) (Record, error) {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO certificates(domain, domains, wildcard, acme_ca, cert_path, key_path, expires_at, last_renew_at, status, last_error, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, datetime('now'))
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
domains = excluded.domains,
|
||||
wildcard = excluded.wildcard,
|
||||
acme_ca = excluded.acme_ca,
|
||||
cert_path = excluded.cert_path,
|
||||
key_path = excluded.key_path,
|
||||
expires_at = excluded.expires_at,
|
||||
last_renew_at = datetime('now'),
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = datetime('now')
|
||||
`, domain, domainsJSON, boolInt(wildcard), acmeCA, certPath, keyPath, expiresAt.UTC().Format(time.RFC3339), status, lastError)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return s.GetByDomain(ctx, domain)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateStatus(ctx context.Context, domain, status, lastError string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE certificates SET status = ?, last_error = ?, updated_at = datetime('now') WHERE domain = ?
|
||||
`, status, lastError, domain)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, domain string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE domain = ?`, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("证书不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByDomain(ctx context.Context, domain string) (Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
|
||||
expires_at, last_renew_at, status, COALESCE(last_error, '')
|
||||
FROM certificates WHERE domain = ?
|
||||
`, domain)
|
||||
rec, err := scanRecord(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Record{}, fmt.Errorf("证书不存在")
|
||||
}
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func scanRecord(row interface{ Scan(dest ...any) error }) (Record, error) {
|
||||
var rec Record
|
||||
var wildcard int
|
||||
var domainsJSON string
|
||||
var expiresAt, lastRenewAt sql.NullString
|
||||
if err := row.Scan(&rec.ID, &rec.Domain, &domainsJSON, &wildcard, &rec.ACMECA, &rec.CertPath, &rec.KeyPath, &expiresAt, &lastRenewAt, &rec.Status, &rec.LastError); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
rec.Wildcard = wildcard == 1
|
||||
rec.Domains = decodeDomains(domainsJSON, rec.Domain, rec.Wildcard)
|
||||
if expiresAt.Valid {
|
||||
t := parseTime(expiresAt.String)
|
||||
rec.ExpiresAt = &t
|
||||
rec.DaysLeft = int(time.Until(t).Hours() / 24)
|
||||
}
|
||||
if lastRenewAt.Valid {
|
||||
t := parseTime(lastRenewAt.String)
|
||||
rec.LastRenewAt = &t
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func decodeDomains(raw string, domain string, wildcard bool) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if wildcard {
|
||||
return []string{domain, "*." + domain}
|
||||
}
|
||||
return []string{domain}
|
||||
}
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err != nil || len(domains) == 0 {
|
||||
if wildcard {
|
||||
return []string{domain, "*." + domain}
|
||||
}
|
||||
return []string{domain}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
t, _ = time.Parse("2006-01-02 15:04:05", v)
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user