换成单集群模式

This commit is contained in:
robin
2026-03-02 20:07:53 +08:00
parent 5d0b7c7e91
commit 2a76d1773d
432 changed files with 5681 additions and 5095 deletions

View File

@@ -0,0 +1,172 @@
package accesslogs
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
defaultHTTPDNSLogDir = "/var/log/edge/edge-httpdns"
envHTTPDNSLogDir = "EDGE_HTTPDNS_LOG_DIR"
)
var (
sharedHTTPDNSFileWriter *HTTPDNSFileWriter
sharedHTTPDNSFileWriterOnce sync.Once
)
// SharedHTTPDNSFileWriter 返回 HTTPDNS 本地日志写入器(单例).
func SharedHTTPDNSFileWriter() *HTTPDNSFileWriter {
sharedHTTPDNSFileWriterOnce.Do(func() {
sharedHTTPDNSFileWriter = NewHTTPDNSFileWriter()
})
return sharedHTTPDNSFileWriter
}
// HTTPDNSFileWriter 将 HTTPDNS 访问日志以 JSON Lines 写入本地文件,供 Fluent Bit 采集。
type HTTPDNSFileWriter struct {
dir string
mu sync.Mutex
file *lumberjack.Logger
rotateConfig *serverconfigs.AccessLogRotateConfig
inited bool
}
// NewHTTPDNSFileWriter 创建 HTTPDNS 本地日志写入器.
func NewHTTPDNSFileWriter() *HTTPDNSFileWriter {
logDir := resolveDefaultHTTPDNSLogDir()
return &HTTPDNSFileWriter{
dir: logDir,
rotateConfig: serverconfigs.NewDefaultAccessLogRotateConfig(),
}
}
func resolveDefaultHTTPDNSLogDir() string {
logDir := strings.TrimSpace(os.Getenv(envHTTPDNSLogDir))
if logDir == "" {
return defaultHTTPDNSLogDir
}
return logDir
}
// SetDir 更新日志目录并重置文件句柄。
func (w *HTTPDNSFileWriter) SetDir(dir string) {
if strings.TrimSpace(dir) == "" {
dir = resolveDefaultHTTPDNSLogDir()
}
w.mu.Lock()
defer w.mu.Unlock()
if dir == w.dir {
return
}
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
w.inited = false
w.dir = dir
}
// Dir 返回当前日志目录.
func (w *HTTPDNSFileWriter) Dir() string {
return w.dir
}
// EnsureInit 在启动时预创建目录与 access.log.
func (w *HTTPDNSFileWriter) EnsureInit() error {
if w.dir == "" {
return nil
}
return w.init()
}
func (w *HTTPDNSFileWriter) init() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.inited && w.file != nil {
return nil
}
if w.dir == "" {
return nil
}
if err := os.MkdirAll(w.dir, 0755); err != nil {
log.Println("[HTTPDNS_ACCESS_LOG]mkdir log dir failed:", err.Error())
return err
}
rotateConfig := w.rotateConfig.Normalize()
w.file = &lumberjack.Logger{
Filename: filepath.Join(w.dir, "access.log"),
MaxSize: rotateConfig.MaxSizeMB,
MaxBackups: rotateConfig.MaxBackups,
MaxAge: rotateConfig.MaxAgeDays,
Compress: *rotateConfig.Compress,
LocalTime: *rotateConfig.LocalTime,
}
w.inited = true
return nil
}
// WriteBatch 批量写入 HTTPDNS 访问日志.
func (w *HTTPDNSFileWriter) WriteBatch(logs []*pb.HTTPDNSAccessLog) {
if len(logs) == 0 || w.dir == "" {
return
}
if err := w.init(); err != nil {
return
}
w.mu.Lock()
file := w.file
w.mu.Unlock()
if file == nil {
return
}
for _, logItem := range logs {
ingestLog := FromPBAccessLog(logItem)
if ingestLog == nil {
continue
}
line, err := json.Marshal(ingestLog)
if err != nil {
continue
}
_, _ = file.Write(append(line, '\n'))
}
}
// Close 关闭日志文件.
func (w *HTTPDNSFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
w.inited = false
if err != nil {
log.Println(fmt.Sprintf("[HTTPDNS_ACCESS_LOG]close access.log failed: %v", err))
return err
}
return nil
}

View File

@@ -0,0 +1,57 @@
package accesslogs
import "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
// HTTPDNSIngestLog HTTPDNS 访问日志单行结构JSONEachRow字段与
// ClickHouse httpdns_access_logs_ingest 表一一对应。
type HTTPDNSIngestLog struct {
Timestamp int64 `json:"timestamp"`
RequestId string `json:"request_id"`
ClusterId int64 `json:"cluster_id"`
NodeId int64 `json:"node_id"`
AppId string `json:"app_id"`
AppName string `json:"app_name"`
Domain string `json:"domain"`
QType string `json:"qtype"`
ClientIP string `json:"client_ip"`
ClientRegion string `json:"client_region"`
Carrier string `json:"carrier"`
SDKVersion string `json:"sdk_version"`
OS string `json:"os"`
ResultIPs string `json:"result_ips"`
Status string `json:"status"`
ErrorCode string `json:"error_code"`
CostMs int32 `json:"cost_ms"`
CreatedAt int64 `json:"created_at"`
Day string `json:"day"`
Summary string `json:"summary"`
}
// FromPBAccessLog 将 pb.HTTPDNSAccessLog 转为本地 ingest 结构。
func FromPBAccessLog(log *pb.HTTPDNSAccessLog) *HTTPDNSIngestLog {
if log == nil {
return nil
}
return &HTTPDNSIngestLog{
Timestamp: log.GetCreatedAt(),
RequestId: log.GetRequestId(),
ClusterId: log.GetClusterId(),
NodeId: log.GetNodeId(),
AppId: log.GetAppId(),
AppName: log.GetAppName(),
Domain: log.GetDomain(),
QType: log.GetQtype(),
ClientIP: log.GetClientIP(),
ClientRegion: log.GetClientRegion(),
Carrier: log.GetCarrier(),
SDKVersion: log.GetSdkVersion(),
OS: log.GetOs(),
ResultIPs: log.GetResultIPs(),
Status: log.GetStatus(),
ErrorCode: log.GetErrorCode(),
CostMs: log.GetCostMs(),
CreatedAt: log.GetCreatedAt(),
Day: log.GetDay(),
Summary: log.GetSummary(),
}
}

View File

@@ -13,14 +13,18 @@ import (
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/iplibrary"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs"
"github.com/TeaOSLab/EdgeHttpDNS/internal/accesslogs"
"github.com/TeaOSLab/EdgeHttpDNS/internal/configs"
"github.com/TeaOSLab/EdgeHttpDNS/internal/rpc"
"github.com/iwind/TeaGo/rands"
"github.com/miekg/dns"
)
@@ -83,61 +87,79 @@ type clientRouteProfile struct {
RegionText string
}
type ResolveServer struct {
quitCh <-chan struct{}
type tlsListener struct {
addr string // e.g. ":443"
listener net.Listener
server *http.Server
}
type ResolveServer struct {
quitCh <-chan struct{}
snapshotManager *SnapshotManager
listenAddr string
certFile string
keyFile string
server *http.Server
// Local config fallback
fallbackAddr string
certFile string
keyFile string
logQueue chan *pb.HTTPDNSAccessLog
handler http.Handler // shared mux
tlsConfig *tls.Config // shared TLS config (with GetCertificate)
logWriter *accesslogs.HTTPDNSFileWriter
logQueue chan *pb.HTTPDNSAccessLog
// TLS certificate hot-reload
certMu sync.RWMutex
currentCert *tls.Certificate
certSnapshotAt int64
// Listener hot-reload
listenerMu sync.Mutex
listeners map[string]*tlsListener // key: addr (e.g. ":443")
}
func NewResolveServer(quitCh <-chan struct{}, snapshotManager *SnapshotManager) *ResolveServer {
listenAddr := ":443"
fallbackAddr := ":443"
certFile := ""
keyFile := ""
if apiConfig, err := configs.SharedAPIConfig(); err == nil && apiConfig != nil {
if len(apiConfig.HTTPSListenAddr) > 0 {
listenAddr = apiConfig.HTTPSListenAddr
fallbackAddr = apiConfig.HTTPSListenAddr
}
certFile = apiConfig.HTTPSCert
keyFile = apiConfig.HTTPSKey
}
logWriter := accesslogs.SharedHTTPDNSFileWriter()
if apiConfig, err := configs.SharedAPIConfig(); err == nil && apiConfig != nil {
if len(strings.TrimSpace(apiConfig.LogDir)) > 0 {
logWriter.SetDir(strings.TrimSpace(apiConfig.LogDir))
}
}
if err := logWriter.EnsureInit(); err != nil {
log.Println("[HTTPDNS_NODE][resolve]init access log file writer failed:", err.Error())
}
instance := &ResolveServer{
quitCh: quitCh,
snapshotManager: snapshotManager,
listenAddr: listenAddr,
fallbackAddr: fallbackAddr,
certFile: certFile,
keyFile: keyFile,
logWriter: logWriter,
logQueue: make(chan *pb.HTTPDNSAccessLog, 8192),
listeners: make(map[string]*tlsListener),
}
mux := http.NewServeMux()
mux.HandleFunc("/resolve", instance.handleResolve)
mux.HandleFunc("/healthz", instance.handleHealth)
instance.handler = mux
instance.server = &http.Server{
Addr: instance.listenAddr,
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 75 * time.Second,
MaxHeaderBytes: 8 * 1024,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS11,
// /resolve is a small JSON API; pin to HTTP/1.1 to avoid ALPN/h2 handshake variance
// across some clients and middleboxes.
NextProtos: []string{"http/1.1"},
},
// Disable automatic HTTP/2 upgrade on TLS listeners. This keeps handshake behavior
// deterministic for SDK resolve calls.
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
instance.tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS11,
NextProtos: []string{"http/1.1"},
GetCertificate: instance.getCertificate,
}
return instance
@@ -145,19 +167,240 @@ func NewResolveServer(quitCh <-chan struct{}, snapshotManager *SnapshotManager)
func (s *ResolveServer) Start() {
go s.startAccessLogFlusher()
go s.waitForShutdown()
log.Println("[HTTPDNS_NODE][resolve]listening HTTPS on", s.listenAddr)
if err := s.server.ListenAndServeTLS(s.certFile, s.keyFile); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Println("[HTTPDNS_NODE][resolve]listen failed:", err.Error())
// 1. Load initial certificate from file (fallback)
if len(s.certFile) > 0 && len(s.keyFile) > 0 {
cert, err := tls.LoadX509KeyPair(s.certFile, s.keyFile)
if err != nil {
log.Println("[HTTPDNS_NODE][resolve]load cert file failed:", err.Error())
} else {
s.currentCert = &cert
log.Println("[HTTPDNS_NODE][resolve]loaded initial TLS cert from file")
}
}
// 2. Try loading certificate from cluster snapshot (takes priority over file)
if snapshot := s.snapshotManager.Current(); snapshot != nil {
s.reloadCertFromSnapshot(snapshot)
}
if s.currentCert == nil {
log.Println("[HTTPDNS_NODE][resolve]WARNING: no TLS certificate available, HTTPS will fail")
}
// 3. Parse initial listen addresses and start listeners
if snapshot := s.snapshotManager.Current(); snapshot != nil {
addrs := s.desiredAddrs(snapshot)
s.syncListeners(addrs)
} else {
s.syncListeners([]string{s.fallbackAddr})
}
// 4. Watch for changes (blocks until quit)
s.watchLoop()
}
func (s *ResolveServer) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
s.certMu.RLock()
cert := s.currentCert
s.certMu.RUnlock()
if cert != nil {
return cert, nil
}
return nil, errors.New("no TLS certificate available")
}
type snapshotTLSConfig struct {
Listen []*serverconfigs.NetworkAddressConfig `json:"listen"`
SSLPolicy *sslconfigs.SSLPolicy `json:"sslPolicy"`
}
func (s *ResolveServer) parseTLSConfig(snapshot *LoadedSnapshot) *snapshotTLSConfig {
if snapshot.ClusterID <= 0 {
return nil
}
cluster := snapshot.Clusters[snapshot.ClusterID]
if cluster == nil {
return nil
}
raw := cluster.GetTlsPolicyJSON()
if len(raw) == 0 {
return nil
}
var cfg snapshotTLSConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
log.Println("[HTTPDNS_NODE][resolve]parse tlsPolicyJSON failed:", err.Error())
return nil
}
return &cfg
}
func (s *ResolveServer) desiredAddrs(snapshot *LoadedSnapshot) []string {
cfg := s.parseTLSConfig(snapshot)
if cfg == nil || len(cfg.Listen) == 0 {
return []string{s.fallbackAddr}
}
seen := make(map[string]struct{})
var addrs []string
for _, listenCfg := range cfg.Listen {
if listenCfg == nil {
continue
}
if err := listenCfg.Init(); err != nil {
log.Println("[HTTPDNS_NODE][resolve]init listen config failed:", err.Error())
continue
}
for _, addr := range listenCfg.Addresses() {
if _, ok := seen[addr]; !ok {
seen[addr] = struct{}{}
addrs = append(addrs, addr)
}
}
}
if len(addrs) == 0 {
return []string{s.fallbackAddr}
}
sort.Strings(addrs)
return addrs
}
func (s *ResolveServer) reloadCertFromSnapshot(snapshot *LoadedSnapshot) {
cfg := s.parseTLSConfig(snapshot)
if cfg == nil || cfg.SSLPolicy == nil || len(cfg.SSLPolicy.Certs) == 0 {
s.certMu.Lock()
s.certSnapshotAt = snapshot.LoadedAt
s.certMu.Unlock()
return
}
if err := cfg.SSLPolicy.Init(context.Background()); err != nil {
log.Println("[HTTPDNS_NODE][resolve]init SSLPolicy failed:", err.Error())
s.certMu.Lock()
s.certSnapshotAt = snapshot.LoadedAt
s.certMu.Unlock()
return
}
cert := cfg.SSLPolicy.FirstCert()
if cert == nil {
s.certMu.Lock()
s.certSnapshotAt = snapshot.LoadedAt
s.certMu.Unlock()
return
}
s.certMu.Lock()
s.currentCert = cert
s.certSnapshotAt = snapshot.LoadedAt
s.certMu.Unlock()
log.Println("[HTTPDNS_NODE][resolve]TLS certificate reloaded from snapshot")
}
func (s *ResolveServer) startListener(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", addr, err)
}
tlsLn := tls.NewListener(ln, s.tlsConfig)
srv := &http.Server{
Handler: s.handler,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 75 * time.Second,
MaxHeaderBytes: 8 * 1024,
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
}
s.listeners[addr] = &tlsListener{
addr: addr,
listener: tlsLn,
server: srv,
}
go func() {
if err := srv.Serve(tlsLn); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Println("[HTTPDNS_NODE][resolve]serve failed on", addr, ":", err.Error())
}
}()
log.Println("[HTTPDNS_NODE][resolve]listening HTTPS on", addr)
return nil
}
func (s *ResolveServer) stopListener(addr string) {
tl, ok := s.listeners[addr]
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = tl.server.Shutdown(ctx)
delete(s.listeners, addr)
log.Println("[HTTPDNS_NODE][resolve]stopped listener on", addr)
}
func (s *ResolveServer) syncListeners(desired []string) {
s.listenerMu.Lock()
defer s.listenerMu.Unlock()
desiredSet := make(map[string]struct{}, len(desired))
for _, addr := range desired {
desiredSet[addr] = struct{}{}
}
// Stop listeners that are no longer desired
for addr := range s.listeners {
if _, ok := desiredSet[addr]; !ok {
s.stopListener(addr)
}
}
// Start new listeners
for _, addr := range desired {
if _, ok := s.listeners[addr]; !ok {
if err := s.startListener(addr); err != nil {
log.Println("[HTTPDNS_NODE][resolve]start listener failed:", err.Error())
}
}
}
}
func (s *ResolveServer) waitForShutdown() {
<-s.quitCh
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.server.Shutdown(ctx)
func (s *ResolveServer) watchLoop() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
snapshot := s.snapshotManager.Current()
if snapshot == nil {
continue
}
s.certMu.RLock()
lastAt := s.certSnapshotAt
s.certMu.RUnlock()
if snapshot.LoadedAt == lastAt {
continue
}
// Snapshot changed — sync listeners and reload cert
addrs := s.desiredAddrs(snapshot)
s.syncListeners(addrs)
s.reloadCertFromSnapshot(snapshot)
case <-s.quitCh:
s.shutdownAll()
return
}
}
}
func (s *ResolveServer) shutdownAll() {
s.listenerMu.Lock()
defer s.listenerMu.Unlock()
for addr := range s.listeners {
s.stopListener(addr)
}
}
func (s *ResolveServer) handleHealth(writer http.ResponseWriter, _ *http.Request) {
@@ -219,11 +462,22 @@ func (s *ResolveServer) handleResolve(writer http.ResponseWriter, request *http.
return
}
if snapshot.ClusterID > 0 &&
loadedApp.App.GetPrimaryClusterId() != snapshot.ClusterID &&
loadedApp.App.GetBackupClusterId() != snapshot.ClusterID {
s.writeFailedResolve(writer, requestID, snapshot, loadedApp.App, domain, qtype, httpdnsCodeAppInvalid, "当前应用未绑定到该解析集群", startAt, request, query)
return
if snapshot.ClusterID > 0 {
var appClusterIds []int64
if len(loadedApp.App.GetClusterIdsJSON()) > 0 {
_ = json.Unmarshal(loadedApp.App.GetClusterIdsJSON(), &appClusterIds)
}
var clusterBound bool
for _, cid := range appClusterIds {
if cid == snapshot.ClusterID {
clusterBound = true
break
}
}
if !clusterBound {
s.writeFailedResolve(writer, requestID, snapshot, loadedApp.App, domain, qtype, httpdnsCodeAppInvalid, "当前应用未绑定到该解析集群", startAt, request, query)
return
}
}
loadedDomain := loadedApp.Domains[domain]
@@ -341,11 +595,14 @@ func pickDefaultTTL(snapshot *LoadedSnapshot, app *pb.HTTPDNSApp) int32 {
}
}
if app != nil {
if cluster := snapshot.Clusters[app.GetPrimaryClusterId()]; cluster != nil && cluster.GetDefaultTTL() > 0 {
return cluster.GetDefaultTTL()
var appClusterIds []int64
if len(app.GetClusterIdsJSON()) > 0 {
_ = json.Unmarshal(app.GetClusterIdsJSON(), &appClusterIds)
}
if cluster := snapshot.Clusters[app.GetBackupClusterId()]; cluster != nil && cluster.GetDefaultTTL() > 0 {
return cluster.GetDefaultTTL()
for _, cid := range appClusterIds {
if cluster := snapshot.Clusters[cid]; cluster != nil && cluster.GetDefaultTTL() > 0 {
return cluster.GetDefaultTTL()
}
}
}
return 30
@@ -591,15 +848,19 @@ func normalizeChinaRegion(province string) string {
func normalizeContinent(country string) string {
switch normalizeCountryName(country) {
case "中国", "中国香港", "中国澳门", "中国台湾", "日本", "韩国", "新加坡", "印度", "泰国", "越南":
case "中国", "中国香港", "中国澳门", "中国台湾", "日本", "韩国", "新加坡", "印度", "泰国", "越南",
"印度尼西亚", "马来西亚", "菲律宾", "柬埔寨", "缅甸", "老挝", "斯里兰卡", "孟加拉国", "巴基斯坦", "尼泊尔",
"阿联酋", "沙特阿拉伯", "土耳其", "以色列", "伊朗", "伊拉克", "卡塔尔", "科威特", "蒙古":
return "亚洲"
case "美国", "加拿大", "墨西哥":
case "美国", "加拿大", "墨西哥", "巴拿马", "哥斯达黎加", "古巴":
return "北美洲"
case "巴西", "阿根廷", "智利", "哥伦比亚":
case "巴西", "阿根廷", "智利", "哥伦比亚", "秘鲁", "委内瑞拉", "厄瓜多尔":
return "南美洲"
case "德国", "英国", "法国", "荷兰", "西班牙", "意大利", "俄罗斯":
case "德国", "英国", "法国", "荷兰", "西班牙", "意大利", "俄罗斯",
"波兰", "瑞典", "瑞士", "挪威", "芬兰", "丹麦", "葡萄牙", "爱尔兰", "比利时", "奥地利",
"乌克兰", "捷克", "罗马尼亚", "匈牙利", "希腊":
return "欧洲"
case "南非", "埃及", "尼日利亚", "肯尼亚", "摩洛哥":
case "南非", "埃及", "尼日利亚", "肯尼亚", "摩洛哥", "阿尔及利亚", "坦桑尼亚", "埃塞俄比亚", "加纳", "突尼斯":
return "非洲"
case "澳大利亚", "新西兰":
return "大洋洲"
@@ -639,8 +900,8 @@ func pickRuleRecords(rules []*pb.HTTPDNSCustomRule, qtype string, profile *clien
Type: qtype,
IP: value,
Weight: item.GetWeight(),
Line: ruleLineSummary(rule),
Region: ruleRegionSummary(rule),
Line: lookupIPLineLabel(value),
Region: lookupIPRegionSummary(value),
})
}
if len(records) == 0 {
@@ -969,6 +1230,91 @@ func normalizeCountryName(country string) string {
return "智利"
case "哥伦比亚", "colombia":
return "哥伦比亚"
// --- 亚洲(新增)---
case "印度尼西亚", "indonesia":
return "印度尼西亚"
case "马来西亚", "malaysia":
return "马来西亚"
case "菲律宾", "philippines":
return "菲律宾"
case "柬埔寨", "cambodia":
return "柬埔寨"
case "缅甸", "myanmar", "burma":
return "缅甸"
case "老挝", "laos":
return "老挝"
case "斯里兰卡", "srilanka":
return "斯里兰卡"
case "孟加拉国", "孟加拉", "bangladesh":
return "孟加拉国"
case "巴基斯坦", "pakistan":
return "巴基斯坦"
case "尼泊尔", "nepal":
return "尼泊尔"
case "阿联酋", "阿拉伯联合酋长国", "uae", "unitedarabemirates":
return "阿联酋"
case "沙特阿拉伯", "沙特", "saudiarabia", "saudi":
return "沙特阿拉伯"
case "土耳其", "turkey", "türkiye", "turkiye":
return "土耳其"
case "以色列", "israel":
return "以色列"
case "伊朗", "iran":
return "伊朗"
case "伊拉克", "iraq":
return "伊拉克"
case "卡塔尔", "qatar":
return "卡塔尔"
case "科威特", "kuwait":
return "科威特"
case "蒙古", "mongolia":
return "蒙古"
// --- 欧洲(新增)---
case "波兰", "poland":
return "波兰"
case "瑞典", "sweden":
return "瑞典"
case "瑞士", "switzerland":
return "瑞士"
case "挪威", "norway":
return "挪威"
case "芬兰", "finland":
return "芬兰"
case "丹麦", "denmark":
return "丹麦"
case "葡萄牙", "portugal":
return "葡萄牙"
case "爱尔兰", "ireland":
return "爱尔兰"
case "比利时", "belgium":
return "比利时"
case "奥地利", "austria":
return "奥地利"
case "乌克兰", "ukraine":
return "乌克兰"
case "捷克", "czech", "czechrepublic", "czechia":
return "捷克"
case "罗马尼亚", "romania":
return "罗马尼亚"
case "匈牙利", "hungary":
return "匈牙利"
case "希腊", "greece":
return "希腊"
// --- 北美洲(新增)---
case "巴拿马", "panama":
return "巴拿马"
case "哥斯达黎加", "costarica":
return "哥斯达黎加"
case "古巴", "cuba":
return "古巴"
// --- 南美洲(新增)---
case "秘鲁", "peru":
return "秘鲁"
case "委内瑞拉", "venezuela":
return "委内瑞拉"
case "厄瓜多尔", "ecuador":
return "厄瓜多尔"
// --- 非洲 ---
case "南非", "southafrica":
return "南非"
case "埃及", "egypt":
@@ -979,6 +1325,17 @@ func normalizeCountryName(country string) string {
return "肯尼亚"
case "摩洛哥", "morocco":
return "摩洛哥"
case "阿尔及利亚", "algeria":
return "阿尔及利亚"
case "坦桑尼亚", "tanzania":
return "坦桑尼亚"
case "埃塞俄比亚", "ethiopia":
return "埃塞俄比亚"
case "加纳", "ghana":
return "加纳"
case "突尼斯", "tunisia":
return "突尼斯"
// --- 大洋洲 ---
case "澳大利亚", "australia":
return "澳大利亚"
case "新西兰", "newzealand":
@@ -1087,18 +1444,7 @@ func (s *ResolveServer) startAccessLogFlusher() {
if len(batch) == 0 {
return
}
rpcClient, err := rpc.SharedRPC()
if err != nil {
log.Println("[HTTPDNS_NODE][resolve]access-log rpc unavailable:", err.Error())
return
}
_, err = rpcClient.HTTPDNSAccessLogRPC.CreateHTTPDNSAccessLogs(rpcClient.Context(), &pb.CreateHTTPDNSAccessLogsRequest{
Logs: batch,
})
if err != nil {
log.Println("[HTTPDNS_NODE][resolve]flush access logs failed:", err.Error())
return
}
s.logWriter.WriteBatch(batch)
batch = batch[:0]
}
@@ -1119,6 +1465,7 @@ func (s *ResolveServer) startAccessLogFlusher() {
flush()
case <-s.quitCh:
flush()
_ = s.logWriter.Close()
return
}
}

View File

@@ -0,0 +1,160 @@
package executils
import (
"bytes"
"context"
"os"
"os/exec"
"strings"
"time"
)
type Cmd struct {
name string
args []string
env []string
dir string
ctx context.Context
timeout time.Duration
cancelFunc func()
captureStdout bool
captureStderr bool
stdout *bytes.Buffer
stderr *bytes.Buffer
rawCmd *exec.Cmd
}
func NewCmd(name string, args ...string) *Cmd {
return &Cmd{
name: name,
args: args,
}
}
func NewTimeoutCmd(timeout time.Duration, name string, args ...string) *Cmd {
return (&Cmd{
name: name,
args: args,
}).WithTimeout(timeout)
}
func (this *Cmd) WithTimeout(timeout time.Duration) *Cmd {
this.timeout = timeout
ctx, cancelFunc := context.WithTimeout(context.Background(), timeout)
this.ctx = ctx
this.cancelFunc = cancelFunc
return this
}
func (this *Cmd) WithStdout() *Cmd {
this.captureStdout = true
return this
}
func (this *Cmd) WithStderr() *Cmd {
this.captureStderr = true
return this
}
func (this *Cmd) WithEnv(env []string) *Cmd {
this.env = env
return this
}
func (this *Cmd) WithDir(dir string) *Cmd {
this.dir = dir
return this
}
func (this *Cmd) Start() error {
var cmd = this.compose()
return cmd.Start()
}
func (this *Cmd) Wait() error {
var cmd = this.compose()
return cmd.Wait()
}
func (this *Cmd) Run() error {
if this.cancelFunc != nil {
defer this.cancelFunc()
}
var cmd = this.compose()
return cmd.Run()
}
func (this *Cmd) RawStdout() string {
if this.stdout != nil {
return this.stdout.String()
}
return ""
}
func (this *Cmd) Stdout() string {
return strings.TrimSpace(this.RawStdout())
}
func (this *Cmd) RawStderr() string {
if this.stderr != nil {
return this.stderr.String()
}
return ""
}
func (this *Cmd) Stderr() string {
return strings.TrimSpace(this.RawStderr())
}
func (this *Cmd) String() string {
if this.rawCmd != nil {
return this.rawCmd.String()
}
var newCmd = exec.Command(this.name, this.args...)
return newCmd.String()
}
func (this *Cmd) Process() *os.Process {
if this.rawCmd != nil {
return this.rawCmd.Process
}
return nil
}
func (this *Cmd) compose() *exec.Cmd {
if this.rawCmd != nil {
return this.rawCmd
}
if this.ctx != nil {
this.rawCmd = exec.CommandContext(this.ctx, this.name, this.args...)
} else {
this.rawCmd = exec.Command(this.name, this.args...)
}
if this.env != nil {
this.rawCmd.Env = this.env
}
if len(this.dir) > 0 {
this.rawCmd.Dir = this.dir
}
if this.captureStdout {
this.stdout = &bytes.Buffer{}
this.rawCmd.Stdout = this.stdout
}
if this.captureStderr {
this.stderr = &bytes.Buffer{}
this.rawCmd.Stderr = this.stderr
}
return this.rawCmd
}

View File

@@ -0,0 +1,57 @@
//go:build linux
package executils
import (
"golang.org/x/sys/unix"
"io/fs"
"os"
"os/exec"
"syscall"
)
// LookPath customize our LookPath() function, to work in broken $PATH environment variable
func LookPath(file string) (string, error) {
result, err := exec.LookPath(file)
if err == nil && len(result) > 0 {
return result, nil
}
// add common dirs contains executable files these may be excluded in $PATH environment variable
var binPaths = []string{
"/usr/sbin",
"/usr/bin",
"/usr/local/sbin",
"/usr/local/bin",
}
for _, binPath := range binPaths {
var fullPath = binPath + string(os.PathSeparator) + file
stat, err := os.Stat(fullPath)
if err != nil {
continue
}
if stat.IsDir() {
return "", syscall.EISDIR
}
var mode = stat.Mode()
if mode.IsDir() {
return "", syscall.EISDIR
}
err = syscall.Faccessat(unix.AT_FDCWD, fullPath, unix.X_OK, unix.AT_EACCESS)
if err == nil || (err != syscall.ENOSYS && err != syscall.EPERM) {
return fullPath, err
}
if mode&0111 != 0 {
return fullPath, nil
}
return "", fs.ErrPermission
}
return "", &exec.Error{
Name: file,
Err: exec.ErrNotFound,
}
}

View File

@@ -0,0 +1,9 @@
//go:build !linux
package executils
import "os/exec"
func LookPath(file string) (string, error) {
return exec.LookPath(file)
}

View File

@@ -6,25 +6,107 @@ package utils
import (
"errors"
"os"
"os/exec"
"time"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
executils "github.com/TeaOSLab/EdgeHttpDNS/internal/utils/exec"
"github.com/iwind/TeaGo/Tea"
)
var systemdServiceFile = "/etc/systemd/system/" + teaconst.SystemdServiceName + ".service"
var initServiceFile = "/etc/init.d/" + teaconst.SystemdServiceName
// Install 安装系统服务
func (m *ServiceManager) Install(exePath string, args []string) error {
if os.Getgid() != 0 {
return errors.New("only root users can install the service")
}
systemd, err := exec.LookPath("systemctl")
systemd, err := executils.LookPath("systemctl")
if err != nil {
return err
// systemd 不可用,降级到 init.d
return m.installInitService(exePath, args)
}
desc := `[Unit]
Description=GoEdge HTTPDNS Node Service
return m.installSystemdService(systemd, exePath, args)
}
// Start 启动服务
func (m *ServiceManager) Start() error {
if os.Getgid() != 0 {
return errors.New("only root users can start the service")
}
// 优先检查 systemd
if fileExists(systemdServiceFile) {
systemd, err := executils.LookPath("systemctl")
if err != nil {
return err
}
return executils.NewTimeoutCmd(10*time.Second, systemd, "start", teaconst.SystemdServiceName+".service").Start()
}
// 降级到 init.d
if fileExists(initServiceFile) {
return executils.NewTimeoutCmd(10*time.Second, "service", teaconst.ProcessName, "start").Start()
}
return errors.New("no service file found, please install the service first")
}
// Uninstall 删除服务
func (m *ServiceManager) Uninstall() error {
if os.Getgid() != 0 {
return errors.New("only root users can uninstall the service")
}
// systemd
if fileExists(systemdServiceFile) {
systemd, err := executils.LookPath("systemctl")
if err == nil {
_ = executils.NewTimeoutCmd(10*time.Second, systemd, "stop", teaconst.SystemdServiceName+".service").Run()
_ = executils.NewTimeoutCmd(10*time.Second, systemd, "disable", teaconst.SystemdServiceName+".service").Run()
_ = executils.NewTimeoutCmd(10*time.Second, systemd, "daemon-reload").Run()
}
return os.Remove(systemdServiceFile)
}
// init.d
if fileExists(initServiceFile) {
_ = executils.NewTimeoutCmd(10*time.Second, "service", teaconst.ProcessName, "stop").Run()
chkCmd, err := executils.LookPath("chkconfig")
if err == nil {
_ = executils.NewTimeoutCmd(10*time.Second, chkCmd, "--del", teaconst.ProcessName).Run()
}
return os.Remove(initServiceFile)
}
return nil
}
// installSystemdService 安装 systemd 服务
func (m *ServiceManager) installSystemdService(systemd, exePath string, args []string) error {
shortName := teaconst.SystemdServiceName
longName := "GoEdge HTTPDNS"
// 用 bash 包装启动命令,兼容路径含空格的场景
var startCmd = exePath + " daemon"
bashPath, _ := executils.LookPath("bash")
if len(bashPath) > 0 {
startCmd = bashPath + " -c \"" + startCmd + "\""
}
desc := `### BEGIN INIT INFO
# Provides: ` + shortName + `
# Required-Start: $all
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ` + longName + ` Service
### END INIT INFO
[Unit]
Description=` + longName + ` Node Service
Before=shutdown.target
After=network-online.target
@@ -32,34 +114,102 @@ After=network-online.target
Type=simple
Restart=always
RestartSec=1s
ExecStart=` + exePath + ` daemon
ExecStart=` + startCmd + `
ExecStop=` + exePath + ` stop
ExecReload=` + exePath + ` restart
[Install]
WantedBy=multi-user.target`
err = os.WriteFile(systemdServiceFile, []byte(desc), 0777)
// 权限 0644systemd 单元文件不需要执行权限
err := os.WriteFile(systemdServiceFile, []byte(desc), 0644)
if err != nil {
return err
}
_ = exec.Command(systemd, "stop", teaconst.SystemdServiceName+".service").Run()
_ = exec.Command(systemd, "daemon-reload").Run()
return exec.Command(systemd, "enable", teaconst.SystemdServiceName+".service").Run()
// 停止已有服务
_ = executils.NewTimeoutCmd(10*time.Second, systemd, "stop", shortName+".service").Run()
// 重新加载
_ = executils.NewTimeoutCmd(10*time.Second, systemd, "daemon-reload").Run()
// 启用开机自启
return executils.NewTimeoutCmd(10*time.Second, systemd, "enable", shortName+".service").Run()
}
func (m *ServiceManager) Uninstall() error {
if os.Getgid() != 0 {
return errors.New("only root users can uninstall the service")
}
// installInitService 安装 init.d 服务(降级方案,适用于无 systemd 的旧系统)
func (m *ServiceManager) installInitService(exePath string, args []string) error {
shortName := teaconst.SystemdServiceName
longName := "GoEdge HTTPDNS"
systemd, err := exec.LookPath("systemctl")
// 生成 init.d 脚本
script := `#!/bin/bash
### BEGIN INIT INFO
# Provides: ` + shortName + `
# Required-Start: $all
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ` + longName + ` Service
### END INIT INFO
INSTALL_DIR=` + Tea.Root + `
case "$1" in
start)
cd "${INSTALL_DIR}"
` + exePath + ` daemon &
echo "` + longName + ` started"
;;
stop)
cd "${INSTALL_DIR}"
` + exePath + ` stop
echo "` + longName + ` stopped"
;;
restart)
cd "${INSTALL_DIR}"
` + exePath + ` stop
sleep 1
` + exePath + ` daemon &
echo "` + longName + ` restarted"
;;
status)
cd "${INSTALL_DIR}"
` + exePath + ` status
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
`
// init.d 脚本需要执行权限
err := os.WriteFile(initServiceFile, []byte(script), 0755)
if err != nil {
return err
}
_ = exec.Command(systemd, "disable", teaconst.SystemdServiceName+".service").Run()
_ = exec.Command(systemd, "daemon-reload").Run()
return os.Remove(systemdServiceFile)
// 尝试用 chkconfig 注册CentOS/RHEL
chkCmd, err := executils.LookPath("chkconfig")
if err == nil {
_ = executils.NewTimeoutCmd(10*time.Second, chkCmd, "--add", teaconst.ProcessName).Run()
return nil
}
// 尝试用 update-rc.d 注册Debian/Ubuntu
updateRcCmd, err := executils.LookPath("update-rc.d")
if err == nil {
_ = executils.NewTimeoutCmd(10*time.Second, updateRcCmd, teaconst.ProcessName, "defaults").Run()
return nil
}
return nil
}
// fileExists 检查文件是否存在
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}