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,89 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
)
|
||||
|
||||
const (
|
||||
CALetsEncrypt = "letsencrypt"
|
||||
CALetsEncryptStaging = "letsencrypt-staging"
|
||||
CAZeroSSL = "zerossl"
|
||||
CABuypass = "buypass"
|
||||
)
|
||||
|
||||
const (
|
||||
zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
|
||||
buypassDirectoryURL = "https://api.buypass.com/acme/directory"
|
||||
)
|
||||
|
||||
func NormalizeCA(ca string) string {
|
||||
switch ca {
|
||||
case "", CALetsEncrypt, "production":
|
||||
return CALetsEncrypt
|
||||
case CALetsEncryptStaging, "staging", "letsencrypt-test":
|
||||
return CALetsEncryptStaging
|
||||
case CAZeroSSL:
|
||||
return CAZeroSSL
|
||||
case CABuypass, "buypass-go":
|
||||
return CABuypass
|
||||
default:
|
||||
return ca
|
||||
}
|
||||
}
|
||||
|
||||
func DirectoryURL(ca string) (string, error) {
|
||||
switch NormalizeCA(ca) {
|
||||
case CALetsEncrypt:
|
||||
return lego.LEDirectoryProduction, nil
|
||||
case CALetsEncryptStaging:
|
||||
return lego.LEDirectoryStaging, nil
|
||||
case CAZeroSSL:
|
||||
return zeroSSLDirectoryURL, nil
|
||||
case CABuypass:
|
||||
return buypassDirectoryURL, nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的颁发机构:%s", ca)
|
||||
}
|
||||
}
|
||||
|
||||
func CALabel(ca string) string {
|
||||
switch NormalizeCA(ca) {
|
||||
case CALetsEncrypt:
|
||||
return "Let's Encrypt"
|
||||
case CALetsEncryptStaging:
|
||||
return "Let's Encrypt 测试"
|
||||
case CAZeroSSL:
|
||||
return "ZeroSSL"
|
||||
case CABuypass:
|
||||
return "Buypass"
|
||||
default:
|
||||
if ca == "" {
|
||||
return "手动导入"
|
||||
}
|
||||
return ca
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateCA(ca string) error {
|
||||
_, err := DirectoryURL(ca)
|
||||
return err
|
||||
}
|
||||
|
||||
func ValidateCADomains(ca string, domains []string) error {
|
||||
switch NormalizeCA(ca) {
|
||||
case CABuypass:
|
||||
if len(domains) > 5 {
|
||||
return fmt.Errorf("Buypass 单张证书最多支持 5 个域名")
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return fmt.Errorf("Buypass 不支持通配符证书")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package acme
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectoryURL(t *testing.T) {
|
||||
url, err := DirectoryURL(CALetsEncrypt)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("production: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CALetsEncryptStaging)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("staging: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CAZeroSSL)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("zerossl: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CABuypass)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("buypass: %v", err)
|
||||
}
|
||||
if err := ValidateCA("unknown"); err == nil {
|
||||
t.Fatal("expected error for unknown ca")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCADomainsBuypass(t *testing.T) {
|
||||
if err := ValidateCADomains(CABuypass, []string{"*.example.com"}); err == nil {
|
||||
t.Fatal("expected wildcard error")
|
||||
}
|
||||
if err := ValidateCADomains(CABuypass, []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}); err == nil {
|
||||
t.Fatal("expected domain limit error")
|
||||
}
|
||||
if err := ValidateCADomains(CALetsEncrypt, []string{"*.example.com"}); err != nil {
|
||||
t.Fatalf("letsencrypt wildcard should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/providers/dns/alidns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/dnspod"
|
||||
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
)
|
||||
|
||||
func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
cfg := dnspod.NewDefaultConfig()
|
||||
cfg.LoginToken = cred.LoginToken()
|
||||
return dnspod.NewDNSProviderConfig(cfg)
|
||||
case "alidns":
|
||||
cfg := alidns.NewDefaultConfig()
|
||||
cfg.APIKey = cred.Token
|
||||
cfg.SecretKey = cred.Secret
|
||||
return alidns.NewDNSProviderConfig(cfg)
|
||||
case "tencentcloud":
|
||||
cfg := tencentcloud.NewDefaultConfig()
|
||||
cfg.SecretID = cred.Token
|
||||
cfg.SecretKey = cred.Secret
|
||||
return tencentcloud.NewDNSProviderConfig(cfg)
|
||||
case "volcengine":
|
||||
return newVolcengineDNSProvider(cred)
|
||||
default:
|
||||
cfg := cloudflare.NewDefaultConfig()
|
||||
cfg.AuthToken = cred.Token
|
||||
return cloudflare.NewDNSProviderConfig(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func dnsProviderName(provider string) string {
|
||||
switch provider {
|
||||
case "dnspod", "alidns", "tencentcloud", "volcengine":
|
||||
return provider
|
||||
default:
|
||||
return "cloudflare"
|
||||
}
|
||||
}
|
||||
|
||||
type loggingDNSProvider struct {
|
||||
inner challenge.Provider
|
||||
job *Job
|
||||
}
|
||||
|
||||
func wrapDNSProvider(inner challenge.Provider, job *Job) challenge.Provider {
|
||||
if job == nil {
|
||||
return inner
|
||||
}
|
||||
return &loggingDNSProvider{inner: inner, job: job}
|
||||
}
|
||||
|
||||
func (p *loggingDNSProvider) Present(domain, token, keyAuth string) error {
|
||||
record := strings.TrimSuffix(domain, ".")
|
||||
p.job.Info(fmt.Sprintf("添加 DNS TXT 记录: _acme-challenge.%s", record))
|
||||
if err := p.inner.Present(domain, token, keyAuth); err != nil {
|
||||
p.job.Error(fmt.Sprintf("添加 DNS TXT 记录失败: %v", err))
|
||||
return err
|
||||
}
|
||||
p.job.Info("DNS TXT 记录已添加,等待 DNS 传播…")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *loggingDNSProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
p.job.Info("清理 DNS TXT 验证记录…")
|
||||
return p.inner.CleanUp(domain, token, keyAuth)
|
||||
}
|
||||
|
||||
func validateDNSCredentials(provider string, cred ddns.Credentials) error {
|
||||
if err := cred.Validate(dnsProviderName(provider)); err != nil {
|
||||
return err
|
||||
}
|
||||
if provider == "dnspod" && cred.TokenID == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置 DNSPod ID 和 Token")
|
||||
}
|
||||
if provider == "alidns" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置阿里云 AccessKey")
|
||||
}
|
||||
if provider == "tencentcloud" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置腾讯云 SecretKey")
|
||||
}
|
||||
if provider == "volcengine" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置火山引擎 Secret Access Key")
|
||||
}
|
||||
if provider != "dnspod" && provider != "alidns" && provider != "tencentcloud" && provider != "volcengine" && cred.Token == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置 Cloudflare API Token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
)
|
||||
|
||||
func TestValidateDNSCredentialsVolcengine(t *testing.T) {
|
||||
if err := validateDNSCredentials("volcengine", ddns.Credentials{Token: "ak", Secret: "sk"}); err != nil {
|
||||
t.Fatalf("expected volcengine credentials to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVolcengineDNSProvider(t *testing.T) {
|
||||
p, err := newVolcengineDNSProvider(ddns.Credentials{Token: "ak", Secret: "sk"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("expected provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
)
|
||||
|
||||
func NormalizeCertDomains(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
if len(raw) > 100 {
|
||||
return nil, fmt.Errorf("域名数量不能超过 100 个")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for _, part := range splitDomainInput(item) {
|
||||
domain, err := normalizeCertDomain(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[domain] {
|
||||
continue
|
||||
}
|
||||
seen[domain] = true
|
||||
out = append(out, domain)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitDomainInput(input string) []string {
|
||||
input = strings.ReplaceAll(input, "\r\n", "\n")
|
||||
input = strings.ReplaceAll(input, ",", "\n")
|
||||
parts := strings.Split(input, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCertDomain(domain string) (string, error) {
|
||||
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("域名不能为空")
|
||||
}
|
||||
wildcard := strings.HasPrefix(domain, "*.")
|
||||
base := strings.TrimPrefix(domain, "*.")
|
||||
if err := validate.Domain(base); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if wildcard {
|
||||
return "*." + base, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func DomainsUnderZone(domains []string, zone string) error {
|
||||
return DomainsUnderZones(domains, []string{zone})
|
||||
}
|
||||
|
||||
func DomainsUnderZones(domains []string, zones []string) error {
|
||||
normalized := normalizeManagedZones(zones)
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("请选择 DNS 凭证")
|
||||
}
|
||||
for _, domain := range domains {
|
||||
base := strings.TrimPrefix(domain, "*.")
|
||||
if !domainUnderAnyZone(base, normalized) {
|
||||
return fmt.Errorf("域名 %s 不在所选 DNS 凭证的管理范围内(%s)", domain, strings.Join(normalized, "、"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeManagedZones(zones []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(zones))
|
||||
for _, zone := range zones {
|
||||
zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
|
||||
if zone == "" || seen[zone] {
|
||||
continue
|
||||
}
|
||||
seen[zone] = true
|
||||
out = append(out, zone)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func domainUnderAnyZone(domain string, zones []string) bool {
|
||||
for _, zone := range zones {
|
||||
if domain == zone || strings.HasSuffix(domain, "."+zone) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PrimaryCertDomain(domains []string) string {
|
||||
for _, domain := range domains {
|
||||
if !strings.HasPrefix(domain, "*.") {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
if len(domains) > 0 {
|
||||
return strings.TrimPrefix(domains[0], "*.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func HasWildcardDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func LegacyCertDomains(domain string, wildcard bool) []string {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
if wildcard {
|
||||
return []string{domain, "*." + domain}
|
||||
}
|
||||
return []string{domain}
|
||||
}
|
||||
|
||||
func EncodeCertDomains(domains []string) string {
|
||||
if len(domains) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(domains)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeCertDomains(raw string, domain string, wildcard bool) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err == nil && len(domains) > 0 {
|
||||
normalized, err := NormalizeCertDomains(domains)
|
||||
if err == nil {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return LegacyCertDomains(domain, wildcard)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package acme
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeCertDomains(t *testing.T) {
|
||||
domains, err := NormalizeCertDomains([]string{"example.com", "*.example.com", "nas.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domains) != 3 {
|
||||
t.Fatalf("expected 3 domains, got %d", len(domains))
|
||||
}
|
||||
if domains[1] != "*.example.com" {
|
||||
t.Fatalf("unexpected wildcard: %s", domains[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainsUnderZone(t *testing.T) {
|
||||
domains := []string{"example.com", "*.example.com", "nas.example.com"}
|
||||
if err := DomainsUnderZone(domains, "example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DomainsUnderZone([]string{"other.com"}, "example.com"); err == nil {
|
||||
t.Fatal("expected zone mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainsUnderZonesMultiRoot(t *testing.T) {
|
||||
zones := []string{"roven.cc", "chiak.cc"}
|
||||
if err := DomainsUnderZones([]string{"chiak.cc", "*.chiak.cc"}, zones); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DomainsUnderZones([]string{"other.com"}, zones); err == nil {
|
||||
t.Fatal("expected zone mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCertDomainsLegacy(t *testing.T) {
|
||||
got := DecodeCertDomains("", "example.com", true)
|
||||
if len(got) != 2 || got[1] != "*.example.com" {
|
||||
t.Fatalf("unexpected legacy domains: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
)
|
||||
|
||||
type DownloadPart string
|
||||
|
||||
const (
|
||||
DownloadZIP DownloadPart = "zip"
|
||||
DownloadCert DownloadPart = "cert"
|
||||
DownloadKey DownloadPart = "key"
|
||||
)
|
||||
|
||||
func (s *Service) Download(ctx context.Context, domain string, part string) (filename string, contentType string, data []byte, err error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return "", "", nil, fmt.Errorf("域名不能为空")
|
||||
}
|
||||
|
||||
rec, err := s.store.GetByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if rec.CertPath == "" || rec.KeyPath == "" {
|
||||
return "", "", nil, fmt.Errorf("证书文件不存在,可能尚未申请成功")
|
||||
}
|
||||
if err := validateCertFilePath(s.cfg.CertsDir(), rec.CertPath); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if err := validateCertFilePath(s.cfg.CertsDir(), rec.KeyPath); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(rec.CertPath)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("读取证书失败")
|
||||
}
|
||||
keyPEM, err := os.ReadFile(rec.KeyPath)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("读取私钥失败")
|
||||
}
|
||||
|
||||
base := downloadBaseName(rec)
|
||||
switch DownloadPart(strings.ToLower(strings.TrimSpace(part))) {
|
||||
case "", DownloadZIP:
|
||||
data, err = zipCertBundle(base, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return base + ".zip", "application/zip", data, nil
|
||||
case DownloadCert:
|
||||
return base + "-fullchain.pem", "application/x-pem-file", certPEM, nil
|
||||
case DownloadKey:
|
||||
return base + "-privatekey.pem", "application/x-pem-file", keyPEM, nil
|
||||
default:
|
||||
return "", "", nil, fmt.Errorf("不支持的下载类型")
|
||||
}
|
||||
}
|
||||
|
||||
func downloadBaseName(rec certstore.Record) string {
|
||||
name := strings.TrimPrefix(rec.Domain, "*.")
|
||||
name = strings.ReplaceAll(name, "*", "wildcard")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
if name == "" {
|
||||
name = "certificate"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func validateCertFilePath(certsDir, path string) error {
|
||||
absCerts, err := filepath.Abs(certsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("证书目录无效")
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("证书路径无效")
|
||||
}
|
||||
rel, err := filepath.Rel(absCerts, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("证书路径无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zipCertBundle(base string, certPEM, keyPEM []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := writeZipEntry(zw, "fullchain.pem", certPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeZipEntry(zw, "privatekey.pem", keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCertFilePath(t *testing.T) {
|
||||
certsDir := filepath.Clean("/data/certs")
|
||||
if err := validateCertFilePath(certsDir, "/data/certs/example.com/fullchain.pem"); err != nil {
|
||||
t.Fatalf("expected valid path: %v", err)
|
||||
}
|
||||
if err := validateCertFilePath(certsDir, "/etc/passwd"); err == nil {
|
||||
t.Fatal("expected invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipCertBundle(t *testing.T) {
|
||||
data, err := zipCertBundle("example.com", []byte("cert"), []byte("key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("empty zip")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
)
|
||||
|
||||
type JobEvent struct {
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Result *JobDonePayload `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type JobDonePayload struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
CertDir string `json:"cert_dir,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
id string
|
||||
mu sync.Mutex
|
||||
events []JobEvent
|
||||
done bool
|
||||
waiters map[chan JobEvent]struct{}
|
||||
}
|
||||
|
||||
type JobManager struct {
|
||||
mu sync.Mutex
|
||||
jobs map[string]*Job
|
||||
}
|
||||
|
||||
func NewJobManager() *JobManager {
|
||||
return &JobManager{jobs: map[string]*Job{}}
|
||||
}
|
||||
|
||||
func (m *JobManager) Create() *Job {
|
||||
job := &Job{
|
||||
id: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
waiters: map[chan JobEvent]struct{}{},
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.jobs[job.id] = job
|
||||
m.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
|
||||
func (m *JobManager) Get(id string) *Job {
|
||||
m.mu.Lock()
|
||||
job := m.jobs[id]
|
||||
m.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
|
||||
func (j *Job) ID() string {
|
||||
return j.id
|
||||
}
|
||||
|
||||
func (j *Job) Append(level, message string) {
|
||||
j.emit(JobEvent{Type: "log", Level: level, Message: message})
|
||||
}
|
||||
|
||||
func (j *Job) Info(message string) { j.Append("info", message) }
|
||||
func (j *Job) Warn(message string) { j.Append("warn", message) }
|
||||
func (j *Job) Error(message string) { j.Append("error", message) }
|
||||
|
||||
func (j *Job) Finish(result JobDonePayload) {
|
||||
j.emit(JobEvent{Type: "done", Result: &result})
|
||||
}
|
||||
|
||||
func (j *Job) emit(ev JobEvent) {
|
||||
j.mu.Lock()
|
||||
j.events = append(j.events, ev)
|
||||
if ev.Type == "done" {
|
||||
j.done = true
|
||||
}
|
||||
for ch := range j.waiters {
|
||||
ch <- ev
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
func (j *Job) Snapshot() []JobEvent {
|
||||
j.mu.Lock()
|
||||
out := append([]JobEvent(nil), j.events...)
|
||||
j.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (j *Job) Subscribe() chan JobEvent {
|
||||
ch := make(chan JobEvent, 32)
|
||||
j.mu.Lock()
|
||||
j.waiters[ch] = struct{}{}
|
||||
j.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (j *Job) Unsubscribe(ch chan JobEvent) {
|
||||
j.mu.Lock()
|
||||
delete(j.waiters, ch)
|
||||
j.mu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
|
||||
job := m.Get(id)
|
||||
if job == nil {
|
||||
return fmt.Errorf("任务不存在或已过期")
|
||||
}
|
||||
|
||||
ch := job.Subscribe()
|
||||
defer job.Unsubscribe(ch)
|
||||
|
||||
for _, ev := range job.Snapshot() {
|
||||
if err := writeJobSSE(w, ev); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Type == "done" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case ev := <-ch:
|
||||
if err := writeJobSSE(w, ev); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Type == "done" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJobSSE(w io.Writer, ev JobEvent) error {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
|
||||
if err != nil {
|
||||
return JobDonePayload{OK: false, Error: err.Error()}
|
||||
}
|
||||
payload := JobDonePayload{
|
||||
OK: true,
|
||||
Domain: rec.Domain,
|
||||
CertPath: rec.CertPath,
|
||||
KeyPath: rec.KeyPath,
|
||||
}
|
||||
if rec.CertPath != "" {
|
||||
payload.CertDir = filepath.Dir(rec.CertPath)
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
legocert "github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
store *certstore.Store
|
||||
ddnsSvc *ddns.Service
|
||||
settings *settings.Store
|
||||
proxySvc *service.ProxyService
|
||||
logger *slog.Logger
|
||||
notify *notify.Service
|
||||
jobs *JobManager
|
||||
|
||||
expiryAlertMu sync.Mutex
|
||||
expiryAlerted map[string]string
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
ddnsSvc: ddnsSvc,
|
||||
settings: settings,
|
||||
proxySvc: proxySvc,
|
||||
logger: logger.With("module", "ACME"),
|
||||
notify: notifySvc,
|
||||
jobs: NewJobManager(),
|
||||
expiryAlerted: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
|
||||
if ddnsConfigID <= 0 {
|
||||
return "", fmt.Errorf("请选择 DNS 任务")
|
||||
}
|
||||
domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
job := s.jobs.Create()
|
||||
go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
|
||||
return job.ID(), nil
|
||||
}
|
||||
|
||||
func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
|
||||
return s.jobs.Stream(ctx, w, jobID, flush)
|
||||
}
|
||||
|
||||
func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
|
||||
domains, err := NormalizeCertDomains(domains)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
primary := PrimaryCertDomain(domains)
|
||||
if primary == "" {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
email, err = s.resolveACMEEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := ValidateCADomains(ca, domains); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
|
||||
cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := validateDNSCredentials(provider, cred); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
dnsZone := cfg.RootDomain
|
||||
zones := cfg.ManagedDNSZones()
|
||||
if len(zones) > 0 {
|
||||
dnsZone = zones[0]
|
||||
}
|
||||
return domains, ca, email, primary, dnsZone, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
|
||||
domains, err := NormalizeCertDomains(domains)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
|
||||
ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
primary := PrimaryCertDomain(domains)
|
||||
if primary == "" {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
email, err = s.resolveACMEEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := ValidateCADomains(ca, domains); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
return domains, ca, email, primary, dnsZone, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
|
||||
ctx := context.Background()
|
||||
job.Info("开始申请证书…")
|
||||
job.Info(fmt.Sprintf("主域名: %s", primary))
|
||||
job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
|
||||
job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
|
||||
|
||||
rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
|
||||
if err != nil {
|
||||
s.markCertError(ctx, primary, domains, ca, err)
|
||||
job.Error(err.Error())
|
||||
job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
|
||||
return
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
|
||||
job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
job.Warn("Nginx 重载失败: " + err.Error())
|
||||
} else {
|
||||
job.Info("Nginx 已重载")
|
||||
}
|
||||
job.Info("证书申请完成")
|
||||
job.Finish(jobDoneFromRecord(rec, nil))
|
||||
}
|
||||
|
||||
func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
|
||||
domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
|
||||
if err != nil {
|
||||
s.markCertError(ctx, primary, domains, ca, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
|
||||
s.logger.Warn("save acme ca setting failed", "error", err.Error())
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
|
||||
}
|
||||
_ = rec
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
|
||||
certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("证书:%w", err)
|
||||
}
|
||||
keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("私钥:%w", err)
|
||||
}
|
||||
|
||||
info, err := certstore.InspectImport(certBytes, keyBytes)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if time.Now().After(info.ExpiresAt) {
|
||||
status = "expired"
|
||||
}
|
||||
|
||||
domainsJSON := EncodeCertDomains(info.Domains)
|
||||
record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
|
||||
}
|
||||
s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, domain string) error {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("域名不能为空")
|
||||
}
|
||||
existing, err := s.store.GetByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
|
||||
s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
|
||||
}
|
||||
if err := s.store.Delete(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
|
||||
}
|
||||
s.logger.Info("certificate deleted", "domain", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
|
||||
rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
if rootDomain == "" {
|
||||
return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
|
||||
}
|
||||
existing, err := s.store.GetByDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if existing.ACMECA == "imported" {
|
||||
return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
|
||||
}
|
||||
if ca == "" {
|
||||
ca = existing.ACMECA
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
email, err := s.resolveACMEEmail(ctx, "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
domains := existing.Domains
|
||||
if len(domains) == 0 {
|
||||
domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
|
||||
}
|
||||
dnsZone, err := s.resolveDNSZone(ctx, domains)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
|
||||
s.markCertError(ctx, rootDomain, domains, ca, err)
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
|
||||
}
|
||||
rec, err := s.store.GetByDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if s.notify != nil {
|
||||
expires := "未知"
|
||||
if rec.ExpiresAt != nil {
|
||||
expires = rec.ExpiresAt.Local().Format("2006-01-02")
|
||||
}
|
||||
s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
|
||||
fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
records, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
|
||||
if err != nil || threshold <= 0 {
|
||||
threshold = 30
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.ExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
|
||||
s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
|
||||
}
|
||||
if rec.DaysLeft <= threshold {
|
||||
if rec.ACMECA == "imported" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
|
||||
s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
|
||||
if s.notify != nil {
|
||||
s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
|
||||
fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
|
||||
if s.notify == nil {
|
||||
return
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
s.expiryAlertMu.Lock()
|
||||
if s.expiryAlerted[domain] == today {
|
||||
s.expiryAlertMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.expiryAlerted[domain] = today
|
||||
s.expiryAlertMu.Unlock()
|
||||
|
||||
msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
|
||||
if daysLeft <= 0 {
|
||||
msg = fmt.Sprintf("%s 已到期或即将失效", domain)
|
||||
}
|
||||
s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
|
||||
}
|
||||
|
||||
func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
|
||||
_, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
|
||||
if upsertErr != nil {
|
||||
s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
|
||||
if strings.TrimSpace(ca) == "" {
|
||||
ca, err := s.settings.Get(ctx, settings.KeyACMECA)
|
||||
if err != nil || strings.TrimSpace(ca) == "" {
|
||||
ca = CALetsEncrypt
|
||||
}
|
||||
}
|
||||
ca = NormalizeCA(ca)
|
||||
if err := ValidateCA(ca); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
|
||||
reg, err := client.Registration.ResolveAccountByKey()
|
||||
if err == nil {
|
||||
return reg, nil
|
||||
}
|
||||
logStep("注册 ACME 账户…")
|
||||
switch NormalizeCA(ca) {
|
||||
case CAZeroSSL:
|
||||
apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||
TermsOfServiceAgreed: true,
|
||||
Kid: kid,
|
||||
HmacEncoded: hmac,
|
||||
})
|
||||
default:
|
||||
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
|
||||
logStep := func(msg string) {
|
||||
if job != nil {
|
||||
job.Info(msg)
|
||||
}
|
||||
}
|
||||
caDir, err := DirectoryURL(ca)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
|
||||
user, err := newUser(email)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
config := lego.NewConfig(user)
|
||||
config.CADirURL = caDir
|
||||
config.Certificate.KeyType = certcrypto.EC256
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
dnsProvider, err := newDNS01Provider(provider, cred)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
|
||||
}
|
||||
dnsProvider = wrapDNSProvider(dnsProvider, job)
|
||||
if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
logStep("检查 ACME 账户…")
|
||||
reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
|
||||
}
|
||||
user.registration = reg
|
||||
|
||||
logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
|
||||
request := legocert.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
}
|
||||
cert, err := client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
|
||||
}
|
||||
|
||||
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
logStep(fmt.Sprintf("证书已保存: %s", certPath))
|
||||
logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
|
||||
|
||||
expiresAt, err := parseCertExpiry(cert.Certificate)
|
||||
if err != nil {
|
||||
expiresAt = time.Now().Add(90 * 24 * time.Hour)
|
||||
}
|
||||
logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
|
||||
rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
|
||||
if err != nil || strings.TrimSpace(stored) == "" {
|
||||
return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
|
||||
}
|
||||
email = strings.TrimSpace(stored)
|
||||
}
|
||||
if err := validateACMEEmail(email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
|
||||
s.logger.Warn("save acme email failed", "error", err.Error())
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func validateACMEEmail(email string) error {
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email {
|
||||
return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
|
||||
for _, domain := range domains {
|
||||
cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
|
||||
if err == nil {
|
||||
return cfg.RootDomain, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
|
||||
}
|
||||
|
||||
func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
|
||||
provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
|
||||
}
|
||||
if err := validateDNSCredentials(provider, cred); err != nil {
|
||||
return "", ddns.Credentials{}, err
|
||||
}
|
||||
return provider, cred, nil
|
||||
}
|
||||
|
||||
func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
|
||||
names := certStorageNames(domains)
|
||||
if len(names) == 0 {
|
||||
return "", "", fmt.Errorf("证书域名不能为空")
|
||||
}
|
||||
var certPath, keyPath string
|
||||
for _, name := range names {
|
||||
dir := filepath.Join(certsDir, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
cp := filepath.Join(dir, "fullchain.pem")
|
||||
kp := filepath.Join(dir, "privatekey.pem")
|
||||
if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath, keyPath = cp, kp
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
func removeCertFiles(certsDir string, record certstore.Record) error {
|
||||
domains := record.Domains
|
||||
if len(domains) == 0 {
|
||||
domains = LegacyCertDomains(record.Domain, record.Wildcard)
|
||||
}
|
||||
var firstErr error
|
||||
for _, name := range certStorageNames(domains) {
|
||||
dir := filepath.Join(certsDir, name)
|
||||
if err := os.RemoveAll(dir); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func certStorageNames(domains []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var names []string
|
||||
for _, domain := range domains {
|
||||
name := domain
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
name = "wildcard." + strings.TrimPrefix(domain, "*.")
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func parseCertExpiry(pemBytes []byte) (time.Time, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return time.Time{}, fmt.Errorf("invalid pem")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return cert.NotAfter, nil
|
||||
}
|
||||
|
||||
type acmeUser struct {
|
||||
email string
|
||||
registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *acmeUser) GetEmail() string { return u.email }
|
||||
func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
|
||||
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func newUser(email string) (*acmeUser, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &acmeUser{email: email, key: privateKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
)
|
||||
|
||||
type volcengineDNS struct {
|
||||
provider *ddns.Volcengine
|
||||
cred ddns.Credentials
|
||||
}
|
||||
|
||||
func newVolcengineDNSProvider(cred ddns.Credentials) (challenge.Provider, error) {
|
||||
if err := cred.Validate("volcengine"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &volcengineDNS{
|
||||
provider: ddns.NewVolcengine(),
|
||||
cred: cred,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *volcengineDNS) Present(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
|
||||
return p.provider.UpsertTXTRecord(context.Background(), p.cred, fqdn, info.Value)
|
||||
}
|
||||
|
||||
func (p *volcengineDNS) CleanUp(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
|
||||
return p.provider.DeleteTXTRecord(context.Background(), p.cred, fqdn, info.Value)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type zeroSSLEABResponse struct {
|
||||
Success int `json:"success"`
|
||||
EABKid string `json:"eab_kid"`
|
||||
EABHmacKey string `json:"eab_hmac_key"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
|
||||
}
|
||||
|
||||
endpoint := "https://api.zerossl.com/acme/eab-credentials?access_key=" + url.QueryEscape(apiKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("ZeroSSL API 返回 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload zeroSSLEABResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", err)
|
||||
}
|
||||
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
|
||||
if payload.Error.Type != "" {
|
||||
return "", "", fmt.Errorf("ZeroSSL API 错误:%s", payload.Error.Type)
|
||||
}
|
||||
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
|
||||
}
|
||||
return payload.EABKid, payload.EABHmacKey, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
auth *auth.Service
|
||||
notify *notify.Service
|
||||
}
|
||||
|
||||
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
|
||||
return &AuthHandler{auth: authSvc, notify: notifySvc}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type authStatusResponse struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
initialized, err := h.auth.IsInitialized(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取初始化状态失败")
|
||||
return
|
||||
}
|
||||
authenticated := false
|
||||
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
||||
if err := h.auth.ValidateSession(r.Context(), sessionID); err == nil {
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authStatusResponse{
|
||||
Initialized: initialized,
|
||||
Authenticated: authenticated,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnauthorized) {
|
||||
if h.notify != nil {
|
||||
h.notify.RecordLoginFailure(r.Context(), clientIP(r))
|
||||
}
|
||||
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusInternalServerError, "登录失败")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
sessionID := sessionIDFromContext(r.Context())
|
||||
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||
return
|
||||
}
|
||||
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
||||
_ = h.auth.Logout(r.Context(), sessionID)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int((7 * 24 * time.Hour).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
func TestAuthSetupAndLoginFlow(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
authSvc := auth.New(conn)
|
||||
proxySvc := service.NewProxyService(config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil)
|
||||
handler := NewRouter(Deps{
|
||||
Config: config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"},
|
||||
Auth: authSvc,
|
||||
Proxy: proxySvc,
|
||||
Backup: backup.New(tmpDir),
|
||||
Discovery: discovery.New(),
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
|
||||
statusRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(statusRec, statusReq)
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("status code: %d", statusRec.Code)
|
||||
}
|
||||
|
||||
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
|
||||
t.Fatalf("setup admin: %v", err)
|
||||
}
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
})
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody))
|
||||
loginRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(loginRec, loginReq)
|
||||
if loginRec.Code != http.StatusOK {
|
||||
t.Fatalf("login status: %d body=%s", loginRec.Code, loginRec.Body.String())
|
||||
}
|
||||
|
||||
cookie := loginRec.Result().Cookies()[0]
|
||||
if cookie.Name != "fonu_session" || cookie.HttpOnly == false {
|
||||
t.Fatal("expected httponly session cookie")
|
||||
}
|
||||
|
||||
authStatusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
|
||||
authStatusReq.AddCookie(cookie)
|
||||
authStatusRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(authStatusRec, authStatusReq)
|
||||
if authStatusRec.Code != http.StatusOK {
|
||||
t.Fatalf("auth status code: %d", authStatusRec.Code)
|
||||
}
|
||||
var authStatus authStatusResponse
|
||||
if err := json.Unmarshal(authStatusRec.Body.Bytes(), &authStatus); err != nil {
|
||||
t.Fatalf("decode auth status: %v", err)
|
||||
}
|
||||
if !authStatus.Initialized || !authStatus.Authenticated {
|
||||
t.Fatalf("expected authenticated status, got %+v", authStatus)
|
||||
}
|
||||
|
||||
proxiesReq := httptest.NewRequest(http.MethodGet, "/api/proxies", nil)
|
||||
proxiesReq.AddCookie(cookie)
|
||||
proxiesRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(proxiesRec, proxiesReq)
|
||||
if proxiesRec.Code != http.StatusOK {
|
||||
t.Fatalf("proxies status: %d", proxiesRec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
svc *backup.Service
|
||||
}
|
||||
|
||||
func NewBackupHandler(svc *backup.Service) *BackupHandler {
|
||||
return &BackupHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Export(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "创建备份失败")
|
||||
return
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取备份失败")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
|
||||
_, _ = io.Copy(w, file)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "上传文件无效")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmpPath := filepath.Join(os.TempDir(), header.Filename)
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = os.Remove(tmpPath)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
)
|
||||
|
||||
type CertHandler struct {
|
||||
svc *acme.Service
|
||||
}
|
||||
|
||||
func NewCertHandler(svc *acme.Service) *CertHandler {
|
||||
return &CertHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取证书失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, records)
|
||||
}
|
||||
|
||||
func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, []map[string]string{
|
||||
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
|
||||
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
|
||||
{"value": acme.CABuypass, "label": acme.CALabel(acme.CABuypass)},
|
||||
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
DNSZone string `json:"dns_zone"`
|
||||
DDNSConfigID int64 `json:"ddns_config_id"`
|
||||
Domains []string `json:"domains"`
|
||||
CA string `json:"ca"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
domains := req.Domains
|
||||
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
||||
domains = []string{req.Domain}
|
||||
}
|
||||
if req.DDNSConfigID <= 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
|
||||
return
|
||||
}
|
||||
jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
|
||||
}
|
||||
|
||||
func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
|
||||
jobID := strings.TrimSpace(r.PathValue("id"))
|
||||
if jobID == "" {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
|
||||
return
|
||||
}
|
||||
if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Certificate string `json:"certificate"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
CertPath string `json:"cert_path"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, record)
|
||||
}
|
||||
|
||||
func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.TrimSpace(r.PathValue("domain"))
|
||||
if domain == "" {
|
||||
writeError(r, w, http.StatusBadRequest, "域名不能为空")
|
||||
return
|
||||
}
|
||||
part := r.URL.Query().Get("part")
|
||||
filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.TrimSpace(r.PathValue("domain"))
|
||||
if domain == "" {
|
||||
writeError(r, w, http.StatusBadRequest, "域名不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), domain); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
|
||||
}
|
||||
|
||||
func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
CA string `json:"ca"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, record)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
)
|
||||
|
||||
type ChinaCIDRHandler struct {
|
||||
svc *chinacidr.Service
|
||||
}
|
||||
|
||||
func NewChinaCIDRHandler(svc *chinacidr.Service) *ChinaCIDRHandler {
|
||||
return &ChinaCIDRHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *ChinaCIDRHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (h *ChinaCIDRHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := h.svc.Update(ctx); err != nil {
|
||||
h.svc.RecordLastError(ctx, err)
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
)
|
||||
|
||||
type DDNSHandler struct {
|
||||
svc *ddns.Service
|
||||
}
|
||||
|
||||
func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
|
||||
return &DDNSHandler{svc: svc}
|
||||
}
|
||||
|
||||
type ddnsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
RootDomain string `json:"root_domain"`
|
||||
RecordName string `json:"record_name"`
|
||||
RecordNames []string `json:"record_names"`
|
||||
Domains []string `json:"domains"`
|
||||
IPv4Enabled *bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled *bool `json:"ipv6_enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
CustomIPv4 string `json:"custom_ipv4"`
|
||||
CustomIPv6 string `json:"custom_ipv6"`
|
||||
APIToken string `json:"api_token"`
|
||||
APITokenID string `json:"api_token_id"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
|
||||
return ddns.SaveInput{
|
||||
Provider: req.Provider,
|
||||
RootDomain: req.RootDomain,
|
||||
RecordName: req.RecordName,
|
||||
RecordNames: req.RecordNames,
|
||||
Domains: req.Domains,
|
||||
IPv4Enabled: boolDefault(req.IPv4Enabled, true),
|
||||
IPv6Enabled: boolDefault(req.IPv6Enabled, false),
|
||||
Enabled: boolDefault(req.Enabled, true),
|
||||
CustomIPv4: req.CustomIPv4,
|
||||
CustomIPv6: req.CustomIPv6,
|
||||
APIToken: req.APIToken,
|
||||
APITokenID: req.APITokenID,
|
||||
APISecret: req.APISecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var configs []ddns.Config
|
||||
var err error
|
||||
if r.URL.Query().Get("lite") == "1" {
|
||||
configs, err = h.svc.ListLite(r.Context())
|
||||
} else {
|
||||
configs, err = h.svc.List(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取 DDNS 配置失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, configs)
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req ddnsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
cfg, err := h.svc.Create(r.Context(), h.toInput(req))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
|
||||
return
|
||||
}
|
||||
var req ddnsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
cfg, err := h.svc.Update(r.Context(), id, h.toInput(req))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ConfigID int64 `json:"config_id"`
|
||||
Provider string `json:"provider"`
|
||||
APIToken string `json:"api_token"`
|
||||
APITokenID string `json:"api_token_id"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := h.svc.Test(r.Context(), ddns.TestInput{
|
||||
ConfigID: req.ConfigID,
|
||||
Provider: req.Provider,
|
||||
APIToken: req.APIToken,
|
||||
APITokenID: req.APITokenID,
|
||||
APISecret: req.APISecret,
|
||||
}); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "连接成功"})
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) UpdateOne(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
|
||||
return
|
||||
}
|
||||
cfg, err := h.svc.UpdateNow(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) UpdateAll(w http.ResponseWriter, r *http.Request) {
|
||||
configs, err := h.svc.UpdateAll(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, configs)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
Auth *auth.Service
|
||||
Proxy *service.ProxyService
|
||||
DDNS *ddns.Service
|
||||
ACME *acme.Service
|
||||
Settings *settings.Store
|
||||
ChinaCIDR *chinacidr.Service
|
||||
Backup *backup.Service
|
||||
Discovery *discovery.Service
|
||||
FRPMulti *frp.Service
|
||||
Traffic *traffic.Collector
|
||||
Notify *notify.Service
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
)
|
||||
|
||||
type DiscoveryHandler struct {
|
||||
svc *discovery.Service
|
||||
}
|
||||
|
||||
func NewDiscoveryHandler(svc *discovery.Service) *DiscoveryHandler {
|
||||
return &DiscoveryHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *DiscoveryHandler) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
writeJSON(w, http.StatusOK, h.svc.Scan(r.Context(), host))
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
)
|
||||
|
||||
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
|
||||
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
|
||||
type FRPMultiHandler struct {
|
||||
svc *frp.Service
|
||||
}
|
||||
|
||||
type frpServerRequest struct {
|
||||
AgentURL string `json:"agent_url"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ClearAgentToken bool `json:"clear_agent_token"`
|
||||
SSHHost string `json:"ssh_host"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SSHAuth string `json:"ssh_auth"`
|
||||
SSHSecret string `json:"ssh_secret"`
|
||||
ClearSSHSecret bool `json:"clear_ssh_secret"`
|
||||
MgmtEnabled *bool `json:"mgmt_enabled"`
|
||||
Name string `json:"name"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
TLSEnabled *bool `json:"tls_enabled"`
|
||||
TLSServerName string `json:"tls_server_name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
ClearToken bool `json:"clear_token"`
|
||||
Options frp.ServerOptions `json:"options"`
|
||||
OIDCClientSecret string `json:"oidc_client_secret"`
|
||||
ClearOIDCClientSecret bool `json:"clear_oidc_client_secret"`
|
||||
DashboardPassword string `json:"dashboard_password"`
|
||||
ClearDashboardPassword bool `json:"clear_dashboard_password"`
|
||||
TLSCertificate string `json:"tls_certificate"`
|
||||
TLSKey string `json:"tls_key"`
|
||||
TLSTrustedCA string `json:"tls_trusted_ca"`
|
||||
ClearTLSCertificate bool `json:"clear_tls_certificate"`
|
||||
ClearTLSKey bool `json:"clear_tls_key"`
|
||||
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
|
||||
}
|
||||
|
||||
type frpProxyRequest struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"local_ip"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemotePort *int `json:"remote_port"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite"`
|
||||
Options frp.ProxyOptions `json:"options"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type frpDownloadProxyRequest struct {
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
|
||||
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
|
||||
return &FRPMultiHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Runtime(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.svc.Runtime(r.Context()))
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) SetDownloadProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var request frpDownloadProxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
runtime, err := h.svc.SetDownloadProxy(r.Context(), request.Proxy)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runtime)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Releases(w http.ResponseWriter, r *http.Request) {
|
||||
releases, err := h.svc.ListReleases(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, releases)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Binaries(w http.ResponseWriter, r *http.Request) {
|
||||
binaries, err := h.svc.ListBinaries()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取已下载 FRP 客户端失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, binaries)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DownloadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.svc.DownloadStatus())
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DownloadRelease(w http.ResponseWriter, r *http.Request) {
|
||||
version := r.PathValue("version")
|
||||
task, err := h.svc.StartDownload(version)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, task)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ActivateBinary(w http.ResponseWriter, r *http.Request) {
|
||||
version := r.PathValue("version")
|
||||
status, err := h.svc.ActivateBinary(r.Context(), version)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DeleteBinary(w http.ResponseWriter, r *http.Request) {
|
||||
version := r.PathValue("version")
|
||||
if err := h.svc.DeleteBinary(version); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ListServers(w http.ResponseWriter, r *http.Request) {
|
||||
servers, err := h.svc.ListServers(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取 FRP 服务端失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, servers)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ServerDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
diagnostics, err := h.svc.ServerDiagnostics(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, diagnostics)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ServerDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
detail, err := h.svc.ServerDetail(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) StartServer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
server, err := h.svc.StartServer(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) StopServer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
server, err := h.svc.StopServer(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) CreateServer(w http.ResponseWriter, r *http.Request) {
|
||||
request, ok := decodeFRPServerRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
server, err := h.svc.CreateServer(r.Context(), request.serverInput())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) UpdateServer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
request, ok := decodeFRPServerRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
server, err := h.svc.UpdateServer(r.Context(), id, request.serverInput())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DeleteServer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteServer(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentProbe(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.AgentProbe(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
server, err := h.svc.AgentInstall(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
keepData := r.URL.Query().Get("keep_data") == "true"
|
||||
output, err := h.svc.AgentUninstall(r.Context(), id, keepData)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentSSHDiagnose(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
output, err := h.svc.AgentSSHDiagnose(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"output": output})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AgentTest(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
status, err := h.svc.AgentStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentRouteCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
proxies, err := h.svc.ListAgentRouteCandidates(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxies)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DeployAgentRoute(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID, err := parseID(r.PathValue("proxyID"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeployAgentRoute(r.Context(), proxyID); err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) RemoveAgentRoute(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID, err := parseID(r.PathValue("proxyID"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.RemoveAgentRoute(r.Context(), proxyID); err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
// 路由状态明细在独立的 /api/v1/routes 端点(status 里的 routes 仍是字符串数组),
|
||||
// 从 agentClient 直取明细并透传。
|
||||
client, ok := h.svc.AgentClient(r.Context(), id)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
details, err := client.RoutesDetail(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, details)
|
||||
}
|
||||
|
||||
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
|
||||
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
proxyID, err := parseID(r.PathValue("proxyID"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
proxy, err := h.svc.GetProxy(r.Context(), proxyID)
|
||||
if err != nil || proxy.ServerID != id {
|
||||
writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
|
||||
return
|
||||
}
|
||||
if len(proxy.CustomDomains) == 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
|
||||
return
|
||||
}
|
||||
client, ok := h.svc.AgentClient(r.Context(), id)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
|
||||
}
|
||||
|
||||
// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
|
||||
func (h *FRPMultiHandler) AgentInstallNginx(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.InstallAgentNginx(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AgentRouteHealth 返回指定服务端公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
|
||||
func (h *FRPMultiHandler) AgentRouteHealth(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
health, err := h.svc.RouteHealth(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, health)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Version string `json:"version"`
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Version == "" {
|
||||
body.Version = "0.71.0"
|
||||
}
|
||||
result, err := h.svc.InstallFRPSOnAgent(r.Context(), id, body.Version, body.Proxy)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) PushFRPSConfigToAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.PushFRPSConfigToAgent(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) TestServer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Test(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "FRP 配置校验通过"})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ListProxies(w http.ResponseWriter, r *http.Request) {
|
||||
proxies, err := h.svc.ListProxies(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxies)
|
||||
}
|
||||
|
||||
// ListProxiesTraffic 返回各 frps 管理接口采集到的穿透规则流量(含速率)。
|
||||
func (h *FRPMultiHandler) ListProxiesTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
traffic, err := h.svc.ListProxiesTraffic(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则流量失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, traffic)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) CreateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
request, ok := decodeFRPProxyRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proxy, err := h.svc.CreateProxy(r.Context(), request.proxyInput())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxy)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) UpdateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
request, ok := decodeFRPProxyRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proxy, err := h.svc.UpdateProxy(r.Context(), id, request.proxyInput())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxy)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) DeleteProxy(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteProxy(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Start(r.Context()); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Stop(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Stop(r.Context()); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Reload(r.Context()); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
|
||||
lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取 FRP 日志失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lines)
|
||||
}
|
||||
|
||||
// ProxyLogs 返回单条穿透规则的日志(按规则名过滤所属服务端 frpc 日志)。
|
||||
func (h *FRPMultiHandler) ProxyLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
}
|
||||
lines, err := h.svc.ReadProxyLogs(r.Context(), id, queryInt(r, "limit", 200))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lines)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) ExportConfig(w http.ResponseWriter, r *http.Request) {
|
||||
content, err := h.svc.ExportMaskedConfig(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"content": content})
|
||||
}
|
||||
|
||||
// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(bindPort/auth/webServer 与 frpc 侧一致)。
|
||||
func (h *FRPMultiHandler) FRPSServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
content, err := h.svc.FRPSServerConfig(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"content": content})
|
||||
}
|
||||
|
||||
func decodeFRPServerRequest(w http.ResponseWriter, r *http.Request) (frpServerRequest, bool) {
|
||||
var request frpServerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return frpServerRequest{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func decodeFRPProxyRequest(w http.ResponseWriter, r *http.Request) (frpProxyRequest, bool) {
|
||||
var request frpProxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return frpProxyRequest{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func (request frpServerRequest) serverInput() frp.ServerInput {
|
||||
return frp.ServerInput{
|
||||
AgentURL: request.AgentURL, AgentToken: request.AgentToken, ClearAgentToken: request.ClearAgentToken,
|
||||
SSHHost: request.SSHHost, SSHPort: request.SSHPort, SSHUser: request.SSHUser, SSHAuth: request.SSHAuth,
|
||||
SSHSecret: request.SSHSecret, ClearSSHSecret: request.ClearSSHSecret, MgmtEnabled: boolDefault(request.MgmtEnabled, false),
|
||||
Name: request.Name, ServerAddr: request.ServerAddr, ServerPort: request.ServerPort,
|
||||
TLS: boolDefault(request.TLSEnabled, true), TLSServerName: request.TLSServerName,
|
||||
Enabled: boolDefault(request.Enabled, true), Token: request.Token, ClearToken: request.ClearToken, Options: request.Options,
|
||||
OIDCClientSecret: request.OIDCClientSecret, ClearOIDCClientSecret: request.ClearOIDCClientSecret,
|
||||
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
|
||||
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
|
||||
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
|
||||
}
|
||||
}
|
||||
|
||||
func (request frpProxyRequest) proxyInput() frp.ProxyInput {
|
||||
return frp.ProxyInput{
|
||||
ServerID: request.ServerID, Name: request.Name, Type: request.Type, LocalIP: request.LocalIP,
|
||||
LocalPort: request.LocalPort, RemotePort: request.RemotePort, CustomDomains: request.CustomDomains,
|
||||
HostHeaderRewrite: request.HostHeaderRewrite, Options: request.Options, Enabled: boolDefault(request.Enabled, true), Remark: request.Remark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
)
|
||||
|
||||
type LogsHandler struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func NewLogsHandler(cfg config.Config) *LogsHandler {
|
||||
return &LogsHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *LogsHandler) Access(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 100)
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
status := queryInt(r, "status", 0)
|
||||
entries, err := logstore.ReadAccess(filepath.Join(h.cfg.LogsDir(), "access.log"), limit, keyword, status)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取访问日志失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) Error(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 100)
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
lines, err := logstore.ReadError(filepath.Join(h.cfg.LogsDir(), "error.log"), limit, keyword)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取错误日志失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lines)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) System(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 100)
|
||||
level := r.URL.Query().Get("level")
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
entries, err := logstore.ReadSystem(filepath.Join(h.cfg.LogsDir(), "app.log"), limit, level, keyword)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取系统日志失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
kind := r.URL.Query().Get("type")
|
||||
if kind == "" {
|
||||
kind = "error"
|
||||
}
|
||||
var path string
|
||||
switch kind {
|
||||
case "access":
|
||||
path = filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||
case "error":
|
||||
path = filepath.Join(h.cfg.LogsDir(), "error.log")
|
||||
default:
|
||||
writeError(r, w, http.StatusBadRequest, "实时日志仅支持 Nginx 访问/错误日志")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
|
||||
return
|
||||
}
|
||||
tail := queryInt(r, "tail", 100)
|
||||
var filter logstore.LineFilter
|
||||
if kind == "access" {
|
||||
filter = logstore.AccessHostFilter(r.URL.Query()["host"])
|
||||
}
|
||||
_ = logstore.StreamFile(r.Context(), path, w, func() error {
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}, tail, filter)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) StreamAccessForHosts(w http.ResponseWriter, r *http.Request, hosts []string) {
|
||||
endpoints := make([]logstore.AccessEndpoint, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
endpoints = append(endpoints, logstore.AccessEndpoint{Host: host})
|
||||
}
|
||||
h.StreamAccessForEndpoints(w, r, endpoints)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) StreamAccessForEndpoints(w http.ResponseWriter, r *http.Request, endpoints []logstore.AccessEndpoint) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||
tail := queryInt(r, "tail", 100)
|
||||
filter := logstore.AccessEndpointFilter(endpoints)
|
||||
_ = logstore.StreamFile(r.Context(), path, w, func() error {
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}, tail, filter)
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
raw := r.URL.Query().Get(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
func TestLogsAccessRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
authSvc := auth.New(conn)
|
||||
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
sessionID, err := authSvc.Login(context.Background(), "admin", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
handler := NewRouter(Deps{
|
||||
Config: config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")},
|
||||
Auth: authSvc,
|
||||
Proxy: service.NewProxyService(config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil),
|
||||
Backup: backup.New(dir),
|
||||
Discovery: discovery.New(),
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "fonu_session", Value: sessionID})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const sessionContextKey contextKey = "session_id"
|
||||
|
||||
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("fonu_session")
|
||||
if err == nil && cookie.Value != "" {
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, _ := r.Context().Value(sessionContextKey).(string)
|
||||
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
|
||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sessionIDFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(sessionContextKey).(string)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
)
|
||||
|
||||
type NginxHandler struct {
|
||||
proxy *service.ProxyService
|
||||
}
|
||||
|
||||
func NewNginxHandler(proxy *service.ProxyService) *NginxHandler {
|
||||
return &NginxHandler{proxy: proxy}
|
||||
}
|
||||
|
||||
type saveRuleNginxRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
type rollbackNginxRequest struct {
|
||||
Backup string `json:"backup"`
|
||||
}
|
||||
|
||||
func (h *NginxHandler) GetRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
view, err := h.proxy.GetRuleNginx(r.Context(), id)
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) PutRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
var req saveRuleNginxRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
view, err := h.proxy.SaveRuleNginx(r.Context(), id, service.SaveRuleNginxInput{
|
||||
Mode: req.Mode,
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) RollbackRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
var req rollbackNginxRequest
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
view, err := h.proxy.RollbackRuleNginx(r.Context(), id, req.Backup)
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) GetGlobal(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.proxy.GetGlobalNginx(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) PutGlobal(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveRuleNginxRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
view, err := h.proxy.SaveGlobalNginx(r.Context(), service.SaveGlobalNginxInput{
|
||||
Mode: req.Mode,
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
|
||||
var req rollbackNginxRequest
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
view, err := h.proxy.RollbackGlobalNginx(r.Context(), req.Backup)
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if msg == "规则不存在" {
|
||||
writeError(r, w, http.StatusNotFound, msg, err)
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusBadRequest, msg, err)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
)
|
||||
|
||||
type NotifyHandler struct {
|
||||
notify *notify.Service
|
||||
}
|
||||
|
||||
func NewNotifyHandler(notifySvc *notify.Service) *NotifyHandler {
|
||||
return &NotifyHandler{notify: notifySvc}
|
||||
}
|
||||
|
||||
func (h *NotifyHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
if h.notify == nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "通知服务未初始化")
|
||||
return
|
||||
}
|
||||
var req notify.TestInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.notify.SendTest(r.Context(), req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "测试通知已发送"})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
)
|
||||
|
||||
type ProxyHandler struct {
|
||||
cfg config.Config
|
||||
svc *service.ProxyService
|
||||
logs *LogsHandler
|
||||
traffic *traffic.Collector
|
||||
}
|
||||
|
||||
func NewProxyHandler(cfg config.Config, svc *service.ProxyService, logs *LogsHandler, collector *traffic.Collector) *ProxyHandler {
|
||||
return &ProxyHandler{cfg: cfg, svc: svc, logs: logs, traffic: collector}
|
||||
}
|
||||
|
||||
type proxyRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
Upstream string `json:"upstream"`
|
||||
ListenPort *int `json:"listen_port"`
|
||||
ListenIPv4 *bool `json:"listen_ipv4"`
|
||||
ListenIPv6 *bool `json:"listen_ipv6"`
|
||||
Hosts []string `json:"hosts"`
|
||||
HTTPSEnabled *bool `json:"https_enabled"`
|
||||
HTTPRedirect *bool `json:"http_redirect"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name"`
|
||||
Remark *string `json:"remark"` // deprecated alias for name
|
||||
Security *securityRequest `json:"security,omitempty"`
|
||||
}
|
||||
|
||||
type proxyReorderRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxy.RulesForAPI(rules))
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req proxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
|
||||
hosts := req.hosts()
|
||||
if len(hosts) == 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "至少需要一个前端域名")
|
||||
return
|
||||
}
|
||||
|
||||
listenPort := intDefault(req.ListenPort, h.cfg.DefaultListenPort(boolDefault(req.HTTPSEnabled, true)))
|
||||
name := ""
|
||||
if resolved := req.resolveName(); resolved != nil {
|
||||
name = *resolved
|
||||
}
|
||||
in := proxy.CreateInput{
|
||||
Upstream: req.Upstream,
|
||||
ListenPort: listenPort,
|
||||
ListenIPv4: boolDefault(req.ListenIPv4, true),
|
||||
ListenIPv6: boolDefault(req.ListenIPv6, false),
|
||||
Hosts: hosts,
|
||||
HTTPSEnabled: boolDefault(req.HTTPSEnabled, true),
|
||||
HTTPRedirect: boolDefault(req.HTTPRedirect, true),
|
||||
Enabled: boolDefault(req.Enabled, true),
|
||||
Name: name,
|
||||
Security: req.securityInput(),
|
||||
}
|
||||
|
||||
rule, err := h.svc.Create(r.Context(), in)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, proxy.RuleForAPI(rule))
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req proxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
|
||||
in := proxy.UpdateInput{}
|
||||
if req.Upstream != "" {
|
||||
in.Upstream = &req.Upstream
|
||||
}
|
||||
if req.ListenPort != nil {
|
||||
in.ListenPort = req.ListenPort
|
||||
}
|
||||
if req.ListenIPv4 != nil {
|
||||
in.ListenIPv4 = req.ListenIPv4
|
||||
}
|
||||
if req.ListenIPv6 != nil {
|
||||
in.ListenIPv6 = req.ListenIPv6
|
||||
}
|
||||
if hosts := req.hosts(); len(hosts) > 0 {
|
||||
in.Hosts = &hosts
|
||||
}
|
||||
if req.HTTPSEnabled != nil {
|
||||
in.HTTPSEnabled = req.HTTPSEnabled
|
||||
}
|
||||
if req.HTTPRedirect != nil {
|
||||
in.HTTPRedirect = req.HTTPRedirect
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
in.Enabled = req.Enabled
|
||||
}
|
||||
if resolved := req.resolveName(); resolved != nil {
|
||||
in.Name = resolved
|
||||
}
|
||||
if req.Security != nil {
|
||||
sec := req.securityInput()
|
||||
in.Security = &sec
|
||||
}
|
||||
|
||||
rule, err := h.svc.Update(r.Context(), id, in)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, proxy.RuleForAPI(rule))
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Traffic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.traffic == nil {
|
||||
writeJSON(w, http.StatusOK, []traffic.RuleTraffic{})
|
||||
return
|
||||
}
|
||||
rules, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.traffic.SnapshotForRules(rules))
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Clients(w http.ResponseWriter, r *http.Request) {
|
||||
if h.traffic == nil {
|
||||
writeJSON(w, http.StatusOK, []traffic.ClientConn{})
|
||||
return
|
||||
}
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "规则不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.traffic.ClientsForRule(rule))
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "规则不存在")
|
||||
return
|
||||
}
|
||||
endpoints := rule.Endpoints()
|
||||
if len(endpoints) == 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "该规则没有前端域名")
|
||||
return
|
||||
}
|
||||
matches := make([]logstore.AccessEndpoint, 0, len(endpoints))
|
||||
for _, ep := range endpoints {
|
||||
matches = append(matches, logstore.AccessEndpoint{Host: ep.Hostname, Port: ep.Port})
|
||||
}
|
||||
h.logs.StreamAccessForEndpoints(w, r, matches)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Reorder(w http.ResponseWriter, r *http.Request) {
|
||||
var req proxyReorderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Reorder(r.Context(), req.IDs); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (req proxyRequest) resolveName() *string {
|
||||
if req.Name != nil {
|
||||
return req.Name
|
||||
}
|
||||
return req.Remark
|
||||
}
|
||||
|
||||
func (req proxyRequest) hosts() []string {
|
||||
if len(req.Hosts) > 0 {
|
||||
return req.Hosts
|
||||
}
|
||||
if req.Domain != "" {
|
||||
return []string{req.Domain}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intDefault(v *int, fallback int) int {
|
||||
if v == nil {
|
||||
return fallback
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func parseID(raw string) (int64, error) {
|
||||
return strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
|
||||
func boolDefault(v *bool, fallback bool) bool {
|
||||
if v == nil {
|
||||
return fallback
|
||||
}
|
||||
return *v
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
type securityRequest struct {
|
||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||
IPWhitelistMode *bool `json:"ip_whitelist_mode,omitempty"`
|
||||
IPBlacklistText *string `json:"ip_blacklist_text,omitempty"`
|
||||
IPWhitelistText *string `json:"ip_whitelist_text,omitempty"`
|
||||
ChinaOnly *bool `json:"china_only,omitempty"`
|
||||
BasicAuth *basicAuthRequest `json:"basic_auth,omitempty"`
|
||||
RateLimit *proxy.RateLimitConfig `json:"rate_limit,omitempty"`
|
||||
ConnLimit *proxy.ConnLimitConfig `json:"conn_limit,omitempty"`
|
||||
ProxySSLVerifyOff *bool `json:"proxy_ssl_verify_off,omitempty"`
|
||||
ProxyHostUpstream *bool `json:"proxy_host_upstream,omitempty"`
|
||||
TLSMin13Only *bool `json:"tls_min_13_only,omitempty"`
|
||||
SecurityHeaders *bool `json:"security_headers,omitempty"`
|
||||
}
|
||||
|
||||
type basicAuthRequest struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func (req *proxyRequest) securityInput() proxy.SecurityInput {
|
||||
if req.Security == nil {
|
||||
return proxy.SecurityInput{}
|
||||
}
|
||||
s := req.Security
|
||||
in := proxy.SecurityInput{
|
||||
IPBlacklist: s.IPBlacklist,
|
||||
IPWhitelist: s.IPWhitelist,
|
||||
IPWhitelistMode: s.IPWhitelistMode,
|
||||
ChinaOnly: s.ChinaOnly,
|
||||
RateLimit: s.RateLimit,
|
||||
ConnLimit: s.ConnLimit,
|
||||
ProxySSLVerifyOff: s.ProxySSLVerifyOff,
|
||||
ProxyHostUpstream: s.ProxyHostUpstream,
|
||||
TLSMin13Only: s.TLSMin13Only,
|
||||
SecurityHeaders: s.SecurityHeaders,
|
||||
}
|
||||
if s.IPBlacklistText != nil {
|
||||
in.IPBlacklist = proxy.ParseIPListText(*s.IPBlacklistText)
|
||||
}
|
||||
if s.IPWhitelistText != nil {
|
||||
in.IPWhitelist = proxy.ParseIPListText(*s.IPWhitelistText)
|
||||
}
|
||||
if s.BasicAuth != nil {
|
||||
in.BasicAuth = &proxy.BasicAuthInput{
|
||||
Enabled: s.BasicAuth.Enabled,
|
||||
Username: s.BasicAuth.Username,
|
||||
Password: s.BasicAuth.Password,
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if payload == nil {
|
||||
_, _ = w.Write([]byte("null"))
|
||||
return
|
||||
}
|
||||
// Go nil slices/maps encode as JSON null; clients expect [] / {}.
|
||||
v := reflect.ValueOf(payload)
|
||||
switch v.Kind() {
|
||||
case reflect.Slice:
|
||||
if v.IsNil() {
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
return
|
||||
}
|
||||
case reflect.Map:
|
||||
if v.IsNil() {
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(r *http.Request, w http.ResponseWriter, status int, message string, cause ...error) {
|
||||
logAPIError(r, status, message, cause)
|
||||
writeJSON(w, status, ErrorBody{Error: message})
|
||||
}
|
||||
|
||||
func logAPIError(r *http.Request, status int, message string, cause []error) {
|
||||
if status < 400 {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
"module", "API",
|
||||
"status", status,
|
||||
"message", message,
|
||||
}
|
||||
if r != nil {
|
||||
attrs = append(attrs, "method", r.Method, "path", r.URL.Path)
|
||||
}
|
||||
if len(cause) > 0 && cause[0] != nil {
|
||||
attrs = append(attrs, "error", cause[0].Error())
|
||||
}
|
||||
switch {
|
||||
case status >= 500:
|
||||
slog.Error("request failed", attrs...)
|
||||
default:
|
||||
slog.Warn("request rejected", attrs...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteJSONNilSlice(t *testing.T) {
|
||||
var items []string
|
||||
rec := httptest.NewRecorder()
|
||||
writeJSON(rec, http.StatusOK, items)
|
||||
|
||||
if rec.Body.String() != "[]" {
|
||||
t.Fatalf("expected [], got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONNilMap(t *testing.T) {
|
||||
var values map[string]string
|
||||
rec := httptest.NewRecorder()
|
||||
writeJSON(rec, http.StatusOK, values)
|
||||
|
||||
if rec.Body.String() != "{}" {
|
||||
t.Fatalf("expected {}, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONSlice(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeJSON(rec, http.StatusOK, []int{1, 2})
|
||||
|
||||
var out []int
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 items, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorLogsClientError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/settings/notify/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
writeError(req, rec, http.StatusBadRequest, "请填写 SMTP 主机")
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("请填写 SMTP 主机")) {
|
||||
t.Fatalf("expected log message, got %s", buf.String())
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("/api/settings/notify/test")) {
|
||||
t.Fatalf("expected log path, got %s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
|
||||
func NewRouter(deps Deps) http.Handler {
|
||||
logsHandler := NewLogsHandler(deps.Config)
|
||||
r := &Router{
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||
nginxHandler: NewNginxHandler(deps.Proxy),
|
||||
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
|
||||
ddnsHandler: NewDDNSHandler(deps.DDNS),
|
||||
certHandler: NewCertHandler(deps.ACME),
|
||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||
logsHandler: logsHandler,
|
||||
backupHandler: NewBackupHandler(deps.Backup),
|
||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
||||
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
|
||||
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
|
||||
|
||||
protect := func(pattern string, handler http.HandlerFunc) {
|
||||
mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
|
||||
}
|
||||
|
||||
protect("POST /api/auth/password", r.authHandler.ChangePassword)
|
||||
protect("GET /api/status", r.statusHandler.Get)
|
||||
protect("GET /api/proxies", r.proxyHandler.List)
|
||||
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
|
||||
protect("POST /api/proxies", r.proxyHandler.Create)
|
||||
protect("PUT /api/proxies/reorder", r.proxyHandler.Reorder)
|
||||
protect("PUT /api/proxies/{id}", r.proxyHandler.Update)
|
||||
protect("DELETE /api/proxies/{id}", r.proxyHandler.Delete)
|
||||
protect("GET /api/proxies/{id}/logs/stream", r.proxyHandler.StreamLogs)
|
||||
protect("GET /api/proxies/{id}/clients", r.proxyHandler.Clients)
|
||||
protect("GET /api/proxies/{id}/nginx", r.nginxHandler.GetRule)
|
||||
protect("PUT /api/proxies/{id}/nginx", r.nginxHandler.PutRule)
|
||||
protect("POST /api/proxies/{id}/nginx/rollback", r.nginxHandler.RollbackRule)
|
||||
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
|
||||
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
|
||||
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
|
||||
protect("GET /api/ddns", r.ddnsHandler.List)
|
||||
protect("POST /api/ddns", r.ddnsHandler.Create)
|
||||
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
|
||||
protect("DELETE /api/ddns/{id}", r.ddnsHandler.Delete)
|
||||
protect("POST /api/ddns/test", r.ddnsHandler.Test)
|
||||
protect("POST /api/ddns/update", r.ddnsHandler.UpdateAll)
|
||||
protect("POST /api/ddns/{id}/update", r.ddnsHandler.UpdateOne)
|
||||
protect("GET /api/certificates", r.certHandler.List)
|
||||
protect("GET /api/certificates/ca-options", r.certHandler.Options)
|
||||
protect("POST /api/certificates/apply", r.certHandler.Apply)
|
||||
protect("GET /api/certificates/jobs/{id}/stream", r.certHandler.ApplyJobStream)
|
||||
protect("POST /api/certificates/import", r.certHandler.Import)
|
||||
protect("POST /api/certificates/renew", r.certHandler.Renew)
|
||||
protect("GET /api/certificates/{domain}/download", r.certHandler.Download)
|
||||
protect("DELETE /api/certificates/{domain}", r.certHandler.Delete)
|
||||
notifyHandler := NewNotifyHandler(deps.Notify)
|
||||
protect("GET /api/settings", r.settingsHandler.Get)
|
||||
protect("PUT /api/settings", r.settingsHandler.Put)
|
||||
protect("POST /api/settings/notify/test", notifyHandler.Test)
|
||||
if deps.ChinaCIDR != nil {
|
||||
protect("GET /api/settings/china-cidr", r.chinaCIDRHandler.Status)
|
||||
protect("POST /api/settings/china-cidr/refresh", r.chinaCIDRHandler.Refresh)
|
||||
}
|
||||
protect("GET /api/logs/access", r.logsHandler.Access)
|
||||
protect("GET /api/logs/error", r.logsHandler.Error)
|
||||
protect("GET /api/logs/system", r.logsHandler.System)
|
||||
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
||||
protect("GET /api/backup/export", r.backupHandler.Export)
|
||||
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
||||
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
|
||||
if deps.FRPMulti != nil {
|
||||
frpHandler := r.frpmultiHandler
|
||||
protect("GET /api/frpmulti/status", frpHandler.Status)
|
||||
protect("GET /api/frpmulti/runtime", frpHandler.Runtime)
|
||||
protect("PUT /api/frpmulti/runtime/download-proxy", frpHandler.SetDownloadProxy)
|
||||
protect("GET /api/frpmulti/releases", frpHandler.Releases)
|
||||
protect("GET /api/frpmulti/binaries", frpHandler.Binaries)
|
||||
protect("GET /api/frpmulti/download", frpHandler.DownloadStatus)
|
||||
protect("POST /api/frpmulti/releases/{version}/download", frpHandler.DownloadRelease)
|
||||
protect("POST /api/frpmulti/binaries/{version}/activate", frpHandler.ActivateBinary)
|
||||
protect("DELETE /api/frpmulti/binaries/{version}", frpHandler.DeleteBinary)
|
||||
protect("GET /api/frpmulti/servers", frpHandler.ListServers)
|
||||
protect("GET /api/frpmulti/servers/{id}/detail", frpHandler.ServerDetail)
|
||||
protect("GET /api/frpmulti/servers/{id}/diagnostics", frpHandler.ServerDiagnostics)
|
||||
protect("GET /api/frpmulti/servers/{id}/frps-config", frpHandler.FRPSServerConfig)
|
||||
protect("POST /api/frpmulti/servers/{id}/start", frpHandler.StartServer)
|
||||
protect("POST /api/frpmulti/servers/{id}/stop", frpHandler.StopServer)
|
||||
protect("POST /api/frpmulti/servers", frpHandler.CreateServer)
|
||||
protect("PUT /api/frpmulti/servers/{id}", frpHandler.UpdateServer)
|
||||
protect("DELETE /api/frpmulti/servers/{id}", frpHandler.DeleteServer)
|
||||
protect("POST /api/frpmulti/servers/{id}/test", frpHandler.TestServer)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/probe", frpHandler.AgentProbe)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/install", frpHandler.AgentInstall)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
|
||||
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/frps-install", frpHandler.InstallFRPSOnAgent)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/frps-action", frpHandler.AgentFRPSAction)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.PushFRPSConfigToAgent)
|
||||
protect("GET /api/frpmulti/proxies", frpHandler.ListProxies)
|
||||
protect("GET /api/frpmulti/proxies/traffic", frpHandler.ListProxiesTraffic)
|
||||
protect("POST /api/frpmulti/proxies", frpHandler.CreateProxy)
|
||||
protect("PUT /api/frpmulti/proxies/{id}", frpHandler.UpdateProxy)
|
||||
protect("DELETE /api/frpmulti/proxies/{id}", frpHandler.DeleteProxy)
|
||||
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
|
||||
protect("POST /api/frpmulti/start", frpHandler.Start)
|
||||
protect("POST /api/frpmulti/stop", frpHandler.Stop)
|
||||
protect("POST /api/frpmulti/reload", frpHandler.Reload)
|
||||
protect("GET /api/frpmulti/logs", frpHandler.Logs)
|
||||
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
|
||||
}
|
||||
|
||||
if deps.StaticFS != nil {
|
||||
mux.Handle("/", r.spaHandler())
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
authHandler *AuthHandler
|
||||
proxyHandler *ProxyHandler
|
||||
nginxHandler *NginxHandler
|
||||
statusHandler *StatusHandler
|
||||
ddnsHandler *DDNSHandler
|
||||
certHandler *CertHandler
|
||||
settingsHandler *SettingsHandler
|
||||
chinaCIDRHandler *ChinaCIDRHandler
|
||||
logsHandler *LogsHandler
|
||||
backupHandler *BackupHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
|
||||
func (r *Router) spaHandler() http.Handler {
|
||||
fileServer := http.FileServer(http.FS(r.staticFS))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasPrefix(req.URL.Path, "/api/") {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(req.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(r.staticFS, path); err != nil {
|
||||
// SPA fallback: unknown routes serve index.html
|
||||
req.URL.Path = "/index.html"
|
||||
}
|
||||
fileServer.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") &&
|
||||
!strings.Contains(r.URL.Path, "/logs/stream") &&
|
||||
!strings.Contains(r.URL.Path, "/certificates/jobs/") &&
|
||||
rec.status >= 400 {
|
||||
level := slog.LevelWarn
|
||||
msg := "api request completed with client error"
|
||||
if rec.status >= 500 {
|
||||
level = slog.LevelError
|
||||
msg = "api request failed"
|
||||
}
|
||||
logger.Log(r.Context(), level, msg,
|
||||
"module", "HTTP",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
store *settings.Store
|
||||
proxy *service.ProxyService
|
||||
notify *notify.Service
|
||||
}
|
||||
|
||||
func NewSettingsHandler(store *settings.Store, proxySvc *service.ProxyService, notifySvc *notify.Service) *SettingsHandler {
|
||||
return &SettingsHandler{store: store, proxy: proxySvc, notify: notifySvc}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
settings.KeyTheme: true,
|
||||
settings.KeyTimezone: true,
|
||||
settings.KeyLogRetentionDays: true,
|
||||
settings.KeyDDNSCheckInterval: true,
|
||||
settings.KeyCertRenewThreshold: true,
|
||||
settings.KeyRootDomain: true,
|
||||
settings.KeyACMEEmail: true,
|
||||
settings.KeyACMECA: true,
|
||||
settings.KeyZeroSSLAPIKey: true,
|
||||
settings.KeyNotifyWebhookURL: true,
|
||||
settings.KeyNotifyType: true,
|
||||
settings.KeyNotifyEmailJSON: true,
|
||||
settings.KeyNotifySMTPPassword: true,
|
||||
settings.KeyNotifyWebhookJSON: true,
|
||||
settings.KeyNotifyWebhookSecret: true,
|
||||
settings.KeyNotifyTelegramJSON: true,
|
||||
settings.KeyNotifyTelegramToken: true,
|
||||
settings.KeyNotifyOnDDNSIPChange: true,
|
||||
settings.KeyNotifyOnDDNSFailure: true,
|
||||
settings.KeyNotifyOnCertExpiry: true,
|
||||
settings.KeyNotifyOnCertRenewSuccess: true,
|
||||
settings.KeyNotifyOnIPFrequentAccess: true,
|
||||
settings.KeyNotifyOnCertRenewFailure: true,
|
||||
settings.KeyNotifyOnLoginFailure: true,
|
||||
settings.KeyNotifyOnNginxReloadFailure: true,
|
||||
settings.KeyNotifyIPFrequentThreshold: true,
|
||||
settings.KeyNotifyIPFrequentWindowSec: true,
|
||||
settings.KeyNotifyLoginFailureThreshold: true,
|
||||
settings.KeyNotifyLoginFailureWindowSec: true,
|
||||
settings.KeyTrustedProxy: true,
|
||||
settings.KeyGlobalIPBlacklist: true,
|
||||
settings.KeyGlobalIPWhitelist: true,
|
||||
settings.KeyChinaCIDRUpdateHours: true,
|
||||
settings.KeyChinaCIDRSourceV4: true,
|
||||
settings.KeyChinaCIDRSourceV6: true,
|
||||
}
|
||||
filtered := map[string]string{}
|
||||
for key, value := range req {
|
||||
if !allowed[key] {
|
||||
continue
|
||||
}
|
||||
if err := h.validateSetting(key, value); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
if h.notify != nil && h.notify.Store() != nil {
|
||||
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
|
||||
if err := h.notify.Store().Save(r.Context(), in); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
for key := range filtered {
|
||||
if isNotifySettingKey(key) {
|
||||
delete(filtered, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.store.SetMany(r.Context(), filtered); err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
|
||||
return
|
||||
}
|
||||
if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
|
||||
if err := h.proxy.ReloadAll(r.Context()); err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
values, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
func isNotifySettingKey(key string) bool {
|
||||
switch key {
|
||||
case settings.KeyNotifyType,
|
||||
settings.KeyNotifyEmailJSON,
|
||||
settings.KeyNotifySMTPPassword,
|
||||
settings.KeyNotifyWebhookJSON,
|
||||
settings.KeyNotifyWebhookSecret,
|
||||
settings.KeyNotifyTelegramJSON,
|
||||
settings.KeyNotifyTelegramToken,
|
||||
settings.KeyNotifyOnDDNSIPChange,
|
||||
settings.KeyNotifyOnDDNSFailure,
|
||||
settings.KeyNotifyOnCertExpiry,
|
||||
settings.KeyNotifyOnCertRenewSuccess,
|
||||
settings.KeyNotifyOnIPFrequentAccess,
|
||||
settings.KeyNotifyOnCertRenewFailure,
|
||||
settings.KeyNotifyOnLoginFailure,
|
||||
settings.KeyNotifyOnNginxReloadFailure,
|
||||
settings.KeyNotifyIPFrequentThreshold,
|
||||
settings.KeyNotifyIPFrequentWindowSec,
|
||||
settings.KeyNotifyLoginFailureThreshold,
|
||||
settings.KeyNotifyLoginFailureWindowSec,
|
||||
settings.KeyNotifyWebhookURL:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) validateSetting(key, value string) error {
|
||||
switch key {
|
||||
case settings.KeyNotifyEmailJSON, settings.KeyNotifyWebhookJSON, settings.KeyNotifyTelegramJSON:
|
||||
if value != "" {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case settings.KeyNotifyType:
|
||||
switch value {
|
||||
case "", "email", "webhook", "telegram":
|
||||
default:
|
||||
return fmt.Errorf("无效的通知类型")
|
||||
}
|
||||
case settings.KeyNotifyIPFrequentThreshold:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 10 || n > 10000 {
|
||||
return fmt.Errorf("频繁访问阈值需在 10–10000 之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureWindowSec:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 10 || n > 3600 {
|
||||
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyNotifyLoginFailureThreshold:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 3 || n > 100 {
|
||||
return fmt.Errorf("登录失败阈值需在 3–100 之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyGlobalIPBlacklist, settings.KeyGlobalIPWhitelist:
|
||||
var list []string
|
||||
if value != "" && value != "[]" {
|
||||
if err := json.Unmarshal([]byte(value), &list); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.IPOrCIDRList(list); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case settings.KeyTrustedProxy:
|
||||
if value != "" && value != "{}" {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
)
|
||||
|
||||
type StatusHandler struct {
|
||||
cfg config.Config
|
||||
proxySvc *service.ProxyService
|
||||
ddnsSvc *ddns.Service
|
||||
acmeSvc *acme.Service
|
||||
startedAt string
|
||||
nginxPIDFile string
|
||||
}
|
||||
|
||||
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
||||
return &StatusHandler{
|
||||
cfg: cfg,
|
||||
proxySvc: proxySvc,
|
||||
ddnsSvc: ddnsSvc,
|
||||
acmeSvc: acmeSvc,
|
||||
startedAt: startedAt,
|
||||
nginxPIDFile: nginxPIDFile,
|
||||
}
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
PublicIPv4 string `json:"public_ipv4"`
|
||||
PublicIPv6 string `json:"public_ipv6"`
|
||||
PublicIPSource string `json:"public_ip_source"`
|
||||
DDNSStatus string `json:"ddns_status"`
|
||||
DDNSCount int `json:"ddns_count"`
|
||||
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
||||
CertificateStatus string `json:"certificate_status"`
|
||||
CertificateDays int `json:"certificate_days"`
|
||||
CertificateCount int `json:"certificate_count"`
|
||||
CertificateSummary string `json:"certificate_summary,omitempty"`
|
||||
ProxyCount int `json:"proxy_count"`
|
||||
NginxStatus string `json:"nginx_status"`
|
||||
RequestToday int `json:"request_today"`
|
||||
RequestYesterday int `json:"request_yesterday"`
|
||||
RequestTrend *int `json:"request_trend,omitempty"`
|
||||
RequestsHourly []int `json:"requests_hourly"`
|
||||
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
||||
ErrorToday int `json:"error_today"`
|
||||
AvgResponseMs float64 `json:"avg_response_ms"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := h.proxySvc.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
nginxStatus := "unknown"
|
||||
if _, err := os.Stat(h.nginxPIDFile); err == nil {
|
||||
nginxStatus = "running"
|
||||
} else if os.IsNotExist(err) {
|
||||
nginxStatus = "stopped"
|
||||
}
|
||||
|
||||
ddnsStatus, ddnsLastUpdated, ddnsCount := h.ddnsSvc.Summary(r.Context())
|
||||
ipv4, ipv6, ipSource := h.resolvePublicIPs(r.Context())
|
||||
|
||||
records, _ := h.acmeSvc.List(r.Context())
|
||||
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
||||
|
||||
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
|
||||
hourly, hourlyLabels := logstore.HourlyAccessCounts(accessLog)
|
||||
|
||||
started, _ := time.Parse(time.RFC3339, h.startedAt)
|
||||
uptime := time.Since(started).Seconds()
|
||||
|
||||
writeJSON(w, http.StatusOK, statusResponse{
|
||||
PublicIPv4: ipv4,
|
||||
PublicIPv6: ipv6,
|
||||
PublicIPSource: ipSource,
|
||||
DDNSStatus: ddnsStatus,
|
||||
DDNSCount: ddnsCount,
|
||||
DDNSLastUpdated: ddnsLastUpdated,
|
||||
CertificateStatus: certStatus,
|
||||
CertificateDays: certDays,
|
||||
CertificateCount: certCount,
|
||||
CertificateSummary: certSummary,
|
||||
ProxyCount: len(rules),
|
||||
NginxStatus: nginxStatus,
|
||||
RequestToday: total,
|
||||
RequestYesterday: yesterday,
|
||||
RequestTrend: requestTrendPercent(total, yesterday),
|
||||
RequestsHourly: hourly,
|
||||
RequestsHourlyLabels: hourlyLabels,
|
||||
ErrorToday: errors,
|
||||
AvgResponseMs: avgMs,
|
||||
StartedAt: h.startedAt,
|
||||
UptimeSeconds: int64(uptime),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
|
||||
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
||||
return ipv4, ipv6, "ddns"
|
||||
}
|
||||
if ipv4, ipv6, ok := h.ddnsSvc.LastKnownPublicIPs(ctx); ok {
|
||||
return ipv4, ipv6, "ddns"
|
||||
}
|
||||
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
ipv4, ipv6, err := publicip.Detect(detectCtx)
|
||||
if err != nil {
|
||||
return "", ipv6, "none"
|
||||
}
|
||||
return ipv4, ipv6, "detect"
|
||||
}
|
||||
|
||||
func summarizeCertificates(records []certstore.Record) (status string, days int, count int, summary string) {
|
||||
if len(records) == 0 {
|
||||
return "none", 0, 0, ""
|
||||
}
|
||||
count = len(records)
|
||||
status = "ok"
|
||||
days = records[0].DaysLeft
|
||||
for _, rec := range records {
|
||||
if rec.DaysLeft < days {
|
||||
days = rec.DaysLeft
|
||||
}
|
||||
switch {
|
||||
case rec.Status == "error" || rec.DaysLeft <= 0:
|
||||
status = "error"
|
||||
case rec.DaysLeft <= 30 && status != "error":
|
||||
status = "warning"
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
summary = certificateSummaryName(records[0])
|
||||
} else {
|
||||
summary = fmt.Sprintf("%d 张证书", count)
|
||||
}
|
||||
return status, days, count, summary
|
||||
}
|
||||
|
||||
func requestTrendPercent(today, yesterday int) *int {
|
||||
if yesterday <= 0 {
|
||||
if today <= 0 {
|
||||
return nil
|
||||
}
|
||||
v := 100
|
||||
return &v
|
||||
}
|
||||
v := int(math.Round(float64(today-yesterday) / float64(yesterday) * 100))
|
||||
return &v
|
||||
}
|
||||
|
||||
func certificateSummaryName(rec certstore.Record) string {
|
||||
for _, domain := range rec.Domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
if rec.Wildcard {
|
||||
return "*." + rec.Domain
|
||||
}
|
||||
return rec.Domain
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequestTrendPercent(t *testing.T) {
|
||||
if got := requestTrendPercent(0, 0); got != nil {
|
||||
t.Fatalf("expected nil trend, got %v", *got)
|
||||
}
|
||||
if got := requestTrendPercent(10, 0); got == nil || *got != 100 {
|
||||
t.Fatalf("expected 100 for new traffic, got %v", got)
|
||||
}
|
||||
if got := requestTrendPercent(150, 100); got == nil || *got != 50 {
|
||||
t.Fatalf("expected 50, got %v", got)
|
||||
}
|
||||
if got := requestTrendPercent(50, 100); got == nil || *got != -50 {
|
||||
t.Fatalf("expected -50, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/version"
|
||||
)
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
NginxHTTPPort int `json:"nginx_http_port"`
|
||||
NginxHTTPSPort int `json:"nginx_https_port"`
|
||||
}
|
||||
|
||||
func Version(cfg config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, versionResponse{
|
||||
Version: version.Version,
|
||||
NginxHTTPPort: cfg.NginxDefaultHTTPPort,
|
||||
NginxHTTPSPort: cfg.NginxDefaultHTTPSPort,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/version"
|
||||
)
|
||||
|
||||
func TestVersionEndpoint(t *testing.T) {
|
||||
version.Version = "0.1.6"
|
||||
cfg := config.Config{NginxDefaultHTTPPort: 18080, NginxDefaultHTTPSPort: 9443}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
Version(cfg)(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["version"] != "0.1.6" {
|
||||
t.Fatalf("version %q", body["version"])
|
||||
}
|
||||
if body["nginx_http_port"] != float64(18080) {
|
||||
t.Fatalf("nginx_http_port %v", body["nginx_http_port"])
|
||||
}
|
||||
if body["nginx_https_port"] != float64(9443) {
|
||||
t.Fatalf("nginx_https_port %v", body["nginx_https_port"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
"github.com/fonu/fonu/internal/api"
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/scheduler"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
server *http.Server
|
||||
nginx *nginx.Manager
|
||||
frpMulti *frp.Service
|
||||
scheduler *scheduler.Scheduler
|
||||
shutdownFn context.CancelFunc
|
||||
db interface{ Close() error }
|
||||
}
|
||||
|
||||
func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
|
||||
appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
|
||||
logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := db.Open(cfg.DBPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretBox, err := secret.NewBox(cfg.SessionSecret)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authSvc := auth.New(conn)
|
||||
if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
proxyStore := proxy.NewStore(conn)
|
||||
settingsStore := settings.NewStore(conn)
|
||||
ddnsStore := ddns.NewStore(conn)
|
||||
certStore := certificate.NewStore(conn)
|
||||
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
|
||||
if err := nginxMgr.EnsureDirs(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
|
||||
chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
|
||||
notifySvc := notify.New(settingsStore, secretBox, logger)
|
||||
proxySvc.SetNotify(notifySvc)
|
||||
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
|
||||
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
|
||||
backupSvc := backup.New(cfg.DataDir)
|
||||
discoverySvc := discovery.New()
|
||||
// 新增:多服务端 FRP 模块(接管原网关模式)
|
||||
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
|
||||
if err := frpMultiSvc.EnsureDirs(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
|
||||
handler := api.NewRouter(api.Deps{
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
Auth: authSvc,
|
||||
Proxy: proxySvc,
|
||||
DDNS: ddnsSvc,
|
||||
ACME: acmeSvc,
|
||||
Settings: settingsStore,
|
||||
Notify: notifySvc,
|
||||
ChinaCIDR: chinaCIDRSvc,
|
||||
Backup: backupSvc,
|
||||
Discovery: discoverySvc,
|
||||
FRPMulti: frpMultiSvc,
|
||||
Traffic: trafficCollector,
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 5
|
||||
}
|
||||
chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
|
||||
if chinaHours <= 0 {
|
||||
chinaHours = 24
|
||||
}
|
||||
sched := scheduler.New(
|
||||
scheduler.Job{
|
||||
Name: "ddns",
|
||||
Interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
Run: ddnsSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "acme",
|
||||
Interval: 24 * time.Hour,
|
||||
Run: acmeSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "china_cidr",
|
||||
Interval: time.Duration(chinaHours) * time.Hour,
|
||||
Run: chinaCIDRSvc.Tick,
|
||||
},
|
||||
)
|
||||
sched.Start(ctx)
|
||||
trafficCollector.Start(ctx)
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
server: server,
|
||||
nginx: nginxMgr,
|
||||
frpMulti: frpMultiSvc,
|
||||
scheduler: sched,
|
||||
shutdownFn: cancel,
|
||||
db: conn,
|
||||
}
|
||||
|
||||
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
|
||||
cancel()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
|
||||
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
|
||||
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
|
||||
logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
|
||||
if err := proxySvc.ReloadAll(ctx); err != nil {
|
||||
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
return a.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) error {
|
||||
a.logger.Info("application shutting down", "module", "SYSTEM")
|
||||
if a.shutdownFn != nil {
|
||||
a.shutdownFn()
|
||||
}
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nginx.Stop(ctx); err != nil {
|
||||
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
|
||||
}
|
||||
if a.frpMulti != nil {
|
||||
if err := a.frpMulti.Stop(ctx); err != nil {
|
||||
a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
}
|
||||
if a.db != nil {
|
||||
return a.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveMigrationsDir() (string, error) {
|
||||
candidates := []string{
|
||||
"migrations",
|
||||
filepath.Join("..", "migrations"),
|
||||
filepath.Join("..", "..", "migrations"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if info, err := os.Stat(c); err == nil && info.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("migrations directory not found")
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
)
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
var ErrNotInitialized = errors.New("not initialized")
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
|
||||
func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
|
||||
initialized, err := s.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
password, err := generatePassword(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info(
|
||||
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password", password,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Setup(ctx context.Context, username, password string) error {
|
||||
if err := validate.Username(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.Password(password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initialized, err := s.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return fmt.Errorf("管理员已初始化")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
|
||||
var adminID int
|
||||
var hash string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return s.createSession(ctx, adminID)
|
||||
}
|
||||
|
||||
func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
|
||||
if sessionID == "" {
|
||||
return 0, ErrUnauthorized
|
||||
}
|
||||
var adminID int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrUnauthorized
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return adminID, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error {
|
||||
if err := validate.Password(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
var hash string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
|
||||
return fmt.Errorf("当前密码不正确")
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
var expiresAt string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return err
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if time.Now().After(expires) {
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
|
||||
id, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generatePassword(length int) (string, error) {
|
||||
const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
if length < 8 {
|
||||
length = 8
|
||||
}
|
||||
out := make([]byte, length)
|
||||
randBytes := make([]byte, length)
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = letters[int(randBytes[i])%len(letters)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
)
|
||||
|
||||
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
svc := New(conn)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("first bootstrap: %v", err)
|
||||
}
|
||||
initialized, err := svc.IsInitialized(context.Background())
|
||||
if err != nil || !initialized {
|
||||
t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
|
||||
}
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
|
||||
t.Fatalf("expected unauthorized for wrong password, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func New(dataDir string) *Service {
|
||||
return &Service{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *Service) Export(ctx context.Context) (string, error) {
|
||||
tmpDir := filepath.Join(s.dataDir, "backups")
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("fonu-backup-%s.tar.gz", time.Now().Format("20060102-His"))
|
||||
outPath := filepath.Join(tmpDir, name)
|
||||
file, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gw := gzip.NewWriter(file)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
entries := []string{
|
||||
"fonu.db",
|
||||
"certs",
|
||||
"nginx",
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
_ = os.Remove(outPath)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gr, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无效的备份文件")
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(s.dataDir, header.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
|
||||
return fmt.Errorf("备份条目非法")
|
||||
}
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
|
||||
path := filepath.Join(baseDir, rel)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return addDir(ctx, tw, baseDir, rel)
|
||||
}
|
||||
return addFile(tw, baseDir, rel, info)
|
||||
}
|
||||
|
||||
func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
|
||||
root := filepath.Join(baseDir, rel)
|
||||
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return addFile(tw, baseDir, relPath, info)
|
||||
})
|
||||
}
|
||||
|
||||
func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
|
||||
path := filepath.Join(baseDir, rel)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(rel)
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tw, file)
|
||||
return err
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package chinacidr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloadDefaultV6Sources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
lines, err := downloadFromSources(ctx, defaultV6URLs...)
|
||||
if err != nil {
|
||||
t.Fatalf("download v6: %v", err)
|
||||
}
|
||||
if len(lines) < 10 {
|
||||
t.Fatalf("expected at least 10 v6 entries, got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhfastProxyURL(t *testing.T) {
|
||||
raw := "https://raw.githubusercontent.com/foo/bar.txt"
|
||||
got := ghfastProxyURL(raw)
|
||||
want := ghfastProxyPrefix + raw
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if ghfastProxyURL(want) != "" {
|
||||
t.Fatal("expected no double proxy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package chinacidr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
|
||||
ghfastProxyPrefix = "https://ghfast.top/"
|
||||
)
|
||||
|
||||
// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
|
||||
var defaultV6URLs = []string{
|
||||
"https://gaoyifan.github.io/china-operator-ip/china6.txt",
|
||||
"https://raw.githubusercontent.com/gaoyifan/china-operator-ip/china6.txt",
|
||||
"https://raw.githubusercontent.com/carrnot/china-ip-list/release/ipv6.txt",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
settings *settings.Store
|
||||
nginxMgr *nginx.Manager
|
||||
proxyStore *proxy.Store
|
||||
logger *slog.Logger
|
||||
updating atomic.Bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
EntryCountV4 int `json:"entry_count_v4"`
|
||||
EntryCountV6 int `json:"entry_count_v6"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Updating bool `json:"updating"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config, settingsStore *settings.Store, nginxMgr *nginx.Manager, proxyStore *proxy.Store, logger *slog.Logger) *Service {
|
||||
return &Service{cfg: cfg, settings: settingsStore, nginxMgr: nginxMgr, proxyStore: proxyStore, logger: logger}
|
||||
}
|
||||
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
if err := s.Update(ctx); err != nil {
|
||||
s.logger.Warn("china cidr update failed", "module", "CHINA_CIDR", "error", err.Error())
|
||||
s.RecordLastError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RecordLastError(ctx context.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, err.Error())
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context) error {
|
||||
if !s.updating.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("正在更新中,请稍后再试")
|
||||
}
|
||||
defer s.updating.Store(false)
|
||||
|
||||
v4URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV4)
|
||||
customV6URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV6)
|
||||
if isDeprecatedChinaCIDRSource(customV6URL) {
|
||||
customV6URL = ""
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRSourceV6, "")
|
||||
}
|
||||
|
||||
v4Lines, err := downloadFromSources(ctx, v4URL, defaultV4URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 IPv4 CIDR 失败: %w", err)
|
||||
}
|
||||
v6Sources := append([]string{customV6URL}, defaultV6URLs...)
|
||||
v6Lines, err := downloadFromSources(ctx, v6Sources...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 IPv6 CIDR 失败: %w", err)
|
||||
}
|
||||
|
||||
body, v4Count, v6Count, err := buildCIDRFile(v4Lines, v6Lines)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath := s.cfg.ChinaCIDRTempPath()
|
||||
if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, err := s.proxyStore.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := s.loadGenerateOptions(ctx)
|
||||
validateOpts := opts
|
||||
validateOpts.ChinaCIDRPathOverride = tmpPath
|
||||
validateOpts.ChinaCIDRAvailable = true
|
||||
if err := s.nginxMgr.ValidateOnlyWithOptions(ctx, rules, nil, validateOpts); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("nginx 校验失败: %w", err)
|
||||
}
|
||||
|
||||
finalPath := s.cfg.ChinaCIDRPath()
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.nginxMgr.Apply(ctx, rules, nil); err != nil {
|
||||
return fmt.Errorf("nginx 重载失败: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRUpdatedAt, now)
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV4, fmt.Sprintf("%d", v4Count))
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV6, fmt.Sprintf("%d", v6Count))
|
||||
_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, "")
|
||||
|
||||
s.logger.Info("china cidr updated", "module", "CHINA_CIDR", "v4", v4Count, "v6", v6Count)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Status(ctx context.Context) Status {
|
||||
updatedAt, _ := s.settings.Get(ctx, settings.KeyChinaCIDRUpdatedAt)
|
||||
v4, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV4)
|
||||
v6, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV6)
|
||||
lastErr, _ := s.settings.Get(ctx, settings.KeyChinaCIDRLastError)
|
||||
ready := updatedAt != "" && lastErr == "" && v4 > 0 && v6 > 0
|
||||
return Status{
|
||||
UpdatedAt: updatedAt,
|
||||
EntryCountV4: v4,
|
||||
EntryCountV6: v6,
|
||||
LastError: lastErr,
|
||||
Updating: s.updating.Load(),
|
||||
Ready: ready,
|
||||
}
|
||||
}
|
||||
|
||||
func isDeprecatedChinaCIDRSource(url string) bool {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(url, "metowolf/iplist") || strings.Contains(url, "iplist/meta")
|
||||
}
|
||||
|
||||
func ghfastProxyURL(origin string) string {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" || strings.HasPrefix(origin, ghfastProxyPrefix) {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
||||
return ""
|
||||
}
|
||||
return ghfastProxyPrefix + origin
|
||||
}
|
||||
|
||||
func expandDownloadSources(sources ...string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
add := func(url string) {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" || isDeprecatedChinaCIDRSource(url) {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[url]; ok {
|
||||
return
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
out = append(out, url)
|
||||
}
|
||||
for _, raw := range sources {
|
||||
add(raw)
|
||||
if proxy := ghfastProxyURL(raw); proxy != "" {
|
||||
add(proxy)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func downloadFromSources(ctx context.Context, sources ...string) ([]string, error) {
|
||||
var tried []string
|
||||
var errs []string
|
||||
for _, url := range expandDownloadSources(sources...) {
|
||||
tried = append(tried, url)
|
||||
lines, err := downloadLines(ctx, url)
|
||||
if err == nil {
|
||||
return lines, nil
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("%s (%v)", shortSourceURL(url), err))
|
||||
}
|
||||
if len(tried) == 0 {
|
||||
return nil, fmt.Errorf("无可用的下载源")
|
||||
}
|
||||
return nil, fmt.Errorf("已尝试 %d 个源均失败: %s", len(tried), strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func shortSourceURL(url string) string {
|
||||
if len(url) <= 72 {
|
||||
return url
|
||||
}
|
||||
return url[:69] + "..."
|
||||
}
|
||||
|
||||
func downloadLinesWithFallback(ctx context.Context, primary string, fallbacks ...string) ([]string, error) {
|
||||
return downloadFromSources(ctx, append([]string{primary}, fallbacks...)...)
|
||||
}
|
||||
|
||||
func downloadLines(ctx context.Context, url string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lines []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func buildCIDRFile(v4Lines, v6Lines []string) (string, int, int, error) {
|
||||
var b strings.Builder
|
||||
v4Count := 0
|
||||
v6Count := 0
|
||||
for _, line := range v4Lines {
|
||||
cidr, err := normalizeCIDR(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cidrTo4(cidr) == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(cidr + " 1;\n")
|
||||
v4Count++
|
||||
}
|
||||
for _, line := range v6Lines {
|
||||
cidr, err := normalizeCIDR(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cidrTo4(cidr) != nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString(cidr + " 1;\n")
|
||||
v6Count++
|
||||
}
|
||||
if v4Count < 100 {
|
||||
return "", 0, 0, fmt.Errorf("IPv4 条目过少 (%d),拒绝更新", v4Count)
|
||||
}
|
||||
if v6Count < 10 {
|
||||
return "", 0, 0, fmt.Errorf("IPv6 条目过少 (%d),拒绝更新", v6Count)
|
||||
}
|
||||
return b.String(), v4Count, v6Count, nil
|
||||
}
|
||||
|
||||
func normalizeCIDR(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.Contains(raw, "/") {
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4.String() + "/32", nil
|
||||
}
|
||||
return ip.String() + "/128", nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid")
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ipNet.String(), nil
|
||||
}
|
||||
|
||||
func cidrTo4(cidr string) net.IP {
|
||||
ip, _, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ip.To4()
|
||||
}
|
||||
|
||||
func (s *Service) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
|
||||
opts := nginx.GenerateOptions{}
|
||||
raw, _ := s.settings.Get(ctx, settings.KeyTrustedProxy)
|
||||
opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
|
||||
blRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
|
||||
opts.GlobalIPBlacklist = ParseGlobalIPList(blRaw)
|
||||
wlRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
|
||||
opts.GlobalIPWhitelist = ParseGlobalIPList(wlRaw)
|
||||
return opts
|
||||
}
|
||||
|
||||
func ParseGlobalIPList(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
NginxBin string
|
||||
NginxPIDFile string
|
||||
NginxMimeTypes string
|
||||
NginxDefaultHTTPPort int
|
||||
NginxDefaultHTTPSPort int
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
dataDir := envOr("FONU_DATA_DIR", defaultDataDir())
|
||||
if abs, err := filepath.Abs(dataDir); err == nil {
|
||||
dataDir = abs
|
||||
}
|
||||
return Config{
|
||||
ListenAddr: envOr("FONU_LISTEN", ":6893"),
|
||||
DataDir: dataDir,
|
||||
SessionSecret: envOr("FONU_SESSION_SECRET", "change-me-in-production"),
|
||||
NginxBin: envOr("FONU_NGINX_BIN", "nginx"),
|
||||
NginxPIDFile: envOr("FONU_NGINX_PID", dataDir+"/nginx/nginx.pid"),
|
||||
NginxMimeTypes: envOr("FONU_NGINX_MIME_TYPES", defaultMimeTypes()),
|
||||
NginxDefaultHTTPPort: envIntOr("FONU_NGINX_HTTP_PORT", 80),
|
||||
NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) DefaultListenPort(httpsEnabled bool) int {
|
||||
if httpsEnabled {
|
||||
return c.NginxDefaultHTTPSPort
|
||||
}
|
||||
return c.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (c Config) DBPath() string {
|
||||
return c.DataDir + "/fonu.db"
|
||||
}
|
||||
|
||||
func (c Config) NginxDir() string {
|
||||
return c.DataDir + "/nginx"
|
||||
}
|
||||
|
||||
func (c Config) NginxConfigPath() string {
|
||||
return c.NginxDir() + "/nginx.conf"
|
||||
}
|
||||
|
||||
func (c Config) NginxCustomDir() string {
|
||||
return c.NginxDir() + "/custom"
|
||||
}
|
||||
|
||||
func (c Config) NginxGlobalCustomPath() string {
|
||||
return c.NginxCustomDir() + "/global.conf"
|
||||
}
|
||||
|
||||
func (c Config) NginxRulesDir() string {
|
||||
return c.NginxDir() + "/rules"
|
||||
}
|
||||
|
||||
func (c Config) NginxRuleCustomPath(ruleID int64) string {
|
||||
return filepath.Join(c.NginxRulesDir(), fmt.Sprintf("rule_%d.conf", ruleID))
|
||||
}
|
||||
|
||||
func (c Config) NginxBackupsDir() string {
|
||||
return c.NginxDir() + "/backups"
|
||||
}
|
||||
|
||||
func (c Config) NginxGlobalBackupsDir() string {
|
||||
return c.NginxBackupsDir() + "/global"
|
||||
}
|
||||
|
||||
func (c Config) NginxRuleBackupsDir(ruleID int64) string {
|
||||
return filepath.Join(c.NginxBackupsDir(), "rules", fmt.Sprintf("%d", ruleID))
|
||||
}
|
||||
|
||||
func (c Config) LogsDir() string {
|
||||
return c.DataDir + "/logs"
|
||||
}
|
||||
|
||||
func (c Config) CertsDir() string {
|
||||
return c.DataDir + "/certs"
|
||||
}
|
||||
|
||||
func (c Config) ErrorsDir() string {
|
||||
return c.NginxDir() + "/errors"
|
||||
}
|
||||
|
||||
func (c Config) ChinaCIDRPath() string {
|
||||
return c.NginxDir() + "/china_cidr.conf"
|
||||
}
|
||||
|
||||
func (c Config) ChinaCIDRTempPath() string {
|
||||
return c.NginxDir() + "/china_cidr.conf.tmp"
|
||||
}
|
||||
|
||||
func (c Config) FrpDir() string {
|
||||
return c.DataDir + "/frp"
|
||||
}
|
||||
|
||||
func (c Config) FrpConfigPath() string {
|
||||
return c.FrpDir() + "/frpc.toml"
|
||||
}
|
||||
|
||||
func (c Config) FrpLogPath() string {
|
||||
return filepath.Join(c.LogsDir(), "frpc.log")
|
||||
}
|
||||
|
||||
func defaultDataDir() string {
|
||||
if _, err := os.Stat("/data"); err == nil {
|
||||
return "/data"
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Join(cwd, ".data")
|
||||
}
|
||||
return "/data"
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func defaultMimeTypes() string {
|
||||
candidates := []string{
|
||||
"/etc/nginx/mime.types",
|
||||
"deploy/nginx/mime.types",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "/etc/nginx/mime.types"
|
||||
}
|
||||
|
||||
func envIntOr(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadNginxDefaultPorts(t *testing.T) {
|
||||
t.Setenv("FONU_NGINX_HTTP_PORT", "18080")
|
||||
t.Setenv("FONU_NGINX_HTTPS_PORT", "9443")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 18080 {
|
||||
t.Fatalf("http port %d", cfg.NginxDefaultHTTPPort)
|
||||
}
|
||||
if cfg.NginxDefaultHTTPSPort != 9443 {
|
||||
t.Fatalf("https port %d", cfg.NginxDefaultHTTPSPort)
|
||||
}
|
||||
if cfg.DefaultListenPort(false) != 18080 {
|
||||
t.Fatalf("default http listen %d", cfg.DefaultListenPort(false))
|
||||
}
|
||||
if cfg.DefaultListenPort(true) != 9443 {
|
||||
t.Fatalf("default https listen %d", cfg.DefaultListenPort(true))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
||||
os.Unsetenv("FONU_NGINX_HTTP_PORT")
|
||||
os.Unsetenv("FONU_NGINX_HTTPS_PORT")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 80 || cfg.NginxDefaultHTTPSPort != 443 {
|
||||
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, conn *sql.DB, migrationsDir string) error {
|
||||
if err := ensureMigrationsTable(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
version, err := parseVersion(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied, err := isApplied(ctx, conn, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(migrationsDir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("migration %s failed: %w", name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES (?)`, version); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %s failed: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureMigrationsTable(ctx context.Context, conn *sql.DB) error {
|
||||
_, err := conn.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseVersion(name string) (int, error) {
|
||||
parts := strings.SplitN(name, "_", 2)
|
||||
if len(parts) == 0 {
|
||||
return 0, fmt.Errorf("invalid migration filename: %s", name)
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &version); err != nil {
|
||||
return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func isApplied(ctx context.Context, conn *sql.DB, version int) (bool, error) {
|
||||
var count int
|
||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := Open(filepath.Join(dir, "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
migrationsDir := filepath.Join("..", "..", "migrations")
|
||||
if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
|
||||
t.Fatalf("second migrate failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alidnsEndpoint = "https://alidns.aliyuncs.com/"
|
||||
|
||||
type AliDNS struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewAliDNS() *AliDNS {
|
||||
return &AliDNS{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (a *AliDNS) Name() string { return "alidns" }
|
||||
|
||||
func (a *AliDNS) Verify(ctx context.Context, cred Credentials) error {
|
||||
_, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomains",
|
||||
"PageNumber": "1",
|
||||
"PageSize": "1",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AliDNS) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomains",
|
||||
"KeyWord": zone,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
ok, lookupErr := zoneLookupOK(err)
|
||||
return ok, lookupErr
|
||||
}
|
||||
var resp alidnsDomainsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
zone = strings.ToLower(strings.TrimSpace(zone))
|
||||
for _, item := range resp.Domains.Domain {
|
||||
if strings.EqualFold(item.DomainName, zone) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *AliDNS) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
subDomain := aliRR(recordName)
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomainRecords",
|
||||
"DomainName": rootDomain,
|
||||
"RRKeyWord": subDomain,
|
||||
"Type": recordType,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp alidnsRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range resp.DomainRecords.Record {
|
||||
if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
|
||||
return rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a *AliDNS) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
subDomain := aliRR(recordName)
|
||||
existingID, err := a.findRecordID(ctx, cred, rootDomain, subDomain, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingID == "" {
|
||||
_, err = a.request(ctx, cred, map[string]string{
|
||||
"Action": "AddDomainRecord",
|
||||
"DomainName": rootDomain,
|
||||
"RR": subDomain,
|
||||
"Type": recordType,
|
||||
"Value": ip,
|
||||
"TTL": "600",
|
||||
})
|
||||
return err
|
||||
}
|
||||
_, err = a.request(ctx, cred, map[string]string{
|
||||
"Action": "UpdateDomainRecord",
|
||||
"RecordId": existingID,
|
||||
"RR": subDomain,
|
||||
"Type": recordType,
|
||||
"Value": ip,
|
||||
"TTL": "600",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AliDNS) findRecordID(ctx context.Context, cred Credentials, rootDomain, subDomain, recordType string) (string, error) {
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomainRecords",
|
||||
"DomainName": rootDomain,
|
||||
"RRKeyWord": subDomain,
|
||||
"Type": recordType,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp alidnsRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range resp.DomainRecords.Record {
|
||||
if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
|
||||
return rec.RecordID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func aliRR(recordName string) string {
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
if subDomain == "@" {
|
||||
return ""
|
||||
}
|
||||
return subDomain
|
||||
}
|
||||
|
||||
func (a *AliDNS) request(ctx context.Context, cred Credentials, actionParams map[string]string) ([]byte, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("阿里云 DNS 凭证未配置")
|
||||
}
|
||||
query := map[string]string{
|
||||
"Format": "JSON",
|
||||
"Version": "2015-01-09",
|
||||
"AccessKeyId": cred.Token,
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"SignatureVersion": "1.0",
|
||||
"SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
}
|
||||
for k, v := range actionParams {
|
||||
query[k] = v
|
||||
}
|
||||
query["Signature"] = signAliyun(query, cred.Secret)
|
||||
|
||||
values := url.Values{}
|
||||
for k, v := range query {
|
||||
values.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, alidnsEndpoint+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope alidnsEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Code != "" {
|
||||
return nil, fmt.Errorf("阿里云 DNS API 错误:%s", envelope.Message)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func signAliyun(params map[string]string, secret string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if k == "Signature" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var canonical strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
canonical.WriteByte('&')
|
||||
}
|
||||
canonical.WriteString(percentEncode(k))
|
||||
canonical.WriteByte('=')
|
||||
canonical.WriteString(percentEncode(params[k]))
|
||||
}
|
||||
stringToSign := "GET&%2F&" + percentEncode(canonical.String())
|
||||
mac := hmac.New(sha1.New, []byte(secret+"&"))
|
||||
mac.Write([]byte(stringToSign))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func percentEncode(s string) string {
|
||||
encoded := url.QueryEscape(s)
|
||||
encoded = strings.ReplaceAll(encoded, "+", "%20")
|
||||
encoded = strings.ReplaceAll(encoded, "*", "%2A")
|
||||
encoded = strings.ReplaceAll(encoded, "%7E", "~")
|
||||
return encoded
|
||||
}
|
||||
|
||||
type alidnsEnvelope struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
}
|
||||
|
||||
type alidnsRecordsResponse struct {
|
||||
DomainRecords struct {
|
||||
Record []struct {
|
||||
RecordID string `json:"RecordId"`
|
||||
RR string `json:"RR"`
|
||||
Type string `json:"Type"`
|
||||
Value string `json:"Value"`
|
||||
} `json:"Record"`
|
||||
} `json:"DomainRecords"`
|
||||
}
|
||||
|
||||
type alidnsDomainsResponse struct {
|
||||
Domains struct {
|
||||
Domain []struct {
|
||||
DomainName string `json:"DomainName"`
|
||||
} `json:"Domain"`
|
||||
} `json:"Domains"`
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cloudflare struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCloudflare() *Cloudflare {
|
||||
return &Cloudflare{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Cloudflare) Name() string { return "cloudflare" }
|
||||
|
||||
func (c *Cloudflare) Verify(ctx context.Context, cred Credentials) error {
|
||||
_, err := c.request(ctx, cred.Token, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Cloudflare) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
_, err := c.zoneID(ctx, cred.Token, zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (c *Cloudflare) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fqdn := fqdnForRecord(rootDomain, recordName)
|
||||
body, err := c.request(ctx, cred.Token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp cfRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return resp.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fqdn := fqdnForRecord(rootDomain, recordName)
|
||||
existingID, _, err := c.findRecord(ctx, cred.Token, zoneID, recordType, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": recordType,
|
||||
"name": fqdn,
|
||||
"content": ip,
|
||||
"ttl": 120,
|
||||
"proxied": false,
|
||||
}
|
||||
if existingID == "" {
|
||||
_, err = c.request(ctx, cred.Token, http.MethodPost, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), payload)
|
||||
return err
|
||||
}
|
||||
_, err = c.request(ctx, cred.Token, http.MethodPut, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, existingID), payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Cloudflare) zoneID(ctx context.Context, token, rootDomain string) (string, error) {
|
||||
body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", rootDomain), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp cfZonesResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", fmt.Errorf("未找到域名 %s 对应的 Cloudflare Zone", rootDomain)
|
||||
}
|
||||
return resp.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) findRecord(ctx context.Context, token, zoneID, recordType, fqdn string) (string, string, error) {
|
||||
body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var resp cfRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
return resp.Result[0].ID, resp.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) request(ctx context.Context, token, method, url string, payload any) ([]byte, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope cfEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !envelope.Success {
|
||||
if len(envelope.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Cloudflare API 错误:%s", envelope.Errors[0].Message)
|
||||
}
|
||||
return nil, fmt.Errorf("Cloudflare API 请求失败")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
type cfEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type cfZonesResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type cfRecordsResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Credentials struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TokenID string `json:"token_id,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
func CredentialsFromSave(in SaveInput) Credentials {
|
||||
provider := strings.TrimSpace(in.Provider)
|
||||
if provider == "" {
|
||||
provider = "cloudflare"
|
||||
}
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
return Credentials{Provider: "dnspod", TokenID: strings.TrimSpace(in.APITokenID), Token: strings.TrimSpace(in.APIToken)}
|
||||
case "alidns":
|
||||
return Credentials{Provider: "alidns", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
case "tencentcloud":
|
||||
return Credentials{Provider: "tencentcloud", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
case "volcengine":
|
||||
return Credentials{Provider: "volcengine", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
default:
|
||||
return Credentials{Provider: "cloudflare", Token: strings.TrimSpace(in.APIToken)}
|
||||
}
|
||||
}
|
||||
|
||||
func (c Credentials) HasValues() bool {
|
||||
switch c.Provider {
|
||||
case "dnspod":
|
||||
return c.TokenID != "" && c.Token != ""
|
||||
case "alidns", "tencentcloud", "volcengine":
|
||||
return c.Token != "" && c.Secret != ""
|
||||
default:
|
||||
return c.Token != ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c Credentials) Validate(provider string) error {
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
if c.TokenID == "" || c.Token == "" {
|
||||
return fmt.Errorf("请填写 DNSPod ID 和 Token")
|
||||
}
|
||||
case "alidns":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写阿里云 AccessKey ID 和 Secret")
|
||||
}
|
||||
case "tencentcloud":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写腾讯云 SecretId 和 SecretKey")
|
||||
}
|
||||
case "volcengine":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写火山引擎 AccessKey ID 和 Secret Access Key")
|
||||
}
|
||||
default:
|
||||
if c.Token == "" {
|
||||
return fmt.Errorf("请填写 API Token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Credentials) LoginToken() string {
|
||||
return c.TokenID + "," + c.Token
|
||||
}
|
||||
|
||||
func (c Credentials) Marshal() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func ParseCredentials(raw string) (Credentials, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Credentials{}, fmt.Errorf("凭证未配置")
|
||||
}
|
||||
if strings.HasPrefix(raw, "{") {
|
||||
var cred Credentials
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
if cred.Provider == "" {
|
||||
cred.Provider = "cloudflare"
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
return Credentials{Provider: "cloudflare", Token: raw}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCredentialsLegacyToken(t *testing.T) {
|
||||
cred, err := ParseCredentials("cf-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred.Provider != "cloudflare" || cred.Token != "cf-token" {
|
||||
t.Fatalf("unexpected cred: %+v", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsLoginToken(t *testing.T) {
|
||||
cred := Credentials{TokenID: "123", Token: "abc"}
|
||||
if cred.LoginToken() != "123,abc" {
|
||||
t.Fatal("login token mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFqdnForRecord(t *testing.T) {
|
||||
if fqdnForRecord("example.com", "*") != "*.example.com" {
|
||||
t.Fatal("wildcard fqdn mismatch")
|
||||
}
|
||||
if fqdnForRecord("example.com", "@") != "example.com" {
|
||||
t.Fatal("apex fqdn mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nrdcg/dnspod-go"
|
||||
)
|
||||
|
||||
type DNSPod struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDNSPod() *DNSPod {
|
||||
return &DNSPod{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (d *DNSPod) Name() string { return "dnspod" }
|
||||
|
||||
func (d *DNSPod) Verify(ctx context.Context, cred Credentials) error {
|
||||
client := d.apiClient(cred)
|
||||
_, _, err := client.Domains.List()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DNSPod) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
_, err := d.domainID(d.apiClient(cred), zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (d *DNSPod) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client := d.apiClient(cred)
|
||||
domainID, err := d.domainID(client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
records, _, err := client.Records.List(domainID, subDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if strings.EqualFold(rec.Type, recordType) {
|
||||
return rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (d *DNSPod) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client := d.apiClient(cred)
|
||||
domainID, err := d.domainID(client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
records, _, err := client.Records.List(domainID, subDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if strings.EqualFold(rec.Type, recordType) {
|
||||
_, _, err := client.Records.Update(domainID, rec.ID, dnspod.Record{
|
||||
Type: recordType,
|
||||
Name: subDomain,
|
||||
Value: ip,
|
||||
Line: rec.Line,
|
||||
TTL: rec.TTL,
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, _, err = client.Records.Create(domainID, dnspod.Record{
|
||||
Type: recordType,
|
||||
Name: subDomain,
|
||||
Value: ip,
|
||||
Line: "默认",
|
||||
TTL: strconv.Itoa(120),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DNSPod) domainID(client *dnspod.Client, rootDomain string) (string, error) {
|
||||
domains, _, err := client.Domains.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if domain.Name == rootDomain {
|
||||
return domain.ID.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到域名 %s 对应的 DNSPod 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (d *DNSPod) apiClient(cred Credentials) *dnspod.Client {
|
||||
params := dnspod.CommonParams{LoginToken: cred.LoginToken(), Format: "json"}
|
||||
client := dnspod.NewClient(params)
|
||||
client.HTTPClient = d.client
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ddns
|
||||
|
||||
import "strings"
|
||||
|
||||
func fqdnForRecord(rootDomain, recordName string) string {
|
||||
recordName = strings.TrimSpace(recordName)
|
||||
if recordName == "" || recordName == "@" {
|
||||
return rootDomain
|
||||
}
|
||||
if strings.Contains(recordName, ".") {
|
||||
return recordName
|
||||
}
|
||||
if recordName == "*" {
|
||||
return "*." + rootDomain
|
||||
}
|
||||
return recordName + "." + rootDomain
|
||||
}
|
||||
|
||||
func subDomainForRecord(recordName string) string {
|
||||
recordName = strings.TrimSpace(recordName)
|
||||
if recordName == "" || recordName == "@" {
|
||||
return "@"
|
||||
}
|
||||
return recordName
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DomainRecord struct {
|
||||
Domain string `json:"domain"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func EncodeDomainRecords(records []DomainRecord) string {
|
||||
if len(records) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(records)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeDomainRecords(raw string) []DomainRecord {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var records []DomainRecord
|
||||
if err := json.Unmarshal([]byte(raw), &records); err != nil {
|
||||
return nil
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func BuildDomainRecords(cfg Config) []DomainRecord {
|
||||
stored := cfg.DomainRecords
|
||||
byDomain := make(map[string]DomainRecord, len(stored))
|
||||
for _, rec := range stored {
|
||||
byDomain[strings.ToLower(rec.Domain)] = rec
|
||||
}
|
||||
|
||||
lines := cfg.DomainLines()
|
||||
out := make([]DomainRecord, 0, len(lines))
|
||||
for _, domain := range lines {
|
||||
key := strings.ToLower(domain)
|
||||
if rec, ok := byDomain[key]; ok {
|
||||
rec.Domain = domain
|
||||
out = append(out, rec)
|
||||
continue
|
||||
}
|
||||
rec := DomainRecord{Domain: domain, Status: "unknown"}
|
||||
if cfg.LastStatus != "" && cfg.LastStatus != "unknown" {
|
||||
rec.Status = cfg.LastStatus
|
||||
rec.Message = cfg.LastError
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestManagedDNSZonesMultiRoot(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "roven.cc",
|
||||
RecordNames: []string{"roven.cc", "chiak.cc"},
|
||||
}
|
||||
zones := cfg.ManagedDNSZones()
|
||||
if len(zones) != 2 {
|
||||
t.Fatalf("expected 2 zones, got %v", zones)
|
||||
}
|
||||
if !cfg.CoversDomain("chiak.cc") || !cfg.CoversDomain("*.chiak.cc") {
|
||||
t.Fatal("expected chiak.cc coverage")
|
||||
}
|
||||
if cfg.CoversDomain("other.com") {
|
||||
t.Fatal("unexpected coverage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ddns
|
||||
|
||||
import "context"
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Verify(ctx context.Context, cred Credentials) error
|
||||
HasZone(ctx context.Context, cred Credentials, zone string) (bool, error)
|
||||
UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error
|
||||
GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateDomainFormat(domain string) error {
|
||||
domain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(domain, ".")))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("域名不能为空")
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
domain = strings.TrimPrefix(domain, "*.")
|
||||
}
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("请填写完整域名,例如 s.example.com")
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return fmt.Errorf("域名格式无效")
|
||||
}
|
||||
if len(part) > 63 {
|
||||
return fmt.Errorf("域名标签过长")
|
||||
}
|
||||
for i := 0; i < len(part); i++ {
|
||||
c := part[i]
|
||||
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
|
||||
return fmt.Errorf("域名包含非法字符")
|
||||
}
|
||||
}
|
||||
if part[0] == '-' || part[len(part)-1] == '-' {
|
||||
return fmt.Errorf("域名标签不能以连字符开头或结尾")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FQDNsFromSaveInput(in SaveInput) ([]string, error) {
|
||||
var names []string
|
||||
var err error
|
||||
if len(in.Domains) > 0 {
|
||||
_, names, err = ParseDomainLines(in.Domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
root := strings.ToLower(strings.TrimSpace(in.RootDomain))
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
recordNames, err := ParseRecordNames(in.RecordNames, in.RecordName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = FormatDomainLines(root, recordNames)
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := ValidateDomainFormat(name); err != nil {
|
||||
return nil, fmt.Errorf("%s:%s", name, err.Error())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func ParseDomainLines(lines []string) (rootDomain string, recordNames []string, err error) {
|
||||
seen := make(map[string]struct{})
|
||||
names := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
root, record, lineErr := parseDomainLine(line)
|
||||
if lineErr != nil {
|
||||
return "", nil, lineErr
|
||||
}
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
fqdn := FormatDomainLine(root, record)
|
||||
if _, ok := seen[fqdn]; ok {
|
||||
return "", nil, fmt.Errorf("域名 %s 重复", fqdn)
|
||||
}
|
||||
seen[fqdn] = struct{}{}
|
||||
if rootDomain == "" {
|
||||
rootDomain = root
|
||||
}
|
||||
names = append(names, fqdn)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", nil, fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
return rootDomain, names, nil
|
||||
}
|
||||
|
||||
func ResolveDomainTarget(configRoot, name string) (rootDomain, recordName string) {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if strings.Contains(name, ".") {
|
||||
if root, record, err := parseDomainLine(name); err == nil {
|
||||
return root, record
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(configRoot)), name
|
||||
}
|
||||
|
||||
func parseDomainLine(line string) (rootDomain, recordName string, err error) {
|
||||
line = strings.TrimSpace(strings.ToLower(line))
|
||||
if line == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
if idx := strings.Index(line, ":"); idx > 0 {
|
||||
host := line[:idx]
|
||||
if strings.Count(host, ":") <= 1 {
|
||||
line = host
|
||||
}
|
||||
}
|
||||
line = strings.TrimSuffix(line, ".")
|
||||
if strings.HasPrefix(line, "*.") {
|
||||
root := strings.TrimPrefix(line, "*.")
|
||||
if root == "" || !strings.Contains(root, ".") {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", line)
|
||||
}
|
||||
return root, "*", nil
|
||||
}
|
||||
parts := strings.Split(line, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", line)
|
||||
}
|
||||
rootDomain = parts[len(parts)-2] + "." + parts[len(parts)-1]
|
||||
if len(parts) == 2 {
|
||||
return rootDomain, "@", nil
|
||||
}
|
||||
recordName = strings.Join(parts[:len(parts)-2], ".")
|
||||
return rootDomain, recordName, nil
|
||||
}
|
||||
|
||||
func FormatDomainLine(rootDomain, recordName string) string {
|
||||
recordName = strings.TrimSpace(strings.ToLower(recordName))
|
||||
rootDomain = strings.TrimSpace(strings.ToLower(rootDomain))
|
||||
if strings.Contains(recordName, ".") {
|
||||
parts := strings.Split(recordName, ".")
|
||||
if len(parts) >= 3 {
|
||||
if root, record, err := parseDomainLine(recordName); err == nil {
|
||||
return joinDomainLine(root, record)
|
||||
}
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
if root, record, err := parseDomainLine(recordName); err == nil && record == "@" && recordName == root {
|
||||
if rootDomain == "" || rootDomain == root {
|
||||
return recordName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinDomainLine(rootDomain, recordName)
|
||||
}
|
||||
|
||||
// canonicalFQDN returns name when it is already a complete FQDN (e.g. api.nas.example.com).
|
||||
func canonicalFQDN(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(name, ".")))
|
||||
if name == "" || name == "@" || !strings.Contains(name, ".") {
|
||||
return ""
|
||||
}
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err != nil {
|
||||
return name
|
||||
}
|
||||
fqdn := joinDomainLine(root, record)
|
||||
if fqdn == name {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinDomainLine(rootDomain, recordName string) string {
|
||||
if recordName == "" || recordName == "@" {
|
||||
return rootDomain
|
||||
}
|
||||
if recordName == "*" {
|
||||
return "*." + rootDomain
|
||||
}
|
||||
return recordName + "." + rootDomain
|
||||
}
|
||||
|
||||
func FormatDomainLines(rootDomain string, recordNames []string) []string {
|
||||
out := make([]string, 0, len(recordNames))
|
||||
for _, name := range recordNames {
|
||||
if fqdn := canonicalFQDN(name); fqdn != "" {
|
||||
out = append(out, fqdn)
|
||||
continue
|
||||
}
|
||||
root, record := ResolveDomainTarget(rootDomain, name)
|
||||
out = append(out, FormatDomainLine(root, record))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseRecordNames(recordNames []string, legacy string) ([]string, error) {
|
||||
raw := recordNames
|
||||
if len(raw) == 0 && strings.TrimSpace(legacy) != "" {
|
||||
raw = strings.Split(legacy, "\n")
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("至少需要一个子域名")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
name := normalizeRecordName(item)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return nil, fmt.Errorf("子域名 %s 重复", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("至少需要一个子域名")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRecordName(raw string) string {
|
||||
name := strings.TrimSpace(strings.ToLower(raw))
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if name == "" || name == "@" {
|
||||
return "@"
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") {
|
||||
root := strings.TrimPrefix(name, "*.")
|
||||
if root != "" {
|
||||
return FormatDomainLine(root, "*")
|
||||
}
|
||||
return "*"
|
||||
}
|
||||
if strings.Contains(name, ".") {
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err == nil {
|
||||
return FormatDomainLine(root, record)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func EncodeRecordNames(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(names)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeRecordNames(raw string, fallback string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" && raw != "[]" {
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return []string{normalizeRecordName(fallback)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) RecordNamesList() []string {
|
||||
if len(c.RecordNames) > 0 {
|
||||
return c.RecordNames
|
||||
}
|
||||
if names := DecodeRecordNames("", c.RecordName); len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
return []string{"@"}
|
||||
}
|
||||
|
||||
func (c Config) DomainLines() []string {
|
||||
return FormatDomainLines(c.RootDomain, c.RecordNamesList())
|
||||
}
|
||||
|
||||
// ManagedDNSZones returns apex zones this DDNS credential can manage (for ACME DNS-01).
|
||||
func (c Config) ManagedDNSZones() []string {
|
||||
seen := map[string]bool{}
|
||||
var zones []string
|
||||
add := func(zone string) {
|
||||
zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
|
||||
if zone == "" || zone == "@" || seen[zone] {
|
||||
return
|
||||
}
|
||||
seen[zone] = true
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
add(c.RootDomain)
|
||||
for _, name := range c.RecordNamesList() {
|
||||
fqdn := FQDNFromRecord(c, name)
|
||||
add(fqdn)
|
||||
root, record := ResolveDomainTarget(c.RootDomain, name)
|
||||
add(root)
|
||||
if record != "@" && record != "*" {
|
||||
add(FormatDomainLine(root, record))
|
||||
}
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
func (c Config) CoversDomain(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
for _, zone := range c.ManagedDNSZones() {
|
||||
if domain == zone || strings.HasSuffix(domain, "."+zone) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDomainLinesMultiple(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"s.example.com", "www.example.com", "example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || len(names) != 3 || names[0] != "s.example.com" || names[1] != "www.example.com" || names[2] != "example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesAllowsMixedRoots(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"s.example.com", "api.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || len(names) != 2 || names[1] != "api.example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesWildcard(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"*.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || names[0] != "*.example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDomainLine(t *testing.T) {
|
||||
if FormatDomainLine("example.com", "s") != "s.example.com" {
|
||||
t.Fatal("subdomain format failed")
|
||||
}
|
||||
if FormatDomainLine("example.com", "@") != "example.com" {
|
||||
t.Fatal("apex format failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordNamesRejectsDuplicate(t *testing.T) {
|
||||
_, err := ParseRecordNames([]string{"s", "s"}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordNamesKeepsDelegatedSubdomainFQDN(t *testing.T) {
|
||||
names, err := ParseRecordNames([]string{"s", "www.sub.example.com"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if names[0] != "s" || names[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLinesMixedRootDomains(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "example.com",
|
||||
RecordNames: []string{"s", "www.sub.example.com"},
|
||||
}
|
||||
lines := cfg.DomainLines()
|
||||
if len(lines) != 2 || lines[0] != "s.example.com" || lines[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected lines: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNFromRecordDelegatedSubdomainHost(t *testing.T) {
|
||||
cfg := Config{RootDomain: "example.com"}
|
||||
if got := FQDNFromRecord(cfg, "www.sub.example.com"); got != "www.sub.example.com" {
|
||||
t.Fatalf("expected www.sub.example.com, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDomainLineMultiLabelHost(t *testing.T) {
|
||||
if got := FormatDomainLine("example.com", "api.nas"); got != "api.nas.example.com" {
|
||||
t.Fatalf("expected api.nas.example.com, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesDeepSubdomain(t *testing.T) {
|
||||
_, names, err := ParseDomainLines([]string{"api.nas.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if names[0] != "api.nas.example.com" {
|
||||
t.Fatalf("expected full fqdn, got %s", names[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLinesPreservesDeepSubdomain(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "example.com",
|
||||
RecordNames: []string{"s.example.com", "api.nas.example.com"},
|
||||
}
|
||||
lines := cfg.DomainLines()
|
||||
if len(lines) != 2 || lines[1] != "api.nas.example.com" {
|
||||
t.Fatalf("unexpected lines: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainFormat(t *testing.T) {
|
||||
if err := ValidateDomainFormat("s.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateDomainFormat("www.sub.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ValidateDomainFormat("s") == nil {
|
||||
t.Fatal("expected error for short domain")
|
||||
}
|
||||
if ValidateDomainFormat("bad_label-.com") == nil {
|
||||
t.Fatal("expected error for invalid label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNsFromSaveInput(t *testing.T) {
|
||||
names, err := FQDNsFromSaveInput(SaveInput{
|
||||
Domains: []string{"s.example.com", "www.sub.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected names: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
settings *settings.Store
|
||||
secretBox *secret.Box
|
||||
providers map[string]Provider
|
||||
logger *slog.Logger
|
||||
notify *notify.Service
|
||||
}
|
||||
|
||||
func NewService(store *Store, settings *settings.Store, secretBox *secret.Box, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
settings: settings,
|
||||
secretBox: secretBox,
|
||||
providers: map[string]Provider{
|
||||
"cloudflare": NewCloudflare(),
|
||||
"dnspod": NewDNSPod(),
|
||||
"alidns": NewAliDNS(),
|
||||
"tencentcloud": NewTencentCloud(),
|
||||
"volcengine": NewVolcengine(),
|
||||
},
|
||||
logger: logger.With("module", "DDNS"),
|
||||
notify: notifySvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range configs {
|
||||
s.enrichDomainRecords(ctx, &configs[i])
|
||||
sanitizeConfig(&configs[i])
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListLite(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range configs {
|
||||
if len(configs[i].DomainRecords) == 0 {
|
||||
configs[i].DomainRecords = BuildDomainRecords(configs[i])
|
||||
}
|
||||
sanitizeConfig(&configs[i])
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id int64) (Config, error) {
|
||||
cfg, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
s.enrichDomainRecords(ctx, &cfg)
|
||||
sanitizeConfig(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForDomain(ctx context.Context, rootDomain string) (string, Credentials, error) {
|
||||
cfg, err := s.store.GetByRootDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return "", Credentials{}, err
|
||||
}
|
||||
return s.credentialsForConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForConfigID(ctx context.Context, id int64) (Config, string, Credentials, error) {
|
||||
cfg, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, "", Credentials{}, fmt.Errorf("未找到所选 DNS 配置")
|
||||
}
|
||||
provider, cred, err := s.credentialsForConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return Config{}, "", Credentials{}, err
|
||||
}
|
||||
return cfg, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigForDNSZone(ctx context.Context, dnsZone string) (Config, error) {
|
||||
cfg, err := s.store.GetByRootDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := requireEnabled(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigForAnyDomain(ctx context.Context, domain string) (Config, error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if cfg.CoversDomain(domain) {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", domain)
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForAnyDomain(ctx context.Context, domain string) (string, Credentials, error) {
|
||||
cfg, err := s.ConfigForAnyDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return "", Credentials{}, fmt.Errorf("请先在 DDNS 页面配置域名 %s 的 DNS 凭证", strings.TrimPrefix(domain, "*."))
|
||||
}
|
||||
return s.credentialsForConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func requireEnabled(cfg Config) error {
|
||||
if !cfg.Enabled {
|
||||
return fmt.Errorf("DNS 任务 %s 未启用,请先在 DDNS 页面开启", cfg.RootDomain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) credentialsForConfig(ctx context.Context, cfg Config) (string, Credentials, error) {
|
||||
if err := requireEnabled(cfg); err != nil {
|
||||
return cfg.Provider, Credentials{}, err
|
||||
}
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
return cfg.Provider, Credentials{}, err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
return cfg.Provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in SaveInput) (Config, error) {
|
||||
if err := validateSaveInput(in); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !in.HasCredentialUpdate() {
|
||||
return Config{}, fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
tokenEnc, err := s.encryptCredentials(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.store.Create(ctx, in, tokenEnc)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id int64, in SaveInput) (Config, error) {
|
||||
if err := validateSaveInput(in); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
existing, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
tokenEnc := ""
|
||||
updateToken := in.HasCredentialUpdate()
|
||||
if updateToken {
|
||||
tokenEnc, err = s.encryptCredentials(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
} else if !existing.HasToken {
|
||||
return Config{}, fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
return s.store.Update(ctx, id, in, tokenEnc, updateToken)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
ConfigID int64
|
||||
Provider string
|
||||
APIToken string
|
||||
APITokenID string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
func (s *Service) Test(ctx context.Context, in TestInput) error {
|
||||
providerName := strings.TrimSpace(in.Provider)
|
||||
cred := CredentialsFromSave(SaveInput{
|
||||
Provider: providerName,
|
||||
APIToken: in.APIToken,
|
||||
APITokenID: in.APITokenID,
|
||||
APISecret: in.APISecret,
|
||||
})
|
||||
|
||||
if cred.HasValues() {
|
||||
if providerName == "" {
|
||||
providerName = "cloudflare"
|
||||
}
|
||||
cred.Provider = providerName
|
||||
return s.providerFor(providerName).Verify(ctx, cred)
|
||||
}
|
||||
|
||||
if in.ConfigID > 0 {
|
||||
cfg, err := s.store.GetByID(ctx, in.ConfigID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cred, err = s.loadCredentialsByID(ctx, in.ConfigID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
return s.providerFor(cfg.Provider).Verify(ctx, cred)
|
||||
}
|
||||
|
||||
return fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
|
||||
func (s *Service) UpdateNow(ctx context.Context, id int64) (Config, error) {
|
||||
cfg, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return Config{}, fmt.Errorf("该 DDNS 配置未启用")
|
||||
}
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAll(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
var failCount int
|
||||
var runCount int
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
runCount++
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
lastErr = err
|
||||
failCount++
|
||||
s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
|
||||
}
|
||||
}
|
||||
list, listErr := s.List(ctx)
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
if failCount > 0 && failCount == runCount {
|
||||
return list, lastErr
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
|
||||
if s.notify != nil {
|
||||
s.notify.Alert(ctx, notify.EventDDNSFailure, "DDNS 更新失败", cfg.RootDomain+": "+err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PublicIPs returns the most recently synced public IPs from enabled DDNS configs.
|
||||
func (s *Service) PublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
|
||||
return s.latestStoredPublicIPs(ctx, true)
|
||||
}
|
||||
|
||||
// LastKnownPublicIPs returns stored public IPs from the most recently updated config,
|
||||
// including paused tasks. Used to avoid live IP detection when DDNS is temporarily disabled.
|
||||
func (s *Service) LastKnownPublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
|
||||
return s.latestStoredPublicIPs(ctx, false)
|
||||
}
|
||||
|
||||
func (s *Service) latestStoredPublicIPs(ctx context.Context, enabledOnly bool) (ipv4, ipv6 string, ok bool) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil || len(configs) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
var latest *Config
|
||||
for _, cfg := range configs {
|
||||
if enabledOnly && !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
|
||||
latest = &cfg
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return "", "", false
|
||||
}
|
||||
if latest.LastIPv4 == "" && latest.LastIPv6 == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return latest.LastIPv4, latest.LastIPv6, true
|
||||
}
|
||||
|
||||
func (s *Service) Summary(ctx context.Context) (status string, lastUpdated string, count int) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil || len(configs) == 0 {
|
||||
return "disabled", "", 0
|
||||
}
|
||||
count = len(configs)
|
||||
status = "ok"
|
||||
hasEnabled := false
|
||||
var latest *Config
|
||||
for _, cfg := range configs {
|
||||
if cfg.Enabled {
|
||||
hasEnabled = true
|
||||
}
|
||||
if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
|
||||
latest = &cfg
|
||||
}
|
||||
if cfg.LastStatus == "error" {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
if !hasEnabled {
|
||||
return "disabled", "", count
|
||||
}
|
||||
if latest != nil && latest.LastUpdatedAt != nil {
|
||||
lastUpdated = latest.LastUpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if status != "error" && latest != nil && latest.LastStatus != "" {
|
||||
status = latest.LastStatus
|
||||
}
|
||||
return status, lastUpdated, count
|
||||
}
|
||||
|
||||
func sanitizeConfig(cfg *Config) {
|
||||
if !publicip.IsPublicIPv4(cfg.LastIPv4) {
|
||||
cfg.LastIPv4 = ""
|
||||
}
|
||||
if !publicip.IsPublicIPv6(cfg.LastIPv6) {
|
||||
cfg.LastIPv6 = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) enrichDomainRecords(ctx context.Context, cfg *Config) {
|
||||
records := BuildDomainRecords(*cfg)
|
||||
if !cfg.HasToken || len(records) == 0 {
|
||||
cfg.DomainRecords = records
|
||||
return
|
||||
}
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
cfg.DomainRecords = records
|
||||
return
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
provider := s.providerFor(cfg.Provider)
|
||||
zoneCache := make(map[string]zoneHost)
|
||||
|
||||
for i := range records {
|
||||
prevIPv4 := records[i].IPv4
|
||||
prevIPv6 := records[i].IPv6
|
||||
prevStatus := records[i].Status
|
||||
prevMessage := records[i].Message
|
||||
|
||||
fqdn := FQDNFromRecord(*cfg, records[i].Domain)
|
||||
zone, record, err := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
|
||||
if err != nil {
|
||||
records[i].Domain = fqdn
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
continue
|
||||
}
|
||||
records[i].Domain = FormatDomainLine(zone, record)
|
||||
|
||||
if cfg.IPv4Enabled {
|
||||
ip, err := provider.GetRecordIP(ctx, cred, zone, record, "A")
|
||||
switch {
|
||||
case err != nil:
|
||||
records[i].IPv4 = prevIPv4
|
||||
if prevStatus == "" || prevStatus == "unknown" {
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
}
|
||||
case ip != "":
|
||||
records[i].IPv4 = ip
|
||||
if prevStatus == "" || prevStatus == "unknown" {
|
||||
records[i].Status = "unchanged"
|
||||
records[i].Message = "记录未变化"
|
||||
} else {
|
||||
records[i].Status = prevStatus
|
||||
records[i].Message = prevMessage
|
||||
}
|
||||
default:
|
||||
records[i].IPv4 = prevIPv4
|
||||
}
|
||||
}
|
||||
if cfg.IPv6Enabled {
|
||||
ip, err := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
|
||||
switch {
|
||||
case err != nil:
|
||||
records[i].IPv6 = prevIPv6
|
||||
if records[i].Status == "" || records[i].Status == "unknown" {
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
}
|
||||
case ip != "":
|
||||
records[i].IPv6 = ip
|
||||
default:
|
||||
records[i].IPv6 = prevIPv6
|
||||
}
|
||||
}
|
||||
if records[i].Status == "" {
|
||||
records[i].Status = "unknown"
|
||||
}
|
||||
}
|
||||
cfg.DomainRecords = records
|
||||
}
|
||||
|
||||
func (s *Service) runUpdate(ctx context.Context, cfg Config) error {
|
||||
prevIPv4 := cfg.LastIPv4
|
||||
prevIPv6 := cfg.LastIPv6
|
||||
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
provider := s.providerFor(cfg.Provider)
|
||||
|
||||
detectedIPv4, detectedIPv6, _ := publicip.Detect(ctx)
|
||||
if detectedIPv4 != "" && !publicip.IsPublicIPv4(detectedIPv4) {
|
||||
detectedIPv4 = ""
|
||||
}
|
||||
if detectedIPv6 != "" && !publicip.IsPublicIPv6(detectedIPv6) {
|
||||
detectedIPv6 = ""
|
||||
}
|
||||
// 自定义 IP 优先:填写后直接作为更新目标(如公网服务器 IP),不再依赖本机出口 IP 检测
|
||||
customIPv4 := strings.TrimSpace(cfg.CustomIPv4)
|
||||
customIPv6 := strings.TrimSpace(cfg.CustomIPv6)
|
||||
if customIPv4 != "" && !publicip.IsPublicIPv4(customIPv4) {
|
||||
msg := "自定义 IPv4 不是公网地址,已拒绝更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if customIPv6 != "" && !publicip.IsPublicIPv6(customIPv6) {
|
||||
msg := "自定义 IPv6 不是公网地址,已拒绝更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if customIPv4 != "" {
|
||||
detectedIPv4 = customIPv4
|
||||
}
|
||||
if customIPv6 != "" {
|
||||
detectedIPv6 = customIPv6
|
||||
}
|
||||
storeIPv4 := cfg.LastIPv4
|
||||
storeIPv6 := cfg.LastIPv6
|
||||
if !publicip.IsPublicIPv4(storeIPv4) {
|
||||
storeIPv4 = ""
|
||||
}
|
||||
if !publicip.IsPublicIPv6(storeIPv6) {
|
||||
storeIPv6 = ""
|
||||
}
|
||||
if cfg.IPv4Enabled && detectedIPv4 != "" {
|
||||
storeIPv4 = detectedIPv4
|
||||
}
|
||||
if cfg.IPv6Enabled && detectedIPv6 != "" {
|
||||
storeIPv6 = detectedIPv6
|
||||
}
|
||||
|
||||
if cfg.IPv4Enabled && detectedIPv4 == "" {
|
||||
msg := "无法获取公网 IPv4,已跳过更新(不会写入内网 IP)"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if cfg.IPv6Enabled && detectedIPv6 == "" {
|
||||
msg := "无法获取公网 IPv6,已跳过更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
|
||||
recordNames := cfg.RecordNamesList()
|
||||
domainRecords := make([]DomainRecord, 0, len(recordNames))
|
||||
var lastErr error
|
||||
var errCount, okCount int
|
||||
changed := false
|
||||
zoneCache := make(map[string]zoneHost)
|
||||
|
||||
for _, name := range recordNames {
|
||||
fqdn := FQDNFromRecord(cfg, name)
|
||||
zone, record, zoneErr := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
|
||||
if zoneErr != nil {
|
||||
dr := DomainRecord{Domain: fqdn, Status: "error", Message: zoneErr.Error()}
|
||||
domainRecords = append(domainRecords, dr)
|
||||
lastErr = zoneErr
|
||||
errCount++
|
||||
continue
|
||||
}
|
||||
fqdn = FormatDomainLine(zone, record)
|
||||
dr := DomainRecord{Domain: fqdn}
|
||||
|
||||
if cfg.IPv4Enabled && detectedIPv4 != "" {
|
||||
current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "A")
|
||||
if getErr != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = getErr.Error()
|
||||
lastErr = getErr
|
||||
domainRecords = append(domainRecords, dr)
|
||||
continue
|
||||
}
|
||||
dr.IPv4 = current
|
||||
if current == detectedIPv4 {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
} else if !publicip.IsPublicIPv4(detectedIPv4) {
|
||||
dr.Status = "error"
|
||||
dr.Message = "检测到内网 IPv4,拒绝更新"
|
||||
lastErr = fmt.Errorf("检测到内网 IPv4,拒绝更新")
|
||||
} else if err := provider.UpdateRecord(ctx, cred, zone, record, "A", detectedIPv4); err != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
dr.IPv4 = detectedIPv4
|
||||
dr.Status = "ok"
|
||||
dr.Message = "已更新"
|
||||
changed = true
|
||||
s.logger.Info("ddns ipv4 updated", "domain", fqdn, "ip", detectedIPv4)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.IPv6Enabled && detectedIPv6 != "" && dr.Status != "error" {
|
||||
current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
|
||||
if getErr != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = getErr.Error()
|
||||
lastErr = getErr
|
||||
} else {
|
||||
dr.IPv6 = current
|
||||
if current == detectedIPv6 {
|
||||
if dr.Status == "" {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
}
|
||||
} else if !publicip.IsPublicIPv6(detectedIPv6) {
|
||||
dr.Status = "error"
|
||||
dr.Message = "检测到内网 IPv6,拒绝更新"
|
||||
lastErr = fmt.Errorf("检测到内网 IPv6,拒绝更新")
|
||||
} else if err := provider.UpdateRecord(ctx, cred, zone, record, "AAAA", detectedIPv6); err != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
dr.IPv6 = detectedIPv6
|
||||
dr.Status = "ok"
|
||||
dr.Message = "已更新"
|
||||
changed = true
|
||||
s.logger.Info("ddns ipv6 updated", "domain", fqdn, "ip", detectedIPv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dr.Status == "" {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
}
|
||||
if dr.Status == "error" {
|
||||
errCount++
|
||||
} else {
|
||||
okCount++
|
||||
}
|
||||
domainRecords = append(domainRecords, dr)
|
||||
}
|
||||
|
||||
overallStatus := "ok"
|
||||
overallError := ""
|
||||
switch {
|
||||
case errCount > 0 && okCount == 0:
|
||||
overallStatus = "error"
|
||||
if lastErr != nil {
|
||||
overallError = lastErr.Error()
|
||||
}
|
||||
case errCount > 0:
|
||||
overallStatus = "warning"
|
||||
overallError = fmt.Sprintf("%d 个域名同步失败,其余正常", errCount)
|
||||
lastErr = nil
|
||||
}
|
||||
if changed {
|
||||
s.logger.Info("ddns records updated", "provider", cfg.Provider)
|
||||
if s.notify != nil {
|
||||
msg := formatDDNSIPChangeMessage(cfg.RootDomain, prevIPv4, prevIPv6, storeIPv4, storeIPv6)
|
||||
s.notify.Alert(ctx, notify.EventDDNSIPChange, "DDNS IP 已变更", msg)
|
||||
}
|
||||
}
|
||||
_ = s.store.UpdateSyncResult(ctx, cfg.ID, storeIPv4, storeIPv6, overallStatus, overallError, domainRecords)
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func formatDDNSIPChangeMessage(domain, prevIPv4, prevIPv6, newIPv4, newIPv6 string) string {
|
||||
var parts []string
|
||||
if newIPv4 != "" && newIPv4 != prevIPv4 {
|
||||
if prevIPv4 != "" {
|
||||
parts = append(parts, fmt.Sprintf("IPv4: %s → %s", prevIPv4, newIPv4))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("IPv4: %s", newIPv4))
|
||||
}
|
||||
}
|
||||
if newIPv6 != "" && newIPv6 != prevIPv6 {
|
||||
if prevIPv6 != "" {
|
||||
parts = append(parts, fmt.Sprintf("IPv6: %s → %s", prevIPv6, newIPv6))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("IPv6: %s", newIPv6))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return domain
|
||||
}
|
||||
return domain + "\n" + strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (s *Service) encryptCredentials(in SaveInput) (string, error) {
|
||||
cred := CredentialsFromSave(in)
|
||||
if err := cred.Validate(in.Provider); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := cred.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.secretBox.Encrypt(string(raw))
|
||||
}
|
||||
|
||||
func (s *Service) loadCredentialsByID(ctx context.Context, id int64) (Credentials, error) {
|
||||
enc, err := s.store.GetTokenByID(ctx, id)
|
||||
if err != nil || enc == "" {
|
||||
return Credentials{}, fmt.Errorf("DNS API 凭证未配置")
|
||||
}
|
||||
raw, err := s.secretBox.Decrypt(enc)
|
||||
if err != nil {
|
||||
return Credentials{}, secret.DecryptHint(err)
|
||||
}
|
||||
return ParseCredentials(raw)
|
||||
}
|
||||
|
||||
func (s *Service) providerFor(name string) Provider {
|
||||
if p, ok := s.providers[name]; ok {
|
||||
return p
|
||||
}
|
||||
return s.providers["cloudflare"]
|
||||
}
|
||||
|
||||
func (s *Service) resolveZone(ctx context.Context, provider Provider, cred Credentials, fqdn string, cache map[string]zoneHost) (zone, host string, err error) {
|
||||
if cache != nil {
|
||||
if item, ok := cache[fqdn]; ok {
|
||||
return item.zone, item.host, item.err
|
||||
}
|
||||
}
|
||||
zone, host, err = ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return provider.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if cache != nil {
|
||||
cache[fqdn] = zoneHost{zone: zone, host: host, err: err}
|
||||
}
|
||||
return zone, host, err
|
||||
}
|
||||
|
||||
type zoneHost struct {
|
||||
zone string
|
||||
host string
|
||||
err error
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput) error {
|
||||
if _, err := FQDNsFromSaveInput(in); err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.TrimSpace(in.Provider) {
|
||||
case "dnspod", "cloudflare", "alidns", "tencentcloud", "volcengine", "":
|
||||
default:
|
||||
return fmt.Errorf("不支持的 DNS Provider")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
RootDomain string `json:"root_domain"`
|
||||
RecordName string `json:"record_name"`
|
||||
RecordNames []string `json:"record_names"`
|
||||
IPv4Enabled bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"has_token"`
|
||||
CustomIPv4 string `json:"custom_ipv4,omitempty"`
|
||||
CustomIPv6 string `json:"custom_ipv6,omitempty"`
|
||||
LastIPv4 string `json:"last_ipv4"`
|
||||
LastIPv6 string `json:"last_ipv6"`
|
||||
LastStatus string `json:"last_status"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
DomainRecords []DomainRecord `json:"domain_records"`
|
||||
LastUpdatedAt *time.Time `json:"last_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Provider string
|
||||
RootDomain string
|
||||
RecordName string
|
||||
RecordNames []string
|
||||
Domains []string
|
||||
IPv4Enabled bool
|
||||
IPv6Enabled bool
|
||||
Enabled bool
|
||||
CustomIPv4 string
|
||||
CustomIPv6 string
|
||||
APIToken string
|
||||
APITokenID string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
func (in SaveInput) HasCredentialUpdate() bool {
|
||||
return strings.TrimSpace(in.APIToken) != "" ||
|
||||
strings.TrimSpace(in.APITokenID) != "" ||
|
||||
strings.TrimSpace(in.APISecret) != ""
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]Config, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []Config
|
||||
for rows.Next() {
|
||||
cfg, err := scanConfig(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
return configs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id int64) (Config, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs WHERE id = ?
|
||||
`, id)
|
||||
cfg, err := scanConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Config{}, fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *Store) GetByRootDomain(ctx context.Context, rootDomain string) (Config, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs WHERE root_domain = ? ORDER BY id LIMIT 1
|
||||
`, rootDomain)
|
||||
cfg, err := scanConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", rootDomain)
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *Store) GetTokenByID(ctx context.Context, id int64) (string, error) {
|
||||
var enc string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(api_token_enc, '') FROM ddns_configs WHERE id = ?`, id).Scan(&enc)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in SaveInput, tokenEnc string) (Config, error) {
|
||||
rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO ddns_configs(provider, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc string, updateToken bool) (Config, error) {
|
||||
rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if updateToken {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
|
||||
api_token_enc = ?, custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
} else {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
|
||||
custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM ddns_configs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateStatus(ctx context.Context, id int64, ipv4, ipv6, status, lastError string) error {
|
||||
return s.UpdateSyncResult(ctx, id, ipv4, ipv6, status, lastError, nil)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSyncResult(ctx context.Context, id int64, ipv4, ipv6, status, lastError string, records []DomainRecord) error {
|
||||
domainRecordsJSON := ""
|
||||
if records != nil {
|
||||
domainRecordsJSON = EncodeDomainRecords(records)
|
||||
}
|
||||
if domainRecordsJSON != "" {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, domain_records = ?,
|
||||
last_updated_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, ipv4, ipv6, status, lastError, domainRecordsJSON, id)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, last_updated_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, ipv4, ipv6, status, lastError, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeInput(in SaveInput) (rootDomain, recordName, recordNamesJSON, provider string, err error) {
|
||||
provider = strings.TrimSpace(in.Provider)
|
||||
if provider == "" {
|
||||
provider = "cloudflare"
|
||||
}
|
||||
|
||||
var names []string
|
||||
if len(in.Domains) > 0 {
|
||||
rootDomain, names, err = ParseDomainLines(in.Domains)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
} else {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(in.RootDomain))
|
||||
if rootDomain == "" {
|
||||
return "", "", "", "", fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
names, err = ParseRecordNames(in.RecordNames, in.RecordName)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
}
|
||||
return rootDomain, names[0], EncodeRecordNames(names), provider, nil
|
||||
}
|
||||
|
||||
func scanConfig(row interface{ Scan(dest ...any) error }) (Config, error) {
|
||||
var cfg Config
|
||||
var ipv4Enabled, ipv6Enabled, enabled, hasToken int
|
||||
var recordNamesRaw, domainRecordsRaw string
|
||||
var lastUpdated sql.NullString
|
||||
if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RecordNames = DecodeRecordNames(recordNamesRaw, cfg.RecordName)
|
||||
cfg.DomainRecords = DecodeDomainRecords(domainRecordsRaw)
|
||||
cfg.DomainRecords = BuildDomainRecords(cfg)
|
||||
cfg.IPv4Enabled = ipv4Enabled == 1
|
||||
cfg.IPv6Enabled = ipv6Enabled == 1
|
||||
cfg.Enabled = enabled == 1
|
||||
cfg.HasToken = hasToken == 1
|
||||
if lastUpdated.Valid {
|
||||
t := parseTime(lastUpdated.String)
|
||||
cfg.LastUpdatedAt = &t
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||
if err != nil {
|
||||
t, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
|
||||
)
|
||||
|
||||
type TencentCloud struct{}
|
||||
|
||||
func NewTencentCloud() *TencentCloud { return &TencentCloud{} }
|
||||
|
||||
func (t *TencentCloud) Name() string { return "tencentcloud" }
|
||||
|
||||
func (t *TencentCloud) Verify(ctx context.Context, cred Credentials) error {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := dnspod.NewDescribeDomainListRequest()
|
||||
req.Limit = common.Int64Ptr(1)
|
||||
_, err = client.DescribeDomainList(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = t.domainInfo(client, zone)
|
||||
return zoneLookupOK(wrapTencentErr(err))
|
||||
}
|
||||
|
||||
func (t *TencentCloud) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zone, err := t.domainInfo(client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
records, err := t.listRecords(client, zone, tencentSubdomain(recordName), recordType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.Value != nil {
|
||||
return *rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (t *TencentCloud) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := t.domainInfo(client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subDomain := tencentSubdomain(recordName)
|
||||
records, err := t.listRecords(client, zone, subDomain, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) > 0 && records[0].RecordId != nil {
|
||||
req := dnspod.NewModifyRecordRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.RecordId = records[0].RecordId
|
||||
req.SubDomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
req.RecordLine = common.StringPtr(tencentRecordLine(records[0]))
|
||||
req.Value = common.StringPtr(ip)
|
||||
req.TTL = common.Uint64Ptr(600)
|
||||
_, err = client.ModifyRecord(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
req := dnspod.NewCreateRecordRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.SubDomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
req.RecordLine = common.StringPtr("默认")
|
||||
req.Value = common.StringPtr(ip)
|
||||
req.TTL = common.Uint64Ptr(600)
|
||||
_, err = client.CreateRecord(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) client(cred Credentials) (*dnspod.Client, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("腾讯云 DNS 凭证未配置")
|
||||
}
|
||||
credential := common.NewCredential(cred.Token, cred.Secret)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "dnspod.tencentcloudapi.com"
|
||||
return dnspod.NewClient(credential, "", cpf)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) domainInfo(client *dnspod.Client, rootDomain string) (*dnspod.DomainListItem, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
req := dnspod.NewDescribeDomainListRequest()
|
||||
var offset int64
|
||||
for {
|
||||
req.Offset = common.Int64Ptr(offset)
|
||||
resp, err := client.DescribeDomainList(req)
|
||||
if err != nil {
|
||||
return nil, wrapTencentErr(err)
|
||||
}
|
||||
for _, zone := range resp.Response.DomainList {
|
||||
if zone.Name != nil && strings.EqualFold(*zone.Name, rootDomain) {
|
||||
return zone, nil
|
||||
}
|
||||
if zone.Punycode != nil && strings.EqualFold(*zone.Punycode, rootDomain) {
|
||||
return zone, nil
|
||||
}
|
||||
}
|
||||
total := int64(0)
|
||||
if resp.Response.DomainCountInfo != nil && resp.Response.DomainCountInfo.AllTotal != nil {
|
||||
total = int64(*resp.Response.DomainCountInfo.AllTotal)
|
||||
}
|
||||
offset += int64(len(resp.Response.DomainList))
|
||||
if offset >= total || len(resp.Response.DomainList) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("未找到域名 %s 对应的腾讯云 DNS 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) listRecords(client *dnspod.Client, zone *dnspod.DomainListItem, subDomain, recordType string) ([]*dnspod.RecordListItem, error) {
|
||||
req := dnspod.NewDescribeRecordListRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.Subdomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
resp, err := client.DescribeRecordList(req)
|
||||
if err != nil {
|
||||
var sdkErr *errorsdk.TencentCloudSDKError
|
||||
if errors.As(err, &sdkErr) && sdkErr.Code == dnspod.RESOURCENOTFOUND_NODATAOFRECORD {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, wrapTencentErr(err)
|
||||
}
|
||||
if resp.Response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return resp.Response.RecordList, nil
|
||||
}
|
||||
|
||||
func tencentSubdomain(recordName string) string {
|
||||
sub := subDomainForRecord(recordName)
|
||||
if sub == "@" {
|
||||
return "@"
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func tencentRecordLine(rec *dnspod.RecordListItem) string {
|
||||
if rec != nil && rec.Line != nil && *rec.Line != "" {
|
||||
return *rec.Line
|
||||
}
|
||||
return "默认"
|
||||
}
|
||||
|
||||
func wrapTencentErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var sdkErr *errorsdk.TencentCloudSDKError
|
||||
if errors.As(err, &sdkErr) {
|
||||
return fmt.Errorf("腾讯云 DNS API 错误:%s", sdkErr.Message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTencentSubdomain(t *testing.T) {
|
||||
if tencentSubdomain("@") != "@" {
|
||||
t.Fatal("expected @")
|
||||
}
|
||||
if tencentSubdomain("www") != "www" {
|
||||
t.Fatal("expected www")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsTencentCloud(t *testing.T) {
|
||||
cred := CredentialsFromSave(SaveInput{
|
||||
Provider: "tencentcloud",
|
||||
APIToken: "AKIDxxx",
|
||||
APISecret: "secret",
|
||||
})
|
||||
if cred.Provider != "tencentcloud" || !cred.HasValues() {
|
||||
t.Fatal("expected tencentcloud credentials")
|
||||
}
|
||||
if err := cred.Validate("tencentcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/dns"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/volcengineerr"
|
||||
)
|
||||
|
||||
const volcengineDNSRegion = "cn-beijing"
|
||||
|
||||
type Volcengine struct{}
|
||||
|
||||
func NewVolcengine() *Volcengine { return &Volcengine{} }
|
||||
|
||||
func (v *Volcengine) Name() string { return "volcengine" }
|
||||
|
||||
func (v *Volcengine) Verify(ctx context.Context, cred Credentials) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.ListZonesWithContext(ctx, &dns.ListZonesInput{
|
||||
PageNumber: volcengine.Int32(1),
|
||||
PageSize: volcengine.Int32(1),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = v.zoneID(ctx, client, zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec, err := v.findRecord(ctx, client, zid, volcengineHost(recordName), recordType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec != nil && rec.Value != nil {
|
||||
return *rec.Value, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := volcengineHost(recordName)
|
||||
existing, err := v.findRecord(ctx, client, zid, host, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil && existing.RecordID != nil {
|
||||
line := volcengineLine(existing)
|
||||
_, err = client.UpdateRecordWithContext(ctx, &dns.UpdateRecordInput{
|
||||
RecordID: existing.RecordID,
|
||||
Host: volcengine.String(host),
|
||||
Line: volcengine.String(line),
|
||||
Type: volcengine.String(recordType),
|
||||
Value: volcengine.String(ip),
|
||||
TTL: volcengine.Int32(600),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateRecordWithContext(ctx, &dns.CreateRecordInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
Value: volcengine.String(ip),
|
||||
Line: volcengine.String("default"),
|
||||
TTL: volcengine.Int32(600),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) client(cred Credentials) (*dns.DNS, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("火山引擎 DNS 凭证未配置")
|
||||
}
|
||||
cfg := volcengine.NewConfig().
|
||||
WithRegion(volcengineDNSRegion).
|
||||
WithCredentials(credentials.NewStaticCredentials(cred.Token, cred.Secret, ""))
|
||||
sess, err := session.NewSession(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dns.New(sess), nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) zoneID(ctx context.Context, client *dns.DNS, rootDomain string) (int64, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
out, err := client.ListZonesWithContext(ctx, &dns.ListZonesInput{
|
||||
Key: volcengine.String(rootDomain),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, wrapVolcengineErr(err)
|
||||
}
|
||||
for _, zone := range out.Zones {
|
||||
if zone.ZoneName != nil && strings.EqualFold(*zone.ZoneName, rootDomain) && zone.ZID != nil {
|
||||
return int64(*zone.ZID), nil
|
||||
}
|
||||
}
|
||||
total := int32(0)
|
||||
if out.Total != nil {
|
||||
total = *out.Total
|
||||
}
|
||||
if len(out.Zones) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return 0, fmt.Errorf("未找到域名 %s 对应的火山引擎 DNS 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (v *Volcengine) findRecord(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) (*dns.RecordForListRecordsOutput, error) {
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
out, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapVolcengineErr(err)
|
||||
}
|
||||
for _, rec := range out.Records {
|
||||
if rec.Host != nil && *rec.Host == host && rec.Type != nil && strings.EqualFold(*rec.Type, recordType) {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
total := int32(0)
|
||||
if out.TotalCount != nil {
|
||||
total = *out.TotalCount
|
||||
}
|
||||
if len(out.Records) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func volcengineHost(recordName string) string {
|
||||
return subDomainForRecord(recordName)
|
||||
}
|
||||
|
||||
func volcengineLine(rec *dns.RecordForListRecordsOutput) string {
|
||||
if rec != nil && rec.Line != nil && strings.TrimSpace(*rec.Line) != "" {
|
||||
return *rec.Line
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func wrapVolcengineErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var sdkErr volcengineerr.RequestFailure
|
||||
if errors.As(err, &sdkErr) {
|
||||
return fmt.Errorf("火山引擎 DNS API 错误:%s", sdkErr.Message())
|
||||
}
|
||||
var apiErr volcengineerr.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return fmt.Errorf("火山引擎 DNS API 错误:%s", apiErr.Message())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/dns"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine"
|
||||
)
|
||||
|
||||
// UpsertTXTRecord creates or updates a TXT record for ACME DNS-01.
|
||||
func (v *Volcengine) UpsertTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return v.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return v.UpdateRecord(ctx, cred, zone, host, "TXT", value)
|
||||
}
|
||||
|
||||
// DeleteTXTRecord removes TXT records matching value at fqdn (ACME cleanup).
|
||||
func (v *Volcengine) DeleteTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return v.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return v.deleteTXTRecords(ctx, cred, zone, host, value)
|
||||
}
|
||||
|
||||
func (v *Volcengine) deleteTXTRecords(ctx context.Context, cred Credentials, rootDomain, recordName, value string) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := volcengineHost(recordName)
|
||||
records, err := v.listRecords(ctx, client, zid, host, "TXT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.Value == nil || strings.TrimSpace(*rec.Value) != strings.TrimSpace(value) {
|
||||
continue
|
||||
}
|
||||
if rec.RecordID == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := client.DeleteRecordWithContext(ctx, &dns.DeleteRecordInput{
|
||||
RecordID: rec.RecordID,
|
||||
}); err != nil {
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) listRecords(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) ([]*dns.RecordForListRecordsOutput, error) {
|
||||
var out []*dns.RecordForListRecordsOutput
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
resp, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapVolcengineErr(err)
|
||||
}
|
||||
out = append(out, resp.Records...)
|
||||
total := int32(0)
|
||||
if resp.TotalCount != nil {
|
||||
total = *resp.TotalCount
|
||||
}
|
||||
if len(resp.Records) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveZoneForACMEChallenge(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "_acme-challenge.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "example.com" || host != "_acme-challenge" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type zoneChecker func(zone string) (bool, error)
|
||||
|
||||
// ResolveZoneForFQDN finds the longest DNS zone suffix that exists at the provider.
|
||||
// For delegated zones (e.g. sub.example.com hosted separately), it prefers sub.example.com over example.com.
|
||||
func ResolveZoneForFQDN(ctx context.Context, check zoneChecker, fqdn string) (zone, host string, err error) {
|
||||
fqdn = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fqdn, ".")))
|
||||
if fqdn == "" {
|
||||
return "", "", fmt.Errorf("域名不能为空")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(fqdn, "*.") {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, check, strings.TrimPrefix(fqdn, "*."))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if host == "@" {
|
||||
return zone, "*", nil
|
||||
}
|
||||
return zone, "*." + host, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(fqdn, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", fqdn)
|
||||
}
|
||||
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
candidate := strings.Join(parts[i:], ".")
|
||||
ok, err := check(candidate)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
host := "@"
|
||||
if i > 0 {
|
||||
host = strings.Join(parts[:i], ".")
|
||||
}
|
||||
return candidate, host, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("未在 DNS 服务商中找到域名 %s 对应的 Zone", fqdn)
|
||||
}
|
||||
|
||||
func FQDNFromRecord(cfg Config, name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if name == "" {
|
||||
return strings.ToLower(strings.TrimSpace(cfg.RootDomain))
|
||||
}
|
||||
if strings.Contains(name, ".") || strings.HasPrefix(name, "*.") {
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err == nil {
|
||||
return FormatDomainLine(root, record)
|
||||
}
|
||||
return strings.TrimSuffix(name, ".")
|
||||
}
|
||||
return FormatDomainLine(cfg.RootDomain, name)
|
||||
}
|
||||
|
||||
func isZoneNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "未找到") ||
|
||||
strings.Contains(msg, "not found") ||
|
||||
strings.Contains(msg, "resourcenotfound") ||
|
||||
strings.Contains(msg, "invaliddomain")
|
||||
}
|
||||
|
||||
func zoneLookupOK(err error) (bool, error) {
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if isZoneNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveZoneForFQDNDelegatedSubdomain(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "@" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNPrefersLongestZone(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "www.sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "www" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNClassicSubdomain(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "s.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "example.com" || host != "s" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNWildcard(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "*.sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "*" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNFromRecordStoredFQDN(t *testing.T) {
|
||||
cfg := Config{RootDomain: "example.com"}
|
||||
if FQDNFromRecord(cfg, "sub.example.com") != "sub.example.com" {
|
||||
t.Fatal("expected stored fqdn preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Host string `json:"host"`
|
||||
Upstream string `json:"upstream"`
|
||||
Detected bool `json:"detected"`
|
||||
Platform string `json:"platform"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
type catalogEntry struct {
|
||||
Name string
|
||||
Port int
|
||||
Platform string
|
||||
Keywords []string
|
||||
}
|
||||
|
||||
var catalog = []catalogEntry{
|
||||
{Name: "飞牛 fnOS", Port: 5666, Platform: "fnos", Keywords: []string{"fnos", "飞牛", "fnos.cn"}},
|
||||
{Name: "AList", Port: 5244, Platform: "alist", Keywords: []string{"alist"}},
|
||||
{Name: "Jellyfin", Port: 8096, Platform: "jellyfin", Keywords: []string{"jellyfin"}},
|
||||
{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
|
||||
{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
|
||||
{Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{
|
||||
client: &http.Client{Timeout: 2 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Scan(ctx context.Context, host string) []Result {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
out := make([]Result, 0, len(catalog))
|
||||
for _, item := range catalog {
|
||||
result := Result{
|
||||
Name: item.Name,
|
||||
Port: item.Port,
|
||||
Host: host,
|
||||
Upstream: fmt.Sprintf("http://%s:%d", host, item.Port),
|
||||
Platform: item.Platform,
|
||||
}
|
||||
if !s.portOpen(ctx, host, item.Port) {
|
||||
out = append(out, result)
|
||||
continue
|
||||
}
|
||||
result.Detected = true
|
||||
title, body := s.probeHTTP(ctx, host, item.Port)
|
||||
result.Title = title
|
||||
if s.matchPlatform(item, title, body) {
|
||||
result.Platform = item.Platform
|
||||
if item.Platform == "fnos" {
|
||||
result.Suggestion = "检测到飞牛 fnOS 服务,可使用 nas.<域名> 创建反向代理"
|
||||
}
|
||||
}
|
||||
out = append(out, result)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) portOpen(ctx context.Context, host string, port int) bool {
|
||||
dialer := net.Dialer{Timeout: 800 * time.Millisecond}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) probeHTTP(ctx context.Context, host string, port int) (string, string) {
|
||||
url := fmt.Sprintf("http://%s:%d/", host, port)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes := make([]byte, 4096)
|
||||
n, _ := resp.Body.Read(bodyBytes)
|
||||
body := strings.ToLower(string(bodyBytes[:n]))
|
||||
title := extractTitle(body)
|
||||
return title, body
|
||||
}
|
||||
|
||||
func (s *Service) matchPlatform(item catalogEntry, title, body string) bool {
|
||||
content := strings.ToLower(title + " " + body)
|
||||
for _, keyword := range item.Keywords {
|
||||
if strings.Contains(content, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractTitle(body string) string {
|
||||
lower := strings.ToLower(body)
|
||||
start := strings.Index(lower, "<title>")
|
||||
end := strings.Index(lower, "</title>")
|
||||
if start >= 0 && end > start {
|
||||
return strings.TrimSpace(body[start+7 : end])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
maskedToken = "********"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
HasAuthToken bool `json:"has_auth_token"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Enabled bool
|
||||
ServerAddr string
|
||||
ServerPort int
|
||||
AuthToken string
|
||||
TLSEnabled bool
|
||||
CustomDomains []string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
settings *settings.Store
|
||||
secretBox *secret.Box
|
||||
}
|
||||
|
||||
func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
|
||||
return &Store{settings: settingsStore, secretBox: secretBox}
|
||||
}
|
||||
|
||||
func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
enabled, _ := s.settings.GetBool(ctx, settings.KeyFRPEnabled)
|
||||
serverAddr, _ := s.settings.Get(ctx, settings.KeyFRPServerAddr)
|
||||
serverPort, _ := s.settings.GetInt(ctx, settings.KeyFRPServerPort)
|
||||
if serverPort <= 0 {
|
||||
serverPort = 7000
|
||||
}
|
||||
tlsEnabled, _ := s.settings.GetBool(ctx, settings.KeyFRPTLSEnabled)
|
||||
domains, err := s.loadDomains(ctx)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
encToken, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
hasToken := strings.TrimSpace(encToken) != ""
|
||||
token := ""
|
||||
if hasToken {
|
||||
token = maskedToken
|
||||
}
|
||||
return Config{
|
||||
Enabled: enabled,
|
||||
ServerAddr: serverAddr,
|
||||
ServerPort: serverPort,
|
||||
AuthToken: token,
|
||||
HasAuthToken: hasToken,
|
||||
TLSEnabled: tlsEnabled,
|
||||
CustomDomains: domains,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadRuntime(ctx context.Context) (Config, string, error) {
|
||||
cfg, err := s.Load(ctx)
|
||||
if err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
if !cfg.HasAuthToken {
|
||||
return cfg, "", nil
|
||||
}
|
||||
enc, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
plain, err := s.secretBox.Decrypt(enc)
|
||||
if err != nil {
|
||||
return cfg, "", secret.DecryptHint(err)
|
||||
}
|
||||
return cfg, plain, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(ctx context.Context, in SaveInput, keepToken bool) error {
|
||||
if err := validateSaveInput(in, keepToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPEnabled, in.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPServerAddr, normalizeServerAddr(in.ServerAddr)); err != nil {
|
||||
return err
|
||||
}
|
||||
port := in.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyFRPServerPort, port); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPTLSEnabled, in.TLSEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
domains := normalizeDomains(in.CustomDomains)
|
||||
raw, err := json.Marshal(domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPCustomDomains, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token != "" && token != maskedToken {
|
||||
enc, err := s.secretBox.Encrypt(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPAuthToken, enc); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if in.Enabled && !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetLastError(ctx context.Context, msg string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPLastError, strings.TrimSpace(msg))
|
||||
}
|
||||
|
||||
func (s *Store) LastError(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPLastError)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) StartedAt(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPStartedAt)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) SetStartedAt(ctx context.Context, value string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPStartedAt, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func (s *Store) loadDomains(ctx context.Context) ([]string, error) {
|
||||
raw, _ := s.settings.Get(ctx, settings.KeyFRPCustomDomains)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err != nil {
|
||||
return nil, fmt.Errorf("FRP 域名配置无效")
|
||||
}
|
||||
return normalizeDomains(domains), nil
|
||||
}
|
||||
|
||||
func normalizeServerAddr(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
addr = strings.TrimPrefix(addr, "https://")
|
||||
addr = strings.TrimPrefix(addr, "http://")
|
||||
if idx := strings.IndexAny(addr, "/?#"); idx >= 0 {
|
||||
addr = addr[:idx]
|
||||
}
|
||||
return strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
func normalizeDomains(domains []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, d := range domains {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" || seen[strings.ToLower(d)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(d)] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput, keepToken bool) error {
|
||||
if !in.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(in.ServerAddr) == "" {
|
||||
return fmt.Errorf("请填写 FRP 服务器地址")
|
||||
}
|
||||
if in.ServerPort < 0 || in.ServerPort > 65535 {
|
||||
return fmt.Errorf("FRP 服务器端口无效")
|
||||
}
|
||||
if len(normalizeDomains(in.CustomDomains)) == 0 {
|
||||
return fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token == "" || token == maskedToken {
|
||||
if !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeServerAddr(t *testing.T) {
|
||||
if got := normalizeServerAddr("http://127.0.0.1"); got != "127.0.0.1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := normalizeServerAddr("https://frp.example.com/path"); got != "frp.example.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func Generate(cfg config.Config, frpCfg Config, authToken string, httpPort, httpsPort int) (string, error) {
|
||||
if strings.TrimSpace(frpCfg.ServerAddr) == "" {
|
||||
return "", fmt.Errorf("FRP 服务器地址未配置")
|
||||
}
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return "", fmt.Errorf("FRP 认证 Token 未配置")
|
||||
}
|
||||
domains := normalizeDomains(frpCfg.CustomDomains)
|
||||
if len(domains) == 0 {
|
||||
return "", fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
if httpPort <= 0 || httpsPort <= 0 {
|
||||
return "", fmt.Errorf("Nginx 端口未配置")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("serverAddr = %q\n", frpCfg.ServerAddr))
|
||||
b.WriteString(fmt.Sprintf("serverPort = %d\n\n", frpCfg.ServerPort))
|
||||
|
||||
b.WriteString(fmt.Sprintf("log.to = %q\n", tomlPath(cfg.FrpLogPath())))
|
||||
b.WriteString("log.level = \"info\"\n\n")
|
||||
|
||||
b.WriteString("auth.method = \"token\"\n")
|
||||
b.WriteString(fmt.Sprintf("auth.token = %q\n\n", authToken))
|
||||
|
||||
if frpCfg.TLSEnabled {
|
||||
b.WriteString("transport.tls.enable = true\n\n")
|
||||
} else {
|
||||
b.WriteString("transport.tls.enable = false\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-http\"\n")
|
||||
b.WriteString("type = \"http\"\n")
|
||||
b.WriteString("localIP = \"127.0.0.1\"\n")
|
||||
b.WriteString(fmt.Sprintf("localPort = %d\n", httpPort))
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n\n", formatTOMLStringArray(domains)))
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-https\"\n")
|
||||
b.WriteString("type = \"https\"\n")
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n", formatTOMLStringArray(domains)))
|
||||
b.WriteString("[proxies.plugin]\n")
|
||||
b.WriteString("type = \"https2https\"\n")
|
||||
b.WriteString(fmt.Sprintf("localAddr = \"127.0.0.1:%d\"\n", httpsPort))
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func tomlPath(path string) string {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
||||
func formatTOMLStringArray(items []string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateNginxGateway(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
DataDir: "/data",
|
||||
NginxDefaultHTTPPort: 18080,
|
||||
NginxDefaultHTTPSPort: 9443,
|
||||
FrpPIDFile: "/data/frp/frpc.pid",
|
||||
}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
TLSEnabled: true,
|
||||
CustomDomains: []string{"nas.example.com", "*.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `serverAddr = "vps.example.com"`) {
|
||||
t.Fatalf("missing server addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localPort = 18080`) {
|
||||
t.Fatalf("missing http local port: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `type = "https2https"`) {
|
||||
t.Fatalf("missing https plugin: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localAddr = "127.0.0.1:9443"`) {
|
||||
t.Fatalf("missing https local addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = true`) {
|
||||
t.Fatalf("missing tls enable: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDisablesTLSByDefault(t *testing.T) {
|
||||
cfg := config.Config{DataDir: "/data", FrpPIDFile: "/data/frp/frpc.pid"}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
CustomDomains: []string{"nas.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = false`) {
|
||||
t.Fatalf("expected tls disabled: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReadLogTail(path string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
|
||||
return []string{}, nil
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
const (
|
||||
frpcCmdTimeout = 8 * time.Second
|
||||
frpcStartWait = 5 * time.Second
|
||||
frpcTerminateWait = 3 * time.Second
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg config.Config
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
|
||||
return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureDirs() error {
|
||||
return os.MkdirAll(m.cfg.FrpDir(), 0o755)
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
|
||||
existing, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
|
||||
if err := m.store.Save(ctx, in, keepToken); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.clearStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已停用"}, nil
|
||||
}
|
||||
|
||||
runtimeCfg, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
tmpPath := m.cfg.FrpConfigPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.verifyConfig(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !m.available() {
|
||||
msg := "frpc 未安装,配置已保存"
|
||||
_ = m.store.SetLastError(ctx, msg)
|
||||
return ApplyResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
running, _ := m.isRunning()
|
||||
if !running {
|
||||
if err := m.start(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已启动"}, nil
|
||||
}
|
||||
|
||||
if err := m.reload(ctx); err != nil {
|
||||
m.logger.Warn("frpc reload failed, trying restart", "error", err)
|
||||
if err := m.forceRestart(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重新启动"}, nil
|
||||
}
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重载"}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Bootstrap(ctx context.Context) error {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !m.available() {
|
||||
m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
|
||||
return nil
|
||||
}
|
||||
_, err = m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: cfg.CustomDomains,
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Warn("initial frpc apply skipped", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context) (Config, error) {
|
||||
return m.store.Load(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Status(ctx context.Context) Status {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{Message: "读取配置失败"}
|
||||
}
|
||||
return BuildStatus(
|
||||
m.cfg,
|
||||
cfg,
|
||||
cfg.Enabled,
|
||||
m.store.LastError(ctx),
|
||||
m.store.StartedAt(ctx),
|
||||
ResolveClientVersion(m.cfg.FrpcBin),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
merged := MergeDomains(cfg.CustomDomains, domains)
|
||||
return m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: merged,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
|
||||
return ReadLogTail(m.cfg.FrpLogPath(), limit)
|
||||
}
|
||||
|
||||
func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := cfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPPort() int {
|
||||
return m.cfg.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPSPort() int {
|
||||
return m.cfg.NginxDefaultHTTPSPort
|
||||
}
|
||||
|
||||
func (m *Manager) verifyConfig(ctx context.Context) error {
|
||||
if !m.available() {
|
||||
return nil
|
||||
}
|
||||
return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
|
||||
}
|
||||
|
||||
func (m *Manager) markStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (m *Manager) clearStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, "")
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if !m.isRunningQuick() {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
_ = terminateProcess(pid, syscall.SIGTERM)
|
||||
waitProcessExit(pid, frpcTerminateWait)
|
||||
if nginx.IsPIDAlive(pid) {
|
||||
_ = terminateProcess(pid, syscall.SIGKILL)
|
||||
waitProcessExit(pid, time.Second)
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
m.clearStarted(ctx)
|
||||
m.logger.Info("frpc stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) available() bool {
|
||||
_, err := exec.LookPath(m.cfg.FrpcBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context) error {
|
||||
if m.isRunningQuick() {
|
||||
return m.reload(ctx)
|
||||
}
|
||||
args := []string{"-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reload(ctx context.Context) error {
|
||||
args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc reloaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) forceRestart(ctx context.Context) error {
|
||||
_ = m.Stop(ctx)
|
||||
return m.start(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
msg = "操作超时"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
|
||||
cmd := exec.Command(m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
|
||||
deadline := time.Now().Add(frpcStartWait)
|
||||
for time.Now().Before(deadline) {
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
m.logger.Warn("frpc exited during start", "error", msg)
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, "操作超时")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), nil)
|
||||
if msg == "" {
|
||||
msg = "未检测到运行中的进程"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
|
||||
func frpcCommandError(stdout, stderr string, err error) string {
|
||||
msg := strings.TrimSpace(stderr)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stdout)
|
||||
}
|
||||
if msg == "" && err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "进程已退出"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writePIDFile(path string, pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("无效的 frpc 进程 ID")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) isRunning() (bool, error) {
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) isRunningQuick() bool {
|
||||
ok, _ := m.isRunning()
|
||||
return ok
|
||||
}
|
||||
|
||||
func readPIDFile(path string) (int, error) {
|
||||
return nginx.ReadPID(path)
|
||||
}
|
||||
|
||||
func removePIDFile(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func waitProcessExit(pid int, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func terminateProcess(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
|
||||
func FrpConfigDir(cfg config.Config) string {
|
||||
return filepath.Clean(cfg.FrpDir())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Message string `json:"message"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ServerEndpoint string `json:"server_endpoint,omitempty"`
|
||||
ConnectedAt string `json:"connected_at,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
HTTPGatewayEnabled bool `json:"http_gateway_enabled"`
|
||||
HTTPSGatewayEnabled bool `json:"https_gateway_enabled"`
|
||||
SyncedDomainCount int `json:"synced_domain_count"`
|
||||
}
|
||||
|
||||
func BuildStatus(cfg config.Config, frpCfg Config, enabled bool, lastError, startedAt, clientVersion string) Status {
|
||||
st := Status{
|
||||
Enabled: enabled,
|
||||
Connected: false,
|
||||
Message: "未启用",
|
||||
LastError: strings.TrimSpace(lastError),
|
||||
ClientVersion: clientVersion,
|
||||
SyncedDomainCount: len(frpCfg.CustomDomains),
|
||||
}
|
||||
if enabled {
|
||||
addr := strings.TrimSpace(frpCfg.ServerAddr)
|
||||
if addr != "" {
|
||||
port := frpCfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
st.ServerEndpoint = addr + ":" + strconv.Itoa(port)
|
||||
}
|
||||
st.HTTPGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
st.HTTPSGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
}
|
||||
if !enabled {
|
||||
return st
|
||||
}
|
||||
running, err := isProcessRunning(cfg.FrpPIDFile)
|
||||
if err != nil || !running {
|
||||
st.Message = "未连接"
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
} else if hint := readLogHint(cfg.FrpLogPath()); hint != "" {
|
||||
st.Message = hint
|
||||
}
|
||||
return st
|
||||
}
|
||||
ok, logHint := logIndicatesConnected(cfg.FrpLogPath())
|
||||
if ok {
|
||||
st.Connected = true
|
||||
st.Message = "已连接"
|
||||
st.LastError = ""
|
||||
applyConnectedTiming(&st, startedAt)
|
||||
return st
|
||||
}
|
||||
st.Message = "连接中"
|
||||
if logHint != "" {
|
||||
st.Message = logHint
|
||||
}
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func applyConnectedTiming(st *Status, startedAt string) {
|
||||
st.ConnectedAt = strings.TrimSpace(startedAt)
|
||||
if st.ConnectedAt == "" {
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, st.ConnectedAt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
st.UptimeSeconds = int64(time.Since(t).Seconds())
|
||||
if st.UptimeSeconds < 0 {
|
||||
st.UptimeSeconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
func isProcessRunning(pidFile string) (bool, error) {
|
||||
pid, err := nginx.ReadPID(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return nginx.IsPIDAlive(pid), nil
|
||||
}
|
||||
|
||||
func readLogHint(path string) string {
|
||||
lines := tailLines(path, 40)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") ||
|
||||
strings.Contains(line, "token in login doesn't match") {
|
||||
return sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logIndicatesConnected(path string) (bool, string) {
|
||||
lines := tailLines(path, 80)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return true, ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") {
|
||||
return false, sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func tailLines(path string, max int) []string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > max {
|
||||
lines = lines[len(lines)-max:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func sanitizeLogLine(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
|
||||
line = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
if len(line) > 200 {
|
||||
line = line[:200] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func DomainsFromProxyRules(rules []proxy.Rule) []string {
|
||||
seen := make(map[string]bool)
|
||||
var domains []string
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, host := range rule.Hostnames() {
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
domains = append(domains, host)
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func MergeDomains(existing, incoming []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, list := range [][]string{existing, incoming} {
|
||||
for _, d := range list {
|
||||
d = strings.TrimSpace(d)
|
||||
key := strings.ToLower(d)
|
||||
if d == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDomainsFromProxyRules(t *testing.T) {
|
||||
rules := []proxy.Rule{
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}}, Domain: "b.example.com"},
|
||||
{Enabled: false, Hosts: []proxy.Host{{Hostname: "skip.example.com"}}},
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}, {Hostname: "c.example.com"}}},
|
||||
}
|
||||
domains := DomainsFromProxyRules(rules)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %v", domains)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package frp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildFRPSConfig generates frps.toml for the user's VPS, aligned with Fonu Nginx ports and auth.
|
||||
func BuildFRPSConfig(bindPort, httpPort, httpsPort int, token string) string {
|
||||
if bindPort <= 0 {
|
||||
bindPort = 7000
|
||||
}
|
||||
if httpPort <= 0 {
|
||||
httpPort = 80
|
||||
}
|
||||
if httpsPort <= 0 {
|
||||
httpsPort = 443
|
||||
}
|
||||
if token == "" {
|
||||
token = "your-secret-token"
|
||||
}
|
||||
return fmt.Sprintf(`bindAddr = "0.0.0.0"
|
||||
bindPort = %d
|
||||
|
||||
vhostHTTPPort = %d
|
||||
vhostHTTPSPort = %d
|
||||
|
||||
[auth]
|
||||
method = "token"
|
||||
token = "%s"
|
||||
`, bindPort, httpPort, httpsPort, token)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildFRPSConfig(t *testing.T) {
|
||||
out := BuildFRPSConfig(7000, 18080, 9443, "fonu-dev-token")
|
||||
if out == "" {
|
||||
t.Fatal("empty config")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"bindPort = 7000",
|
||||
"vhostHTTPPort = 18080",
|
||||
"vhostHTTPSPort = 9443",
|
||||
`token = "fonu-dev-token"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultClientVersion = "frpc v0.71.0"
|
||||
|
||||
func ResolveClientVersion(bin string) string {
|
||||
bin = strings.TrimSpace(bin)
|
||||
if bin == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
out, err := exec.Command(bin, "--version").Output()
|
||||
if err != nil {
|
||||
return defaultClientVersion
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
if strings.HasPrefix(line, "frpc version ") {
|
||||
return "frpc v" + strings.TrimPrefix(line, "frpc version ")
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentClient 调用公网 VPS 上的 fonu-agent。凭据由调用方解密后只在内存中使用。
|
||||
type AgentClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewAgentClient(baseURL, token string) *AgentClient {
|
||||
return &AgentClient{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
token: token,
|
||||
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
|
||||
if c.baseURL == "" || c.token == "" {
|
||||
return fmt.Errorf("公网 agent 未配置")
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("agent 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if result != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return fmt.Errorf("agent 响应格式无效: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) Ping(ctx context.Context) error {
|
||||
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
|
||||
}
|
||||
|
||||
func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *AgentClient) SyncRoute(ctx context.Context, domain, upstream string, tls bool, certDir string) error {
|
||||
_, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "")
|
||||
return err
|
||||
}
|
||||
|
||||
// RouteSpec 公网反代单域名部署参数(字段名与 agent routeRequest 的 JSON key 严格一致)。
|
||||
type RouteSpec struct {
|
||||
Domain, Upstream, CertDir string
|
||||
TLS, WebSocket, RedirectHTTPS bool
|
||||
HTTPSDisabled, SecurityHeaders bool
|
||||
TLS13Only bool
|
||||
VhostPort int
|
||||
AllowIPs, DenyIPs []string
|
||||
BasicAuthUser, BasicAuthPassword string
|
||||
ClientMaxBodySize, ProxyReadTimeout string
|
||||
RateLimitRate, RateLimitBurst, ConnLimitMax int
|
||||
ChinaOnly bool
|
||||
}
|
||||
|
||||
// SyncRouteSpec 部署路由并要求回源自检;位置参数已到上限,新增选项一律走本方法。
|
||||
func (c *AgentClient) SyncRouteSpec(ctx context.Context, spec RouteSpec) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
err := c.request(ctx, http.MethodPut, "/api/v1/routes", map[string]any{
|
||||
"domain": spec.Domain, "upstream": spec.Upstream, "tls": spec.TLS, "cert_dir": spec.CertDir,
|
||||
"websocket": spec.WebSocket, "vhost_port": spec.VhostPort,
|
||||
"redirect_https": spec.RedirectHTTPS, "allow_ips": spec.AllowIPs, "basic_auth_user": spec.BasicAuthUser,
|
||||
"deny_ips": spec.DenyIPs, "basic_auth_password": spec.BasicAuthPassword,
|
||||
"https_disabled": spec.HTTPSDisabled, "client_max_body_size": spec.ClientMaxBodySize,
|
||||
"proxy_read_timeout": spec.ProxyReadTimeout, "security_headers": spec.SecurityHeaders,
|
||||
"tls13_only": spec.TLS13Only,
|
||||
"rate_limit_rate": spec.RateLimitRate, "rate_limit_burst": spec.RateLimitBurst,
|
||||
"conn_limit_max": spec.ConnLimitMax, "china_only": spec.ChinaOnly,
|
||||
}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// InstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
|
||||
func (c *AgentClient) InstallNginx(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
err := c.request(ctx, http.MethodPost, "/api/v1/nginx/install", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SyncChinaCIDR 把中国 IP 段(geo include 格式)下发到公网 agent。
|
||||
func (c *AgentClient) SyncChinaCIDR(ctx context.Context, content string) error {
|
||||
var result map[string]any
|
||||
return c.request(ctx, http.MethodPut, "/api/v1/china-cidr", map[string]any{"content": content}, &result)
|
||||
}
|
||||
|
||||
// SyncRouteEx 保留的位置参数兼容包装(新代码请用 SyncRouteSpec)。
|
||||
func (c *AgentClient) SyncRouteEx(ctx context.Context, domain, upstream string, tls bool, certDir string, websocket bool, vhostPort int, redirectHTTPS bool, allowIPs []string, basicAuthUser string) (map[string]any, error) {
|
||||
return c.SyncRouteSpec(ctx, RouteSpec{
|
||||
Domain: domain, Upstream: upstream, TLS: tls, CertDir: certDir,
|
||||
WebSocket: websocket, VhostPort: vhostPort, RedirectHTTPS: redirectHTTPS,
|
||||
AllowIPs: allowIPs, BasicAuthUser: basicAuthUser,
|
||||
})
|
||||
}
|
||||
|
||||
// RouteConf 返回指定域名当前反代配置内容。
|
||||
func (c *AgentClient) RouteConf(ctx context.Context, domain string) (string, error) {
|
||||
var result struct {
|
||||
Domain string `json:"domain"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/conf", nil, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
// RouteDetail 单条公网反代路由的真实状态。
|
||||
type RouteDetail struct {
|
||||
Domain string `json:"domain"`
|
||||
FileExists bool `json:"file_exists"`
|
||||
SyntaxOK bool `json:"syntax_ok"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
|
||||
// RoutesDetail 返回全部公网反代路由的状态明细(文件/语法/被 Nginx 加载)。
|
||||
func (c *AgentClient) RoutesDetail(ctx context.Context) ([]RouteDetail, error) {
|
||||
var result []RouteDetail
|
||||
err := c.request(ctx, http.MethodGet, "/api/v1/routes", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
|
||||
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
|
||||
}
|
||||
|
||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
|
||||
}
|
||||
|
||||
func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
err := c.request(ctx, http.MethodPost, "/api/v1/frps/install", map[string]string{"version": version, "proxy": proxy}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error {
|
||||
return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
|
||||
}
|
||||
|
||||
func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
|
||||
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp/agentbin"
|
||||
)
|
||||
|
||||
// AgentInstaller 负责把内嵌的 fonu-agent 通过 SSH 装到 VPS 上,并收集安装结果。
|
||||
type AgentInstaller struct {
|
||||
SSH SSHInstaller
|
||||
}
|
||||
|
||||
// Probe 返回 VPS 环境摘要:架构、nginx 是否安装、agent 是否已装、systemd 可用性。
|
||||
func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
|
||||
client, err := i.SSH.client(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 连接失败: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
out := func(cmd string) string {
|
||||
v, _ := runSSHSession(client, cmd)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
arch := out("uname -m")
|
||||
return map[string]any{
|
||||
"arch": arch,
|
||||
"nginx_conf": out(`conf=$(nginx -V 2>&1 | grep -o '\-\-conf-path=[^ ]*' | head -1 | cut -d= -f2-); [ -z "$conf" ] && conf=/etc/nginx/nginx.conf; grep '^[[:space:]]*include' "$conf" 2>/dev/null | grep -m1 -E 'conf\.d|sites-enabled|vhost/nginx' || ls -d /etc/nginx/conf.d 2>/dev/null`),
|
||||
"agent_exist": out("test -x /usr/local/bin/fonu-agent && echo yes || echo no") == "yes",
|
||||
"agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"),
|
||||
"agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"),
|
||||
"agent_token": out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"),
|
||||
"frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
|
||||
"frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
|
||||
"frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
|
||||
"frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
|
||||
"frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
|
||||
"port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
|
||||
"port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
|
||||
"port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Install 上传内嵌二进制并执行安装脚本;重复执行等于覆盖升级。返回 agent Token。
|
||||
func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
||||
arch, err := i.SSH.Run(ctx, "uname -m")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("探测架构失败: %w", err)
|
||||
}
|
||||
binary, ok := agentbin.BinaryForArch(strings.TrimSpace(arch))
|
||||
if !ok {
|
||||
return "", fmt.Errorf("不支持的 VPS 架构: %s", strings.TrimSpace(arch))
|
||||
}
|
||||
// 开发环境(go run / 非 Docker 构建)里内嵌的是占位文件;提前报错而不是让 VPS 上 systemd 反复 Exec format error
|
||||
if !isELFBinary(binary) {
|
||||
return "", fmt.Errorf("内嵌 agent 二进制无效(开发构建占位文件)。请用 Docker 构建或先执行构建脚本生成真实 agent:go build -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent(GOOS=linux GOARCH=amd64)")
|
||||
}
|
||||
script, err := agentInstallScript()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := i.SSH.Install(ctx, binary, script)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := extractToken(output)
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Uninstall 停止并移除 agent 服务;--keep-data 保留路由与证书。
|
||||
func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, error) {
|
||||
command := "/usr/local/bin/fonu-agent-uninstall"
|
||||
if keepData {
|
||||
command += " --keep-data"
|
||||
}
|
||||
out, err := i.SSH.Run(ctx, fmt.Sprintf("if [ -x %s ]; then %s; else echo 'agent 未安装'; fi", "'"+command+"'", command))
|
||||
return strings.TrimSpace(out), err
|
||||
}
|
||||
|
||||
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
|
||||
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
|
||||
return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
|
||||
}
|
||||
|
||||
func extractToken(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if token, found := strings.CutPrefix(strings.TrimSpace(line), "Token: "); found {
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func agentInstallScript() ([]byte, error) {
|
||||
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
|
||||
return []byte(`#!/usr/bin/env bash
|
||||
set -eu
|
||||
if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 或 sudo 运行" >&2; exit 1; fi
|
||||
AGENT_BINARY=${1:-/tmp/fonu-agent}
|
||||
PREFIX=/usr/local/bin/fonu-agent
|
||||
CONF_DIR=/etc/fonu-agent
|
||||
CONF_FILE="$CONF_DIR/agent.env"
|
||||
UNIT=/etc/systemd/system/fonu-agent.service
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
systemctl stop fonu-agent 2>/dev/null || true
|
||||
systemctl disable fonu-agent 2>/dev/null || true
|
||||
rm -f "$UNIT" "$PREFIX" /usr/local/bin/fonu-agent-uninstall
|
||||
systemctl daemon-reload
|
||||
if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/fonu-agent; fi
|
||||
echo "fonu-agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
|
||||
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
|
||||
systemctl stop fonu-agent 2>/dev/null || true
|
||||
TMP_PREFIX="${PREFIX}.new"
|
||||
install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
|
||||
if [ ! -x "$TMP_PREFIX" ]; then
|
||||
echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
|
||||
exit 1
|
||||
fi
|
||||
AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
|
||||
echo "agent 二进制无法执行:$TMP_PREFIX" >&2
|
||||
rm -f "$TMP_PREFIX"
|
||||
exit 1
|
||||
}
|
||||
mv -f "$TMP_PREFIX" "$PREFIX"
|
||||
if [ ! -x "$PREFIX" ]; then
|
||||
echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'agent version: %s\n' "$AGENT_VERSION"
|
||||
if [ ! -f "$CONF_FILE" ]; then
|
||||
umask 077
|
||||
printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE"
|
||||
fi
|
||||
cat > "$UNIT" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Fonu FRPS management agent
|
||||
After=network-online.target nginx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/fonu-agent/agent.env
|
||||
ExecStart=/usr/local/bin/fonu-agent
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl restart fonu-agent
|
||||
systemctl enable fonu-agent
|
||||
cp "$0" /usr/local/bin/fonu-agent-uninstall
|
||||
echo "fonu-agent installed"
|
||||
echo "Token: $(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE")"
|
||||
`), nil
|
||||
}
|
||||
|
||||
// isELFBinary 判断内容是否为 Linux ELF 可执行(魔数 0x7F 'ELF'),用于拦截开发构建的占位文件。
|
||||
func isELFBinary(data []byte) bool {
|
||||
return len(data) > 4 && data[0] == 0x7F && data[1] == 'E' && data[2] == 'L' && data[3] == 'F'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package agentbin 内嵌各架构 fonu-agent 二进制,供 SSH 安装器按 VPS 架构选择。
|
||||
// 仓库中的占位文件保证开发机构建可通过;Docker 构建阶段会用真实二进制覆盖它们
|
||||
// (见 Dockerfile:先交叉编译 agent,再构建主程序)。
|
||||
package agentbin
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed fonu-agent-linux-amd64
|
||||
var LinuxAMD64 []byte
|
||||
|
||||
//go:embed fonu-agent-linux-arm64
|
||||
var LinuxARM64 []byte
|
||||
|
||||
// BinaryForArch 返回对应架构的 agent 二进制;arch 取 uname -m 输出(x86_64/aarch64)。
|
||||
func BinaryForArch(arch string) ([]byte, bool) {
|
||||
switch arch {
|
||||
case "x86_64", "amd64":
|
||||
return LinuxAMD64, true
|
||||
case "aarch64", "arm64":
|
||||
return LinuxARM64, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,247 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type renderedConfig struct {
|
||||
Server Server
|
||||
Token string
|
||||
OIDCClientSecret string
|
||||
LogPath string
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
TLSTrustedCAPath string
|
||||
Proxies []Proxy
|
||||
}
|
||||
|
||||
func renderTOML(cfg renderedConfig) string {
|
||||
var out strings.Builder
|
||||
writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
|
||||
writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
|
||||
options := normalizeServerOptions(cfg.Server.Options)
|
||||
if options.User != "" {
|
||||
writeKeyValue(&out, "user", options.User)
|
||||
}
|
||||
if options.AuthMethod != "none" {
|
||||
writeKeyValue(&out, "auth.method", options.AuthMethod)
|
||||
}
|
||||
if options.AuthMethod == "token" {
|
||||
writeKeyValue(&out, "auth.token", cfg.Token)
|
||||
}
|
||||
if options.AuthMethod == "oidc" {
|
||||
writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
|
||||
writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
|
||||
writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
|
||||
if options.OIDCAudience != "" {
|
||||
writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
|
||||
}
|
||||
if options.OIDCScope != "" {
|
||||
writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
|
||||
}
|
||||
}
|
||||
if cfg.LogPath != "" {
|
||||
writeKeyValue(&out, "log.to", cfg.LogPath)
|
||||
}
|
||||
writeKeyValue(&out, "log.level", options.LogLevel)
|
||||
writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
|
||||
writeKeyValue(&out, "transport.protocol", options.Protocol)
|
||||
writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
|
||||
writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
|
||||
if cfg.Server.TLSServerName != "" {
|
||||
writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
|
||||
}
|
||||
if cfg.TLSCertPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
|
||||
}
|
||||
if cfg.TLSKeyPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
|
||||
}
|
||||
if cfg.TLSTrustedCAPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
|
||||
}
|
||||
if options.ProxyURL != "" {
|
||||
writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
|
||||
}
|
||||
writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
|
||||
writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
|
||||
if options.ConnectServerLocalIP != "" {
|
||||
writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
|
||||
}
|
||||
if options.DNSServer != "" {
|
||||
writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
|
||||
}
|
||||
writeKeyValue(&out, "transport.poolCount", options.PoolCount)
|
||||
writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
|
||||
writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
|
||||
writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
|
||||
writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
|
||||
writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
|
||||
|
||||
for _, proxy := range cfg.Proxies {
|
||||
out.WriteString("\n[[proxies]]\n")
|
||||
writeKeyValue(&out, "name", proxy.Name)
|
||||
writeKeyValue(&out, "type", proxy.Type)
|
||||
writeKeyValue(&out, "localIP", proxy.LocalIP)
|
||||
writeKeyValue(&out, "localPort", proxy.LocalPort)
|
||||
if proxy.Type == "tcp" && proxy.RemotePort != nil {
|
||||
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
|
||||
}
|
||||
if proxy.Type == "udp" && proxy.RemotePort != nil {
|
||||
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
|
||||
}
|
||||
if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
|
||||
writeStringSlice(&out, "customDomains", proxy.CustomDomains)
|
||||
}
|
||||
if proxy.HostHeaderRewrite != "" {
|
||||
writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
|
||||
}
|
||||
writeProxyOptions(&out, proxy)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func writeProxyOptions(out *strings.Builder, proxy Proxy) {
|
||||
opts := proxy.Options
|
||||
if opts.Subdomain != "" {
|
||||
writeKeyValue(out, "subdomain", opts.Subdomain)
|
||||
}
|
||||
if len(opts.Locations) > 0 {
|
||||
writeStringSlice(out, "locations", opts.Locations)
|
||||
}
|
||||
if opts.RouteByHTTPUser != "" {
|
||||
writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
|
||||
}
|
||||
if opts.HTTPUser != "" {
|
||||
writeKeyValue(out, "httpUser", opts.HTTPUser)
|
||||
}
|
||||
if opts.HTTPPassword != "" {
|
||||
writeKeyValue(out, "httpPassword", opts.HTTPPassword)
|
||||
}
|
||||
if opts.Multiplexer != "" {
|
||||
writeKeyValue(out, "multiplexer", opts.Multiplexer)
|
||||
}
|
||||
if opts.SecretKey != "" {
|
||||
writeKeyValue(out, "secretKey", opts.SecretKey)
|
||||
}
|
||||
if len(opts.AllowUsers) > 0 {
|
||||
writeStringSlice(out, "allowUsers", opts.AllowUsers)
|
||||
}
|
||||
writeProxyTransport(out, opts.Transport)
|
||||
writeProxyHealthCheck(out, opts.HealthCheck)
|
||||
if opts.LoadBalancer.Group != "" {
|
||||
writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
|
||||
}
|
||||
if opts.LoadBalancer.GroupKey != "" {
|
||||
writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
|
||||
}
|
||||
writeStringMap(out, "metadatas", opts.Metadatas)
|
||||
writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
|
||||
writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
|
||||
// natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
|
||||
// disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
|
||||
if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
|
||||
writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
|
||||
}
|
||||
if opts.Plugin != nil {
|
||||
writeProxyPlugin(out, *opts.Plugin)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
|
||||
if opts.BandwidthLimit != "" {
|
||||
writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
|
||||
}
|
||||
if opts.BandwidthLimitMode != "" {
|
||||
writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
|
||||
}
|
||||
if opts.UseEncryption {
|
||||
writeKeyValue(out, "transport.useEncryption", true)
|
||||
}
|
||||
if opts.UseCompression {
|
||||
writeKeyValue(out, "transport.useCompression", true)
|
||||
}
|
||||
if opts.ProxyProtocolVersion != "" {
|
||||
writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
|
||||
if opts.Type == "" {
|
||||
return
|
||||
}
|
||||
writeKeyValue(out, "healthCheck.type", opts.Type)
|
||||
if opts.Path != "" {
|
||||
writeKeyValue(out, "healthCheck.path", opts.Path)
|
||||
}
|
||||
if opts.IntervalSeconds > 0 {
|
||||
writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
|
||||
}
|
||||
if opts.MaxFailed > 0 {
|
||||
writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
|
||||
}
|
||||
if opts.TimeoutSeconds > 0 {
|
||||
writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
|
||||
}
|
||||
if len(opts.HTTPHeaders) > 0 {
|
||||
values := make([]string, 0, len(opts.HTTPHeaders))
|
||||
for _, header := range opts.HTTPHeaders {
|
||||
values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
|
||||
}
|
||||
fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
|
||||
out.WriteString("[proxies.plugin]\n")
|
||||
writeKeyValue(out, "type", plugin.Type)
|
||||
for _, item := range []struct{ key, value string }{
|
||||
{"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
|
||||
{"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
|
||||
{"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
|
||||
{"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
|
||||
} {
|
||||
if item.value != "" {
|
||||
writeKeyValue(out, item.key, item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
writeKeyValue(out, prefix+"."+key, values[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderMaskedTOML(server Server, proxies []Proxy) string {
|
||||
return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
|
||||
}
|
||||
|
||||
func writeKeyValue(out *strings.Builder, key string, value any) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
|
||||
case int:
|
||||
fmt.Fprintf(out, "%s = %d\n", key, v)
|
||||
case bool:
|
||||
fmt.Fprintf(out, "%s = %t\n", key, v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStringSlice(out *strings.Builder, key string, values []string) {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, strconv.Quote(value))
|
||||
}
|
||||
fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTOML(t *testing.T) {
|
||||
remotePort := 60022
|
||||
content := renderTOML(renderedConfig{
|
||||
Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
|
||||
Token: "token with \"quotes\"",
|
||||
Proxies: []Proxy{
|
||||
{Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
|
||||
{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
|
||||
},
|
||||
})
|
||||
|
||||
for _, expected := range []string{
|
||||
`serverAddr = "vps.example.com"`,
|
||||
`auth.token = "token with \"quotes\""`,
|
||||
`remotePort = 60022`,
|
||||
`customDomains = ["nas.example.com"]`,
|
||||
} {
|
||||
if !strings.Contains(content, expected) {
|
||||
t.Fatalf("配置缺少 %q:\n%s", expected, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
|
||||
content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
|
||||
if !strings.Contains(content, `auth.token = "********"`) {
|
||||
t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProxyOptions(t *testing.T) {
|
||||
content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
|
||||
{Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
|
||||
{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
|
||||
}})
|
||||
for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
|
||||
if !strings.Contains(content, expected) {
|
||||
t.Fatalf("配置缺少 %q:\n%s", expected, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
|
||||
omitted := renderTOML(renderedConfig{Proxies: []Proxy{
|
||||
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
|
||||
}})
|
||||
if strings.Contains(omitted, "natTraversal") {
|
||||
t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
|
||||
}
|
||||
enabled := renderTOML(renderedConfig{Proxies: []Proxy{
|
||||
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
|
||||
}})
|
||||
if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
|
||||
t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(value int) *int { return &value }
|
||||
@@ -0,0 +1,46 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxConnectionLogRead = 128 << 10
|
||||
|
||||
func inspectConnectionLog(path, processState string) (string, string) {
|
||||
if processState != "running" {
|
||||
if processState == "error" {
|
||||
return "error", "frpc 进程已退出"
|
||||
}
|
||||
return "disconnected", ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "unknown", ""
|
||||
}
|
||||
if len(data) > maxConnectionLogRead {
|
||||
data = data[len(data)-maxConnectionLogRead:]
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
line := strings.TrimSpace(lines[index])
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(lower, "login to server success"):
|
||||
return "connected", ""
|
||||
case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
|
||||
return "error", trimConnectionError(line)
|
||||
case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
|
||||
return "disconnected", ""
|
||||
}
|
||||
}
|
||||
return "unknown", ""
|
||||
}
|
||||
|
||||
func trimConnectionError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 500 {
|
||||
return value[:500]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
|
||||
const (
|
||||
diagStateOK = "ok"
|
||||
diagStateFailed = "failed"
|
||||
diagStateUnknown = "unknown"
|
||||
|
||||
diagTCPTimeout = 3 * time.Second
|
||||
diagDNSTimeout = 2 * time.Second
|
||||
diagnosticsCacheTTL = 15 * time.Second
|
||||
diagnosticsMaxInFlight = 4
|
||||
)
|
||||
|
||||
type diagnosticsCacheEntry struct {
|
||||
diagnostics ServerDiagnostics
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
|
||||
// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
|
||||
func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
|
||||
server, err := s.store.GetServer(ctx, id)
|
||||
if err != nil {
|
||||
return ServerDiagnostics{}, err
|
||||
}
|
||||
return s.runDiagnostics(ctx, server), nil
|
||||
}
|
||||
|
||||
func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
|
||||
s.diagMu.Lock()
|
||||
if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
|
||||
cached := entry.diagnostics
|
||||
s.diagMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
// 并发上限:超限时返回旧结果或 unknown,不排队等待
|
||||
if s.diagInFlight >= diagnosticsMaxInFlight {
|
||||
cached, ok := s.diagCache[server.ID]
|
||||
s.diagMu.Unlock()
|
||||
if ok {
|
||||
return cached.diagnostics
|
||||
}
|
||||
return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
|
||||
}
|
||||
s.diagInFlight++
|
||||
s.diagMu.Unlock()
|
||||
defer func() {
|
||||
s.diagMu.Lock()
|
||||
s.diagInFlight--
|
||||
s.diagMu.Unlock()
|
||||
}()
|
||||
|
||||
result := s.computeDiagnostics(ctx, server)
|
||||
s.diagMu.Lock()
|
||||
s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
|
||||
s.diagMu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
|
||||
// 绝不写入 Error(Error 仅表示控制端口探测失败)。
|
||||
func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
|
||||
dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
|
||||
tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
|
||||
diagnostics := ServerDiagnostics{
|
||||
ServerID: server.ID,
|
||||
Available: tcpCheck.State == diagStateOK,
|
||||
LatencyMS: tcpCheck.LatencyMS,
|
||||
Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
|
||||
}
|
||||
if tcpCheck.State == diagStateFailed {
|
||||
diagnostics.Error = tcpCheck.Detail
|
||||
}
|
||||
|
||||
// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
|
||||
// 不让列表页同步等待外部定位 API。
|
||||
location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
|
||||
diagnostics.Location = location
|
||||
locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
|
||||
if !resolved {
|
||||
locationCheck.State = diagStateUnknown
|
||||
locationCheck.Detail = "定位信息后台获取中"
|
||||
go s.refreshLocation(server.ServerAddr)
|
||||
}
|
||||
diagnostics.Checks = append(diagnostics.Checks, locationCheck)
|
||||
s.store.AddServerDiagnostic(ctx, diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
|
||||
func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "tcp_reachable"}
|
||||
if dnsCheck.State == diagStateFailed {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = "已跳过:" + dnsCheck.Detail
|
||||
return check
|
||||
}
|
||||
dialer := net.Dialer{Timeout: diagTCPTimeout}
|
||||
started := time.Now()
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
|
||||
check.LatencyMS = time.Since(started).Milliseconds()
|
||||
if check.LatencyMS < 1 {
|
||||
check.LatencyMS = 1
|
||||
}
|
||||
if err != nil {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = diagnoseDialError(err)
|
||||
return check
|
||||
}
|
||||
_ = conn.Close()
|
||||
check.State = diagStateOK
|
||||
check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
|
||||
return check
|
||||
}
|
||||
|
||||
// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
|
||||
func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "dns_resolution"}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
check.State = diagStateOK
|
||||
check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
|
||||
return check
|
||||
}
|
||||
resolver := net.DefaultResolver
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
if err != nil || len(addresses) == 0 {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = "域名解析失败:" + diagnoseResolveError(err)
|
||||
return check
|
||||
}
|
||||
var ipv4, ipv6 []string
|
||||
for _, address := range addresses {
|
||||
if address.IP.To4() != nil {
|
||||
ipv4 = append(ipv4, address.IP.String())
|
||||
} else {
|
||||
ipv6 = append(ipv6, address.IP.String())
|
||||
}
|
||||
}
|
||||
summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
|
||||
if len(ipv6) > 0 {
|
||||
summary += ";AAAA=" + strings.Join(ipv6, ", ")
|
||||
} else {
|
||||
summary += ";AAAA=无"
|
||||
}
|
||||
check.LatencyMS = elapsed
|
||||
check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
|
||||
return check
|
||||
}
|
||||
|
||||
func diagnoseResolveError(err error) string {
|
||||
if err == nil {
|
||||
return "无解析结果"
|
||||
}
|
||||
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
|
||||
return "域名不存在"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
|
||||
func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "frpc_login"}
|
||||
data, err := os.ReadFile(s.serverLogPath(serverID))
|
||||
if os.IsNotExist(err) {
|
||||
check.State = diagStateUnknown
|
||||
check.Detail = "frpc 尚未运行,无登录记录"
|
||||
return check
|
||||
}
|
||||
if err != nil {
|
||||
check.State = diagStateUnknown
|
||||
check.Detail = "读取 frpc 日志失败"
|
||||
return check
|
||||
}
|
||||
state, detail := summarizeFRPCLogin(string(data))
|
||||
check.State = state
|
||||
check.Detail = detail
|
||||
return check
|
||||
}
|
||||
|
||||
// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
|
||||
func summarizeFRPCLogin(log string) (string, string) {
|
||||
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
|
||||
const window = 200
|
||||
if len(lines) > window {
|
||||
lines = lines[len(lines)-window:]
|
||||
}
|
||||
failureDetail := ""
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
line := lines[index]
|
||||
if prefix := strings.Index(line, "] "); prefix >= 0 {
|
||||
line = line[prefix+2:]
|
||||
}
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return diagStateOK, "frpc 已登录 frps"
|
||||
}
|
||||
if failureDetail == "" && isLoginFailureLine(line) {
|
||||
failureDetail = line
|
||||
}
|
||||
}
|
||||
if failureDetail != "" {
|
||||
if len(failureDetail) > 160 {
|
||||
failureDetail = failureDetail[:160] + "…"
|
||||
}
|
||||
return diagStateFailed, "frpc 登录失败:" + failureDetail
|
||||
}
|
||||
return diagStateUnknown, "日志中暂无登录结果记录"
|
||||
}
|
||||
|
||||
func isLoginFailureLine(line string) bool {
|
||||
if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(line, "token") && strings.Contains(line, "error")
|
||||
}
|
||||
|
||||
// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
|
||||
func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
|
||||
ip := resolvePublicIP(ctx, host)
|
||||
if ip == "" {
|
||||
// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
|
||||
return strings.TrimSpace(host), true
|
||||
}
|
||||
s.locationMu.Lock()
|
||||
cached, ok := s.locations[ip]
|
||||
s.locationMu.Unlock()
|
||||
if ok && time.Now().Before(cached.expiresAt) {
|
||||
return cached.value, true
|
||||
}
|
||||
return ip, false
|
||||
}
|
||||
|
||||
// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
|
||||
func (s *Service) refreshLocation(host string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
_ = s.serverLocation(ctx, host)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
|
||||
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
|
||||
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
|
||||
func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
|
||||
// token 认证才需要取同一份 token;none / oidc 不涉及
|
||||
token := ""
|
||||
if server.Options.AuthMethod == "token" {
|
||||
encoded, err := s.store.Token(ctx, server.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if encoded != "" {
|
||||
token, err = s.secretBox.Decrypt(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dashboardOn := server.Options.DashboardPort > 0 && strings.TrimSpace(server.Options.DashboardAddr) != ""
|
||||
hasVhost, vhostErr := s.hasVhostProxies(ctx, server.ID)
|
||||
// subDomainHost 缺口检测:frpc 侧规则填了 subdomain 时,frps 必须配置 subDomainHost 才生效
|
||||
proxies, proxyErr := s.store.ListProxiesByServer(ctx, server.ID)
|
||||
hasSubdomain := false
|
||||
if proxyErr == nil {
|
||||
for _, p := range proxies {
|
||||
if strings.TrimSpace(p.Options.Subdomain) != "" {
|
||||
hasSubdomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
|
||||
b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;INI 自 v0.52.0 起已被官方弃用)\n")
|
||||
b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
|
||||
// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
|
||||
b.WriteString("bindAddr = \"0.0.0.0\"\n")
|
||||
b.WriteString(fmt.Sprintf("bindPort = %d\n", server.ServerPort))
|
||||
switch server.Options.Protocol {
|
||||
case "kcp":
|
||||
b.WriteString(fmt.Sprintf("kcpBindPort = %d # frpc 使用 kcp 协议接入;官方允许与 bindPort 相同(UDP)\n", server.ServerPort))
|
||||
case "quic":
|
||||
b.WriteString(fmt.Sprintf("quicBindPort = %d # frpc 使用 quic 协议接入;UDP 端口官方允许与 bindPort 相同,如冲突请改为独立端口\n", server.ServerPort))
|
||||
}
|
||||
if vhostErr == nil && hasVhost {
|
||||
// 官方默认 vhost 端口为 0(禁用)。默认 8080/8443:80/443 由 Nginx 占用(Nginx 80/443 →
|
||||
// proxy_pass 127.0.0.1:8080 → frps vhost → 隧道),可在服务端表单自定义,与 Nginx upstream 同源
|
||||
httpPort := server.Options.VhostHTTPPort
|
||||
if httpPort <= 0 {
|
||||
httpPort = 8080
|
||||
}
|
||||
httpsPort := server.Options.VhostHTTPSPort
|
||||
if httpsPort <= 0 {
|
||||
httpsPort = 8443
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
|
||||
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
|
||||
}
|
||||
// 子域名根:规则填了 subdomain 时必须配置,否则 subdomain 不生效
|
||||
if host := strings.TrimSpace(server.Options.SubDomainHost); host != "" {
|
||||
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
|
||||
}
|
||||
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
|
||||
if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
|
||||
b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
|
||||
}
|
||||
// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
|
||||
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
|
||||
b.WriteString(fmt.Sprintf("tcpmuxHTTPConnectPort = %d\n", port))
|
||||
}
|
||||
// 强制 TLS:公网 frps 标准安全实践;开启后 frpc 不开 TLS 将无法连接
|
||||
if server.Options.TLSForce {
|
||||
b.WriteString("transport.tls.force = true # 只接受 TLS 连接(frpc 侧必须启用 transport.tls.enable)\n")
|
||||
}
|
||||
// 传输参数与 frpc 侧同源:两端不一致会导致连不上或池被截断
|
||||
if !server.Options.TCPMux {
|
||||
b.WriteString("transport.tcpMux = false # 与 frpc 侧 tcp_mux 同源(官方默认 true,仅在关闭时输出)\n")
|
||||
}
|
||||
if server.Options.PoolCount > 0 {
|
||||
b.WriteString(fmt.Sprintf("transport.maxPoolCount = %d # 不低于 frpc 侧 pool_count,避免连接池被静默截断\n", server.Options.PoolCount))
|
||||
}
|
||||
// 端口白名单:逗号分隔的单端口或「起-止」范围,如 6000,6005-6010,7000-8000;留空不限制
|
||||
if ports := strings.TrimSpace(server.Options.AllowPorts); ports != "" {
|
||||
b.WriteString(fmt.Sprintf("allowPorts = [\n%s\n]\n", renderAllowPorts(ports)))
|
||||
}
|
||||
// 每客户端可绑定端口上限;0 表示不限制
|
||||
if maxPorts := server.Options.MaxPortsPerClient; maxPorts > 0 {
|
||||
b.WriteString(fmt.Sprintf("maxPortsPerClient = %d\n", maxPorts))
|
||||
}
|
||||
// frps 文件日志:journalctl 只保留尾部,排障需完整历史时开启;写入 /var/log/frps.log
|
||||
if server.Options.FrpsLogToFile {
|
||||
maxDays := server.Options.FrpsLogMaxDays
|
||||
if maxDays <= 0 {
|
||||
maxDays = 3
|
||||
}
|
||||
b.WriteString("\nlog.to = \"/var/log/frps.log\"\n")
|
||||
b.WriteString(fmt.Sprintf("log.maxDays = %d\n", maxDays))
|
||||
}
|
||||
if !server.TLS {
|
||||
b.WriteString("# 安全提示:frpc 侧未启用 TLS,frps 可加 transport.tls.force = true 强制 TLS(开启后 frpc 不开 TLS 将无法连接)\n")
|
||||
}
|
||||
switch server.Options.AuthMethod {
|
||||
case "token":
|
||||
if token != "" {
|
||||
// 点式键与官方 frps_full_example.toml 同款(TOML 语义上与 [auth] 表等价)
|
||||
b.WriteString("\nauth.method = \"token\"\n")
|
||||
b.WriteString(fmt.Sprintf("auth.token = %q\n", token))
|
||||
} else {
|
||||
b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Fonu 重新生成本文件以保持两端一致\n")
|
||||
}
|
||||
case "none":
|
||||
b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n")
|
||||
b.WriteString("# 公网暴露的 frps 建议配置认证或用 transport.tls.force = true 强制 TLS,防止任意客户端接入\n")
|
||||
case "oidc":
|
||||
b.WriteString("\n# frpc 侧使用 OIDC 认证;frps 侧的 oidc.* 校验参数(issuer、client_id 等)依赖你的身份提供商,\n")
|
||||
b.WriteString("# 请参考官方 frps_full_example.toml 的 [auth] oidc 段手动配置\n")
|
||||
}
|
||||
if server.TLS {
|
||||
b.WriteString("\n# frpc 侧已启用 transport.tls.enable;frps 的 transport.tls.force 默认为 false,无需额外 TLS 配置即可接受 TLS 连接\n")
|
||||
}
|
||||
|
||||
// webServer(frps 管理接口):与客户端 options.dashboard_* 同源
|
||||
if dashboardOn {
|
||||
b.WriteString("\nwebServer.addr = \"0.0.0.0\"\n")
|
||||
b.WriteString(fmt.Sprintf("webServer.port = %d\n", server.Options.DashboardPort))
|
||||
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
|
||||
b.WriteString(fmt.Sprintf("webServer.user = %q\n", user))
|
||||
}
|
||||
if encoded, err := s.store.DashboardPassword(ctx, server.ID); err == nil && encoded != "" {
|
||||
if password, err := s.secretBox.Decrypt(encoded); err == nil && password != "" {
|
||||
b.WriteString(fmt.Sprintf("webServer.password = %q\n", password))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Fonu 所在机器访问该端口\n")
|
||||
}
|
||||
|
||||
if hasSubdomain {
|
||||
b.WriteString("\n# 注意:该服务端下存在填写 subdomain 的规则;frps 需配置 subDomainHost(如 frps.example.com)后,\n")
|
||||
b.WriteString("# 规则的 subdomain 字段才会生效为 sub.frps.example.com;域名 DNS 也需指向本 VPS\n")
|
||||
}
|
||||
b.WriteString("\n# 防火墙放行清单(按本服务端当前配置生成)\n")
|
||||
b.WriteString(fmt.Sprintf("# %d/tcp # frps 控制端口\n", server.ServerPort))
|
||||
if dashboardOn {
|
||||
b.WriteString(fmt.Sprintf("# %d/tcp # frps 管理接口(webServer)\n", server.Options.DashboardPort))
|
||||
}
|
||||
if vhostErr == nil && hasVhost {
|
||||
httpPort := server.Options.VhostHTTPPort
|
||||
if httpPort <= 0 {
|
||||
httpPort = 8080
|
||||
}
|
||||
httpsPort := server.Options.VhostHTTPSPort
|
||||
if httpsPort <= 0 {
|
||||
httpsPort = 8443
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("# %d, %d/tcp # 上方 vhostHTTPPort / vhostHTTPSPort 对应端口(仅本机 Nginx 回源需要,可不对公网开放)\n", httpPort, httpsPort))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// hasVhostProxies 判断该服务端下是否存在依赖 vhost 端口的规则(http/https/tcpmux)。
|
||||
func (s *Service) hasVhostProxies(ctx context.Context, serverID int64) (bool, error) {
|
||||
proxies, err := s.store.ListProxiesByServer(ctx, serverID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, p := range proxies {
|
||||
if p.Type == "http" || p.Type == "https" || p.Type == "tcpmux" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// renderAllowPorts 把表单端口白名单文本(如 "6000,6005-6010,7000-8000")渲染为 TOML 数组行;
|
||||
// 非法片段直接忽略,不中断生成。
|
||||
func renderAllowPorts(ports string) string {
|
||||
var lines []string
|
||||
for _, part := range strings.Split(ports, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if start, end, ok := strings.Cut(part, "-"); ok {
|
||||
startN, err1 := strconv.Atoi(strings.TrimSpace(start))
|
||||
endN, err2 := strconv.Atoi(strings.TrimSpace(end))
|
||||
if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 {
|
||||
lines = append(lines, fmt.Sprintf("{ start = %d, end = %d },", startN, endN))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 {
|
||||
lines = append(lines, fmt.Sprintf("{ single = %d },", n))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
|
||||
func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
|
||||
server, err := s.store.GetServer(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.buildFRPSServerConfig(ctx, server)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const locationCacheTTL = 24 * time.Hour
|
||||
|
||||
type locationCache struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type ipLocationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func (s *Service) serverLocation(ctx context.Context, host string) string {
|
||||
ip := resolvePublicIP(ctx, host)
|
||||
if ip == "" {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
s.locationMu.Lock()
|
||||
if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
|
||||
s.locationMu.Unlock()
|
||||
return cached.value
|
||||
}
|
||||
s.locationMu.Unlock()
|
||||
value := lookupIPLocation(ctx, ip)
|
||||
// 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
|
||||
s.locationMu.Lock()
|
||||
s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
|
||||
s.locationMu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
func resolvePublicIP(ctx context.Context, host string) string {
|
||||
if ip := net.ParseIP(host); isPublicIP(ip) {
|
||||
return ip.String()
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if isPublicIP(address.IP) {
|
||||
return address.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
func lookupIPLocation(ctx context.Context, ip string) string {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
|
||||
if err != nil {
|
||||
return "未定位"
|
||||
}
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "未定位"
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "未定位"
|
||||
}
|
||||
var data ipLocationResponse
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
|
||||
return "未定位"
|
||||
}
|
||||
return formatLocation(data)
|
||||
}
|
||||
|
||||
func formatLocation(data ipLocationResponse) string {
|
||||
parts := []string{data.City, data.Region, data.Country}
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "未定位"
|
||||
}
|
||||
return strings.Join(values, " · ")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatLocation(t *testing.T) {
|
||||
value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
|
||||
if value != "Hong Kong · China" {
|
||||
t.Fatalf("位置格式错误:%s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
if !isPublicIP(net.ParseIP("38.95.74.87")) {
|
||||
t.Fatal("公网 IP 应被识别")
|
||||
}
|
||||
if isPublicIP(net.ParseIP("192.168.1.1")) {
|
||||
t.Fatal("私网 IP 不应被定位")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
bin string
|
||||
configPath string
|
||||
logPath string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
orphan *os.Process
|
||||
orphanPID int
|
||||
pid int
|
||||
version string
|
||||
started time.Time
|
||||
reloaded time.Time
|
||||
lastErr string
|
||||
}
|
||||
|
||||
func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
|
||||
return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) Verify(ctx context.Context, configPath string) error {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) VerifyBinary(ctx context.Context) error {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.runningLocked() {
|
||||
return nil
|
||||
}
|
||||
bin, err := m.binaryPathLocked()
|
||||
if err != nil {
|
||||
m.lastErr = err.Error()
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(bin, "-c", m.configPath)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
m.lastErr = err.Error()
|
||||
return fmt.Errorf("启动 frpc 失败:%w", err)
|
||||
}
|
||||
m.cmd = cmd
|
||||
m.pid = cmd.Process.Pid
|
||||
m.started = time.Now().UTC()
|
||||
m.reloaded = m.started
|
||||
m.lastErr = ""
|
||||
go m.wait(cmd, logFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Restart(ctx context.Context) error {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start()
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
cmd := m.cmd
|
||||
orphan := m.orphan
|
||||
m.mu.Unlock()
|
||||
if cmd == nil && orphan == nil {
|
||||
return nil
|
||||
}
|
||||
process := orphan
|
||||
if cmd != nil {
|
||||
process = cmd.Process
|
||||
}
|
||||
if process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := process.Kill(); err != nil {
|
||||
return fmt.Errorf("停止 frpc 失败:%w", err)
|
||||
}
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if !m.running() {
|
||||
m.mu.Lock()
|
||||
m.lastErr = ""
|
||||
m.orphan = nil
|
||||
m.orphanPID = 0
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("等待 frpc 停止超时")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.statusLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) statusLocked() RuntimeStatus {
|
||||
status := RuntimeStatus{
|
||||
Status: "stopped",
|
||||
PID: m.pid,
|
||||
Version: m.version,
|
||||
LastError: m.lastErr,
|
||||
}
|
||||
if m.runningLocked() {
|
||||
status.Status = "running"
|
||||
if status.PID == 0 {
|
||||
status.PID = m.orphanPID
|
||||
}
|
||||
} else if m.orphan != nil {
|
||||
status.PID = 0
|
||||
}
|
||||
if !m.started.IsZero() {
|
||||
status.LastStartedAt = m.started.Format(time.RFC3339)
|
||||
}
|
||||
if !m.reloaded.IsZero() {
|
||||
status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (m *Manager) RefreshVersion(ctx context.Context) {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, bin, "--version").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.version = strings.TrimSpace(string(output))
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
|
||||
err := cmd.Wait()
|
||||
logFile.Close()
|
||||
m.mu.Lock()
|
||||
if m.cmd != cmd {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.cmd = nil
|
||||
m.pid = 0
|
||||
if err != nil {
|
||||
// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
|
||||
m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
|
||||
m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
|
||||
func tailFile(path string, maxLines int) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[len(lines)-maxLines:]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
|
||||
func (m *Manager) running() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.runningLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) runningLocked() bool {
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
return true
|
||||
}
|
||||
return m.orphan != nil && processAlive(m.orphanPID)
|
||||
}
|
||||
|
||||
func (m *Manager) Attach(pid int) bool {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil || !processMatches(pid, bin, m.configPath) {
|
||||
return false
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.orphan = process
|
||||
m.orphanPID = pid
|
||||
m.pid = pid
|
||||
m.lastErr = ""
|
||||
m.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
alive, _ := probeProcess(pid)
|
||||
return alive
|
||||
}
|
||||
|
||||
func processMatches(pid int, binary, configPath string) bool {
|
||||
alive, commandLine := probeProcess(pid)
|
||||
if !alive {
|
||||
return false
|
||||
}
|
||||
commandLine = strings.ToLower(filepath.ToSlash(commandLine))
|
||||
return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
|
||||
strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
|
||||
}
|
||||
|
||||
func (m *Manager) BinaryPath() (string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.binaryPathLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) binaryPathLocked() (string, error) {
|
||||
if strings.ContainsAny(m.bin, `/\\`) {
|
||||
if _, err := os.Stat(m.bin); err != nil {
|
||||
return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
|
||||
}
|
||||
return m.bin, nil
|
||||
}
|
||||
path, err := exec.LookPath(m.bin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_FRPC_BIN")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetBinary(bin string) {
|
||||
m.mu.Lock()
|
||||
m.bin = bin
|
||||
m.version = ""
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) LogPath() string {
|
||||
return filepath.Clean(m.logPath)
|
||||
}
|
||||
|
||||
func safeCommandOutput(output []byte, err error) string {
|
||||
text := strings.TrimSpace(string(output))
|
||||
if text == "" {
|
||||
return err.Error()
|
||||
}
|
||||
if len(text) > 500 {
|
||||
return text[:500]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package frp
|
||||
|
||||
import "time"
|
||||
|
||||
type RuntimeInfo struct {
|
||||
Platform string `json:"platform"`
|
||||
ConfiguredBin string `json:"configured_bin"`
|
||||
ActiveBin string `json:"active_bin,omitempty"`
|
||||
ActiveVersion string `json:"active_version,omitempty"`
|
||||
DetectedVersion string `json:"detected_version,omitempty"`
|
||||
DownloadProxy string `json:"download_proxy"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
ConfigPaths []string `json:"config_paths,omitempty"`
|
||||
ConfigExists bool `json:"config_exists"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
Version string `json:"version"`
|
||||
PublishedAt string `json:"published_at,omitempty"`
|
||||
AssetName string `json:"asset_name"`
|
||||
Size int64 `json:"size"`
|
||||
Checksum string `json:"-"`
|
||||
AssetID int64 `json:"-"`
|
||||
}
|
||||
|
||||
type Binary struct {
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// DiagnosticCheck 单项诊断结果;State 取 ok / failed / unknown(unknown 表示无法确认,不等于失败)。
|
||||
type DiagnosticCheck struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
// ServerDiagnostics 服务端诊断结果。Available/LatencyMS/Error 保持 tcp_reachable 口径
|
||||
// 兼容历史延迟曲线;定位等非阻断信息只出现在 Checks 中,不参与 Error。
|
||||
type ServerDiagnostics struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
Available bool `json:"available"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Checks []DiagnosticCheck `json:"checks"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ServerSample struct {
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Available bool `json:"available"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ServerStatusDetail struct {
|
||||
Available bool `json:"available"`
|
||||
Availability float64 `json:"availability"`
|
||||
AverageLatency int64 `json:"average_latency_ms"`
|
||||
PeakLatency int64 `json:"peak_latency_ms"`
|
||||
Samples []ServerSample `json:"samples"`
|
||||
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type ServerDetail struct {
|
||||
Server Server `json:"server"`
|
||||
Status ServerStatusDetail `json:"status"`
|
||||
Proxies []Proxy `json:"proxies"`
|
||||
}
|
||||
|
||||
type DownloadTask struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
Total int64 `json:"total"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentURL string `json:"agent_url,omitempty"`
|
||||
AgentConfigured bool `json:"agent_configured"`
|
||||
SSHConfigured bool `json:"ssh_configured"`
|
||||
MgmtEnabled bool `json:"mgmt_enabled"`
|
||||
SSHHost string `json:"ssh_host,omitempty"`
|
||||
SSHPort int `json:"ssh_port,omitempty"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
SSHAuth string `json:"ssh_auth,omitempty"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
TLS bool `json:"tls_enabled"`
|
||||
TLSServerName string `json:"tls_server_name,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"has_token"`
|
||||
HasOIDCSecret bool `json:"has_oidc_client_secret"`
|
||||
DashboardHasPwd bool `json:"dashboard_has_pwd"`
|
||||
Options ServerOptions `json:"options"`
|
||||
BootStatus string `json:"boot_status"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
ProcessState string `json:"process_state"`
|
||||
ConnectionState string `json:"connection_state"`
|
||||
StateSource string `json:"state_source"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
LastConnectedAt string `json:"last_connected_at,omitempty"`
|
||||
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastStartedAt string `json:"last_started_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ServerRuntimeStatus struct {
|
||||
ServerID int64
|
||||
Status string
|
||||
DesiredState string
|
||||
ProcessState string
|
||||
ConnectionState string
|
||||
StateSource string
|
||||
ProcessPID int
|
||||
ExitCode int
|
||||
LastError string
|
||||
LastStartedAt string
|
||||
LastConnectedAt string
|
||||
LastDisconnectedAt string
|
||||
}
|
||||
|
||||
type ServerOptions struct {
|
||||
User string `json:"user,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
OIDCClientID string `json:"oidc_client_id,omitempty"`
|
||||
OIDCAudience string `json:"oidc_audience,omitempty"`
|
||||
OIDCScope string `json:"oidc_scope,omitempty"`
|
||||
OIDCTokenEndpointURL string `json:"oidc_token_endpoint_url,omitempty"`
|
||||
OIDCTrustedCAPath string `json:"oidc_trusted_ca_path,omitempty"`
|
||||
OIDCInsecureSkipVerify bool `json:"oidc_insecure_skip_verify"`
|
||||
OIDCProxyURL string `json:"oidc_proxy_url,omitempty"`
|
||||
// VPS frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 生成器和 Nginx upstream 同源
|
||||
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
|
||||
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
|
||||
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
|
||||
SubDomainHost string `json:"sub_domain_host,omitempty"`
|
||||
Custom404Page string `json:"custom_404_page,omitempty"`
|
||||
TLSForce bool `json:"tls_force"`
|
||||
AllowPorts string `json:"allow_ports,omitempty"`
|
||||
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmux_http_connect_port,omitempty"`
|
||||
FrpsLogToFile bool `json:"frps_log_to_file"`
|
||||
FrpsLogMaxDays int `json:"frps_log_max_days,omitempty"`
|
||||
// frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息,不写入 frpc.toml
|
||||
DashboardAddr string `json:"dashboard_addr,omitempty"`
|
||||
DashboardPort int `json:"dashboard_port,omitempty"`
|
||||
DashboardUser string `json:"dashboard_user,omitempty"`
|
||||
DashboardHasPwd bool `json:"dashboard_has_pwd"`
|
||||
LogLevel string `json:"log_level"`
|
||||
LogMaxDays int `json:"log_max_days"`
|
||||
Protocol string `json:"protocol"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
DialServerTimeout int `json:"dial_server_timeout"`
|
||||
DialServerKeepalive int `json:"dial_server_keepalive"`
|
||||
ConnectServerLocalIP string `json:"connect_server_local_ip,omitempty"`
|
||||
DNSServer string `json:"dns_server,omitempty"`
|
||||
PoolCount int `json:"pool_count"`
|
||||
TCPMux bool `json:"tcp_mux"`
|
||||
TCPMuxKeepaliveInterval int `json:"tcp_mux_keepalive_interval"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `json:"heartbeat_timeout"`
|
||||
LoginFailExit bool `json:"login_fail_exit"`
|
||||
TLSDisableCustomFirstByte bool `json:"tls_disable_custom_first_byte"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
TLSCertificateConfigured bool `json:"tls_certificate_configured"`
|
||||
TLSKeyConfigured bool `json:"tls_key_configured"`
|
||||
TLSTrustedCAConfigured bool `json:"tls_trusted_ca_configured"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"local_ip"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemotePort *int `json:"remote_port,omitempty"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
|
||||
Options ProxyOptions `json:"options"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ServerInput struct {
|
||||
AgentURL string
|
||||
AgentToken string
|
||||
ClearAgentToken bool
|
||||
SSHHost string
|
||||
SSHPort int
|
||||
SSHUser string
|
||||
SSHAuth string
|
||||
SSHSecret string
|
||||
ClearSSHSecret bool
|
||||
MgmtEnabled bool
|
||||
|
||||
Name string
|
||||
ServerAddr string
|
||||
ServerPort int
|
||||
TLS bool
|
||||
TLSServerName string
|
||||
Enabled bool
|
||||
Token string
|
||||
ClearToken bool
|
||||
Options ServerOptions
|
||||
OIDCClientSecret string
|
||||
ClearOIDCClientSecret bool
|
||||
DashboardPassword string
|
||||
ClearDashboardPassword bool
|
||||
TLSCertificate string
|
||||
TLSKey string
|
||||
TLSTrustedCA string
|
||||
ClearTLSCertificate bool
|
||||
ClearTLSKey bool
|
||||
ClearTLSTrustedCA bool
|
||||
}
|
||||
|
||||
type ProxyInput struct {
|
||||
ServerID int64
|
||||
Name string
|
||||
Type string
|
||||
LocalIP string
|
||||
LocalPort int
|
||||
RemotePort *int
|
||||
CustomDomains []string
|
||||
HostHeaderRewrite string
|
||||
Options ProxyOptions
|
||||
Enabled bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// ProxyOptions 对应 FRP [[proxies]] 中除基础地址外的扩展配置。
|
||||
type ProxyOptions struct {
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
// WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头),不写入 frpc.toml
|
||||
WebSocket bool `json:"websocket,omitempty"`
|
||||
// 公网反代进阶选项(不写入 frpc.toml):强制跳转 HTTPS / IP 白名单 / Basic Auth 用户名(密码由 agent 生成)
|
||||
RedirectHTTPS bool `json:"redirect_https,omitempty"`
|
||||
AllowIPs []string `json:"allow_ips,omitempty"`
|
||||
BasicAuthUser string `json:"basic_auth_user,omitempty"`
|
||||
// 公网反代第一梯队部署设置(同属公网 Nginx vhost 选项,不写入 frpc.toml)
|
||||
DenyIPs []string `json:"deny_ips,omitempty"` // IP 黑名单
|
||||
BasicAuthPassword string `json:"basic_auth_password,omitempty"` // 自定义 Basic Auth 密码;空则首次自动生成
|
||||
HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS
|
||||
ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小,如 50m
|
||||
ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
|
||||
SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头
|
||||
TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
|
||||
// 第二梯队部署设置(同为公网 Nginx vhost 选项)
|
||||
RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
|
||||
RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
|
||||
ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
|
||||
ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP
|
||||
Locations []string `json:"locations,omitempty"`
|
||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||
HTTPUser string `json:"http_user,omitempty"`
|
||||
HTTPPassword string `json:"http_password,omitempty"`
|
||||
Multiplexer string `json:"multiplexer,omitempty"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
AllowUsers []string `json:"allow_users,omitempty"`
|
||||
Transport ProxyTransportOptions `json:"transport"`
|
||||
HealthCheck ProxyHealthCheckOptions `json:"health_check"`
|
||||
LoadBalancer ProxyLoadBalancer `json:"load_balancer"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||
Plugin *ProxyPluginOptions `json:"plugin,omitempty"`
|
||||
NATTraversal *ProxyNATTraversal `json:"nat_traversal,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyTransportOptions struct {
|
||||
BandwidthLimit string `json:"bandwidth_limit,omitempty"`
|
||||
BandwidthLimitMode string `json:"bandwidth_limit_mode,omitempty"`
|
||||
UseEncryption bool `json:"use_encryption"`
|
||||
UseCompression bool `json:"use_compression"`
|
||||
ProxyProtocolVersion string `json:"proxy_protocol_version,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyHealthCheckOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
IntervalSeconds int `json:"interval_seconds,omitempty"`
|
||||
MaxFailed int `json:"max_failed,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
HTTPHeaders []ProxyHeader `json:"http_headers,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyHeader struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ProxyLoadBalancer struct {
|
||||
Group string `json:"group,omitempty"`
|
||||
GroupKey string `json:"group_key,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyPluginOptions struct {
|
||||
Type string `json:"type"`
|
||||
UnixPath string `json:"unix_path,omitempty"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
StripPrefix string `json:"strip_prefix,omitempty"`
|
||||
LocalAddr string `json:"local_addr,omitempty"`
|
||||
HTTPUser string `json:"http_user,omitempty"`
|
||||
HTTPPassword string `json:"http_password,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
CRTPath string `json:"crt_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
|
||||
DestinationIP string `json:"destination_ip,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyNATTraversal struct {
|
||||
DisableAssistedAddrs bool `json:"disable_assisted_addrs"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Version string `json:"frpc_version,omitempty"`
|
||||
LastStartedAt string `json:"last_started_at,omitempty"`
|
||||
LastReloadedAt string `json:"last_reloaded_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Configured bool `json:"configured"`
|
||||
EnabledProxies int `json:"enabled_proxies"`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) managerForServer(serverID int64) *Manager {
|
||||
s.managersMu.Lock()
|
||||
defer s.managersMu.Unlock()
|
||||
if manager, ok := s.managers[serverID]; ok {
|
||||
return manager
|
||||
}
|
||||
bin, err := s.manager.BinaryPath()
|
||||
if err != nil {
|
||||
bin = s.cfg.FrpcBin
|
||||
}
|
||||
manager := NewManager(
|
||||
bin,
|
||||
s.serverConfigPath(serverID),
|
||||
s.serverLogPath(serverID),
|
||||
s.logger,
|
||||
)
|
||||
s.managers[serverID] = manager
|
||||
return manager
|
||||
}
|
||||
|
||||
func (s *Service) serverConfigPath(serverID int64) string {
|
||||
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
|
||||
}
|
||||
|
||||
func (s *Service) serverLogPath(serverID int64) string {
|
||||
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
|
||||
}
|
||||
|
||||
func (s *Service) managerSnapshot() map[int64]*Manager {
|
||||
s.managersMu.RLock()
|
||||
defer s.managersMu.RUnlock()
|
||||
result := make(map[int64]*Manager, len(s.managers))
|
||||
for id, manager := range s.managers {
|
||||
result[id] = manager
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) setManagerBinary(path string) {
|
||||
s.manager.SetBinary(path)
|
||||
for _, manager := range s.managerSnapshot() {
|
||||
manager.SetBinary(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) removeManager(serverID int64) {
|
||||
s.managersMu.Lock()
|
||||
delete(s.managers, serverID)
|
||||
s.managersMu.Unlock()
|
||||
}
|
||||
|
||||
func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
|
||||
result := RuntimeStatus{Status: "stopped"}
|
||||
for _, status := range statuses {
|
||||
if status.Status == "running" {
|
||||
result.Status = "running"
|
||||
}
|
||||
if status.Configured {
|
||||
result.Configured = true
|
||||
}
|
||||
result.EnabledProxies += status.EnabledProxies
|
||||
if result.PID == 0 && status.PID != 0 {
|
||||
result.PID = status.PID
|
||||
}
|
||||
if status.LastStartedAt > result.LastStartedAt {
|
||||
result.LastStartedAt = status.LastStartedAt
|
||||
}
|
||||
if status.LastReloadedAt > result.LastReloadedAt {
|
||||
result.LastReloadedAt = status.LastReloadedAt
|
||||
}
|
||||
if result.LastError == "" && status.LastError != "" {
|
||||
result.LastError = status.LastError
|
||||
}
|
||||
if status.Version != "" && result.Version == "" {
|
||||
result.Version = status.Version
|
||||
}
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
result.Status = "unknown"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
|
||||
var messages []string
|
||||
for id, manager := range s.managerSnapshot() {
|
||||
if err := action(manager); err != nil {
|
||||
messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
|
||||
}
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(messages, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package frp
|
||||
|
||||
import "os"
|
||||
|
||||
func probeProcess(pid int) (bool, string) {
|
||||
if pid <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, process.String()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func probeProcess(pid int) (bool, string) {
|
||||
if pid <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil {
|
||||
commandLine := strings.ReplaceAll(string(data), "\x00", " ")
|
||||
return strings.TrimSpace(commandLine) != "", strings.TrimSpace(commandLine)
|
||||
}
|
||||
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user