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:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+214
View File
@@ -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
}
+118
View File
@@ -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
}
+168
View File
@@ -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()
}