chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+89
View File
@@ -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
}
+37
View File
@@ -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)
}
}
+98
View File
@@ -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
}
+23
View File
@@ -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")
}
}
+166
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+117
View File
@@ -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
}
+26
View File
@@ -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")
}
}
+183
View File
@@ -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
}
+621
View File
@@ -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
}
+37
View File
@@ -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)
}
+62
View File
@@ -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
+131
View File
@@ -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,
})
}
+96
View File
@@ -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)
}
}
+73
View File
@@ -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"})
}
+151
View File
@@ -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)
}
+29
View File
@@ -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))
}
+157
View File
@@ -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)
}
+37
View File
@@ -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
}
+20
View File
@@ -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))
}
+703
View File
@@ -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,
}
}
+128
View File
@@ -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
}
+65
View File
@@ -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)
}
}
+43
View File
@@ -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
}
+136
View File
@@ -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)
}
+33
View File
@@ -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": "测试通知已发送"})
}
+260
View File
@@ -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
}
+60
View File
@@ -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
}
+64
View File
@@ -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...)
}
}
+64
View File
@@ -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())
}
}
+227
View File
@@ -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(),
)
}
})
}
+204
View File
@@ -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
}
+183
View File
@@ -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
}
+18
View File
@@ -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)
}
}
+24
View File
@@ -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,
})
}
}
+36
View File
@@ -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"])
}
}
+229
View File
@@ -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")
}
+204
View File
@@ -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
}
+41
View File
@@ -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)
}
}
+170
View File
@@ -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
}
+214
View File
@@ -0,0 +1,214 @@
package certificate
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"strings"
"time"
)
type ImportInfo struct {
Primary string
Domains []string
Wildcard bool
ExpiresAt time.Time
}
func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
cert, err := parseCertificate(certPEM)
if err != nil {
return ImportInfo{}, err
}
key, err := parsePrivateKey(keyPEM)
if err != nil {
return ImportInfo{}, err
}
if !publicKeysMatch(cert.PublicKey, key) {
return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
}
domains, err := ExtractCertDomains(cert)
if err != nil {
return ImportInfo{}, err
}
return ImportInfo{
Primary: primaryDomain(domains),
Domains: domains,
Wildcard: hasWildcardDomain(domains),
ExpiresAt: cert.NotAfter.UTC(),
}, nil
}
func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
info, err := InspectImport(certPEM, keyPEM)
if err != nil {
return time.Time{}, err
}
return info.ExpiresAt, nil
}
func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
seen := map[string]bool{}
var domains []string
add := func(name string) error {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
if name == "" || net.ParseIP(name) != nil {
return nil
}
if seen[name] {
return nil
}
if strings.HasPrefix(name, "*.") {
if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
return err
}
} else if err := validateDomainLabel(name); err != nil {
return err
}
seen[name] = true
domains = append(domains, name)
return nil
}
for _, name := range cert.DNSNames {
if err := add(name); err != nil {
return nil, err
}
}
if err := add(cert.Subject.CommonName); err != nil {
return nil, err
}
if len(domains) == 0 {
return nil, fmt.Errorf("证书中未包含有效域名")
}
return domains, nil
}
func primaryDomain(domains []string) string {
for _, domain := range domains {
if !strings.HasPrefix(domain, "*.") {
return domain
}
}
return strings.TrimPrefix(domains[0], "*.")
}
func hasWildcardDomain(domains []string) bool {
for _, domain := range domains {
if strings.HasPrefix(domain, "*.") {
return true
}
}
return false
}
func validateDomainLabel(domain string) error {
if domain == "" {
return fmt.Errorf("域名格式无效")
}
parts := strings.Split(domain, ".")
for _, part := range parts {
if part == "" || len(part) > 63 {
return fmt.Errorf("域名格式无效")
}
}
return nil
}
func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
rest := certPEM
for {
block, remaining := pem.Decode(rest)
if block == nil {
return nil, fmt.Errorf("证书 PEM 格式无效")
}
if block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析证书失败:%w", err)
}
return cert, nil
}
rest = remaining
if len(rest) == 0 {
break
}
}
return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
}
func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, fmt.Errorf("私钥 PEM 格式无效")
}
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
return x509.ParsePKCS8PrivateKey(block.Bytes)
default:
return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
}
}
func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
switch pub := certKey.(type) {
case *rsa.PublicKey:
priv, ok := privateKey.(*rsa.PrivateKey)
return ok && pub.N.Cmp(priv.N) == 0
case *ecdsa.PublicKey:
priv, ok := privateKey.(*ecdsa.PrivateKey)
return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
default:
return false
}
}
func NormalizePEM(v string) []byte {
return []byte(strings.TrimSpace(v))
}
func ResolvePEMSource(content, path string) ([]byte, error) {
path = strings.TrimSpace(path)
if path != "" {
return ReadPEMFile(path)
}
data := NormalizePEM(content)
if len(data) == 0 {
return nil, fmt.Errorf("内容不能为空")
}
return data, nil
}
func ReadPEMFile(path string) ([]byte, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("文件路径不能为空")
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s 是目录,不是文件", path)
}
if info.Size() > 1<<20 {
return nil, fmt.Errorf("文件过大(最大 1MB)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取文件失败:%w", err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, fmt.Errorf("文件内容为空")
}
return data, nil
}
+118
View File
@@ -0,0 +1,118 @@
package certificate
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"strings"
"testing"
"time"
)
func TestReadPEMFile(t *testing.T) {
certPEM, keyPEM := generateTestCert("example.com")
certPath := t.TempDir() + "/cert.pem"
keyPath := t.TempDir() + "/key.pem"
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatal(err)
}
readCert, err := ReadPEMFile(certPath)
if err != nil {
t.Fatal(err)
}
if string(readCert) != string(certPEM) {
t.Fatal("cert content mismatch")
}
got, err := ResolvePEMSource("", certPath)
if err != nil || string(got) != string(certPEM) {
t.Fatalf("ResolvePEMSource path: %v", err)
}
}
func TestInspectImport(t *testing.T) {
certPEM, keyPEM := generateTestCertWithSANs([]string{"example.com", "*.example.com", "nas.example.com"})
info, err := InspectImport(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
if info.Primary != "example.com" {
t.Fatalf("primary: %s", info.Primary)
}
if !info.Wildcard {
t.Fatal("expected wildcard")
}
if len(info.Domains) != 3 {
t.Fatalf("domains: %v", info.Domains)
}
}
func TestValidatePEM(t *testing.T) {
certPEM, keyPEM := generateTestCert("example.com")
expiresAt, err := ValidatePEM(certPEM, keyPEM)
if err != nil {
t.Fatalf("ValidatePEM: %v", err)
}
if expiresAt.Before(time.Now()) {
t.Fatalf("expected future expiry, got %v", expiresAt)
}
_, err = ValidatePEM(certPEM, []byte("not a key"))
if err == nil {
t.Fatal("expected error for invalid key")
}
_, wrongKeyPEM := generateTestCert("other.example.com")
_, err = ValidatePEM(certPEM, wrongKeyPEM)
if err == nil {
t.Fatal("expected mismatch error")
}
}
func generateTestCert(domain string) ([]byte, []byte) {
return generateTestCertWithSANs([]string{domain})
}
func generateTestCertWithSANs(domains []string) ([]byte, []byte) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
cn := domains[0]
if strings.HasPrefix(cn, "*.") {
cn = strings.TrimPrefix(cn, "*.")
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
DNSNames: domains,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
panic(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
+168
View File
@@ -0,0 +1,168 @@
package certificate
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Record struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
Wildcard bool `json:"wildcard"`
ACMECA string `json:"acme_ca,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastRenewAt *time.Time `json:"last_renew_at,omitempty"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
DaysLeft int `json:"days_left"`
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) List(ctx context.Context) ([]Record, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
expires_at, last_renew_at, status, COALESCE(last_error, '')
FROM certificates ORDER BY id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []Record
for rows.Next() {
rec, err := scanRecord(rows)
if err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (s *Store) Upsert(ctx context.Context, domain string, wildcard bool, domainsJSON, acmeCA, certPath, keyPath string, expiresAt time.Time, status string, lastError string) (Record, error) {
_, err := s.db.ExecContext(ctx, `
INSERT INTO certificates(domain, domains, wildcard, acme_ca, cert_path, key_path, expires_at, last_renew_at, status, last_error, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, datetime('now'))
ON CONFLICT(domain) DO UPDATE SET
domains = excluded.domains,
wildcard = excluded.wildcard,
acme_ca = excluded.acme_ca,
cert_path = excluded.cert_path,
key_path = excluded.key_path,
expires_at = excluded.expires_at,
last_renew_at = datetime('now'),
status = excluded.status,
last_error = excluded.last_error,
updated_at = datetime('now')
`, domain, domainsJSON, boolInt(wildcard), acmeCA, certPath, keyPath, expiresAt.UTC().Format(time.RFC3339), status, lastError)
if err != nil {
return Record{}, err
}
return s.GetByDomain(ctx, domain)
}
func (s *Store) UpdateStatus(ctx context.Context, domain, status, lastError string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE certificates SET status = ?, last_error = ?, updated_at = datetime('now') WHERE domain = ?
`, status, lastError, domain)
return err
}
func (s *Store) Delete(ctx context.Context, domain string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE domain = ?`, domain)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("证书不存在")
}
return nil
}
func (s *Store) GetByDomain(ctx context.Context, domain string) (Record, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
expires_at, last_renew_at, status, COALESCE(last_error, '')
FROM certificates WHERE domain = ?
`, domain)
rec, err := scanRecord(row)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, fmt.Errorf("证书不存在")
}
return rec, err
}
func scanRecord(row interface{ Scan(dest ...any) error }) (Record, error) {
var rec Record
var wildcard int
var domainsJSON string
var expiresAt, lastRenewAt sql.NullString
if err := row.Scan(&rec.ID, &rec.Domain, &domainsJSON, &wildcard, &rec.ACMECA, &rec.CertPath, &rec.KeyPath, &expiresAt, &lastRenewAt, &rec.Status, &rec.LastError); err != nil {
return Record{}, err
}
rec.Wildcard = wildcard == 1
rec.Domains = decodeDomains(domainsJSON, rec.Domain, rec.Wildcard)
if expiresAt.Valid {
t := parseTime(expiresAt.String)
rec.ExpiresAt = &t
rec.DaysLeft = int(time.Until(t).Hours() / 24)
}
if lastRenewAt.Valid {
t := parseTime(lastRenewAt.String)
rec.LastRenewAt = &t
}
return rec, nil
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func decodeDomains(raw string, domain string, wildcard bool) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
if wildcard {
return []string{domain, "*." + domain}
}
return []string{domain}
}
var domains []string
if err := json.Unmarshal([]byte(raw), &domains); err != nil || len(domains) == 0 {
if wildcard {
return []string{domain, "*." + domain}
}
return []string{domain}
}
return domains
}
func parseTime(v string) time.Time {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
t, _ = time.Parse("2006-01-02 15:04:05", v)
}
return t.UTC()
}
+29
View File
@@ -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")
}
}
+332
View File
@@ -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
}
+161
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+113
View File
@@ -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
}
+24
View File
@@ -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)
}
}
+244
View File
@@ -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"`
}
+164
View File
@@ -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"`
}
+96
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+105
View File
@@ -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
}
+25
View File
@@ -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
}
+63
View File
@@ -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
}
+20
View File
@@ -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")
}
}
+11
View File
@@ -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)
}
+319
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+709
View File
@@ -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
}
+281
View File
@@ -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()
}
+186
View File
@@ -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
}
+26
View File
@@ -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)
}
}
+201
View File
@@ -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
}
+89
View File
@@ -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
}
+21
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+127
View File
@@ -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 ""
}
+208
View File
@@ -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
}
+12
View File
@@ -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)
}
}
+70
View File
@@ -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, ", ") + "]"
}
+58
View File
@@ -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)
}
}
+27
View File
@@ -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
}
+421
View File
@@ -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())
}
+157
View File
@@ -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
}
+42
View File
@@ -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
}
+19
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
+23
View File
@@ -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)
}
}
}
+27
View File
@@ -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
}
+184
View File
@@ -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)
}
+172
View File
@@ -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'
}
+25
View File
@@ -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.
+247
View File
@@ -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, ", "))
}
+65
View File
@@ -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 }
+46
View File
@@ -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
}
+251
View File
@@ -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)
}
+216
View File
@@ -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)
}
+99
View File
@@ -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, " · ")
}
+22
View File
@@ -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 不应被定位")
}
}
+300
View File
@@ -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
}
+345
View File
@@ -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"`
}
+104
View File
@@ -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
}
+16
View File
@@ -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()
}
+25
View File
@@ -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