feat:1. 增加AnyTLS类型支持 2.定时5m全量探测节点健康改为2h,并增加配置可调整 3. 修复网页端修改配置文件保存失败问题

This commit is contained in:
zhangyongsheng
2026-03-12 16:21:34 +08:00
parent 5484d6f65a
commit 284977b7a5
11 changed files with 230 additions and 32 deletions
+7 -2
View File
@@ -29,7 +29,7 @@ const (
defaultDrainTimeout = 10 * time.Second
defaultHealthCheckTimeout = 30 * time.Second
healthCheckPollInterval = 500 * time.Millisecond
periodicHealthInterval = 5 * time.Minute
// periodicHealthInterval is configured via cfg.Management.HealthCheckInterval
periodicHealthTimeout = 10 * time.Second
)
@@ -160,7 +160,8 @@ func (m *Manager) Start(ctx context.Context) error {
// Start periodic health check after nodes are registered
m.mu.Lock()
if m.monitorMgr != nil && !m.healthCheckStarted {
m.monitorMgr.StartPeriodicHealthCheck(periodicHealthInterval, periodicHealthTimeout)
interval := cfg.Management.HealthCheckInterval
m.monitorMgr.StartPeriodicHealthCheck(interval, periodicHealthTimeout)
m.healthCheckStarted = true
}
m.mu.Unlock()
@@ -280,6 +281,10 @@ func (m *Manager) Reload(newCfg *config.Config) error {
l.OnConfigUpdate(newCfg)
}
// Reload 成功后立即触发 1 次全量探测(内部去重,避免多次 Reload 造成突发)
if m.monitorMgr != nil {
go m.monitorMgr.RequestProbeAllOnce(periodicHealthTimeout)
}
m.logger.Infof("reload completed successfully with %d nodes", len(newCfg.Nodes))
return nil
}
+48
View File
@@ -379,11 +379,59 @@ func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeVMess, Tag: tag, Options: &opts}, nil
case "anytls":
opts, err := buildAnyTLSOptions(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeAnyTLS, Tag: tag, Options: &opts}, nil
default:
return option.Outbound{}, fmt.Errorf("unsupported scheme %q", parsed.Scheme)
}
}
func buildAnyTLSOptions(u *url.URL, skipCertVerify bool) (option.AnyTLSOutboundOptions, error) {
password := ""
if u.User != nil {
password = u.User.Username()
}
if password == "" {
return option.AnyTLSOutboundOptions{}, errors.New("anytls uri missing password in userinfo")
}
server, port, err := hostPort(u, 443)
if err != nil {
return option.AnyTLSOutboundOptions{}, err
}
query := u.Query()
opts := option.AnyTLSOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Password: password,
}
// AnyTLS requires TLS
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
tlsOptions.ServerName = server
if sni := query.Get("sni"); sni != "" {
tlsOptions.ServerName = sni
}
insecure := query.Get("insecure")
if insecure == "" {
insecure = query.Get("allowInsecure")
}
if insecure != "" {
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
}
if alpn := query.Get("alpn"); alpn != "" {
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
}
if fp := query.Get("fp"); fp != "" {
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
}
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
return opts, nil
}
func buildVLESSOptions(u *url.URL, skipCertVerify bool) (option.VLESSOutboundOptions, error) {
uuid := u.User.Username()
if uuid == "" {
+49 -6
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"gopkg.in/yaml.v3"
@@ -77,10 +78,11 @@ type MultiPortConfig struct {
// ManagementConfig controls the monitoring HTTP endpoint.
type ManagementConfig struct {
Enabled *bool `yaml:"enabled"`
Listen string `yaml:"listen"`
ProbeTarget string `yaml:"probe_target"`
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
Enabled *bool `yaml:"enabled"`
Listen string `yaml:"listen"`
ProbeTarget string `yaml:"probe_target"`
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
HealthCheckInterval time.Duration `yaml:"health_check_interval"`
}
// SubscriptionRefreshConfig controls subscription auto-refresh and reload settings.
@@ -266,6 +268,9 @@ func (c *Config) applyDefaults() error {
defaultEnabled := true
c.Management.Enabled = &defaultEnabled
}
if c.Management.HealthCheckInterval <= 0 {
c.Management.HealthCheckInterval = 2 * time.Hour
}
// Subscription refresh defaults
if c.SubscriptionRefresh.Interval <= 0 {
@@ -678,7 +683,7 @@ func isBase64(s string) bool {
// IsProxyURI checks if a string is a valid proxy URI
func IsProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://"}
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://", "anytls://"}
for _, scheme := range schemes {
if strings.HasPrefix(strings.ToLower(s), scheme) {
return true
@@ -764,6 +769,8 @@ func convertClashProxyToURI(p clashProxy) string {
return buildShadowsocksURI(p)
case "hysteria2", "hy2":
return buildHysteria2URI(p)
case "anytls":
return buildAnyTLSURI(p)
default:
return ""
}
@@ -908,6 +915,25 @@ func buildHysteria2URI(p clashProxy) string {
return fmt.Sprintf("hysteria2://%s@%s:%d%s#%s", p.Password, p.Server, p.Port, query, url.QueryEscape(p.Name))
}
func buildAnyTLSURI(p clashProxy) string {
params := url.Values{}
if p.ServerName != "" {
params.Set("sni", p.ServerName)
} else if p.SNI != "" {
params.Set("sni", p.SNI)
}
if p.SkipCertVerify {
params.Set("insecure", "1")
}
query := ""
if len(params) > 0 {
query = "?" + params.Encode()
}
return fmt.Sprintf("anytls://%s@%s:%d%s#%s", p.Password, p.Server, p.Port, query, url.QueryEscape(p.Name))
}
// RLock acquires a read lock on the config.
func (c *Config) RLock() {
if c != nil {
@@ -1044,7 +1070,7 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
listenerProtocol, multiPortProtocol,
poolBlacklistDuration, subRefreshInterval, subRefreshTimeout,
subRefreshHealthCheckTimeout, subRefreshDrainTimeout,
geoIPAutoUpdateInterval string) error {
geoIPAutoUpdateInterval, managementHealthCheckInterval string) error {
// Validate mode
switch mode {
@@ -1080,6 +1106,7 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
{"健康检查超时", subRefreshHealthCheckTimeout},
{"排空超时", subRefreshDrainTimeout},
{"GeoIP 更新间隔", geoIPAutoUpdateInterval},
{"周期健康检查间隔", managementHealthCheckInterval},
}
for _, field := range durationFields {
@@ -1153,6 +1180,22 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
// Atomic rename
if err := os.Rename(tmpPath, path); err != nil {
// 当 config.yaml 通过 bind-mount 映射到宿主机时,部分宿主机文件系统/挂载模式会导致
// 对“正在被挂载使用的目标文件”执行 rename 返回 EBUSY(device or resource busy)。
// 这种情况下回退为“直接覆盖写入”(非原子,但可用),以保证 WebUI 能保存配置。
if errors.Is(err, syscall.EBUSY) {
data, rerr := os.ReadFile(tmpPath)
if rerr != nil {
return fmt.Errorf("rename temp file: %w", err)
}
if werr := os.WriteFile(path, data, perm); werr != nil {
return fmt.Errorf("rename temp file: %w", err)
}
if cerr := os.Remove(tmpPath); cerr != nil {
// best-effort cleanup
}
return nil
}
return fmt.Errorf("rename temp file: %w", err)
}
+3 -2
View File
@@ -495,13 +495,14 @@ func extractHostFromURI(uri string) string {
// Handle different URI schemes
lowerURI := strings.ToLower(uri)
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2://
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2:// anytls://
if strings.HasPrefix(lowerURI, "vmess://") ||
strings.HasPrefix(lowerURI, "vless://") ||
strings.HasPrefix(lowerURI, "trojan://") ||
strings.HasPrefix(lowerURI, "hysteria://") ||
strings.HasPrefix(lowerURI, "hysteria2://") ||
strings.HasPrefix(lowerURI, "hy2://") {
strings.HasPrefix(lowerURI, "hy2://") ||
strings.HasPrefix(lowerURI, "anytls://") {
// Standard URL format: scheme://user@host:port?params#fragment
parsed, err := url.Parse(uri)
if err != nil {
+82 -6
View File
@@ -136,6 +136,14 @@ type Manager struct {
ctx context.Context
cancel context.CancelFunc
logger Logger
// periodic health check control
healthMu sync.Mutex
healthInterval time.Duration
healthTimeout time.Duration
healthTicker *time.Ticker
healthIntervalC chan time.Duration
probeAllInFlight atomic.Bool
}
// Logger interface for logging
@@ -199,20 +207,53 @@ func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
}
return
}
if interval <= 0 {
interval = 2 * time.Hour
}
if timeout <= 0 {
timeout = 10 * time.Second
}
m.healthMu.Lock()
if m.healthIntervalC == nil {
m.healthIntervalC = make(chan time.Duration, 1)
}
m.healthInterval = interval
m.healthTimeout = timeout
if m.healthTicker != nil {
m.healthTicker.Stop()
}
m.healthTicker = time.NewTicker(interval)
ticker := m.healthTicker
intervalC := m.healthIntervalC
m.healthMu.Unlock()
go func() {
// 启动后立即进行一次检查
m.probeAllNodes(timeout)
ticker := time.NewTicker(interval)
defer ticker.Stop()
// 启动后立即进行一次检查(走去重)
m.RequestProbeAllOnce(timeout)
for {
select {
case <-m.ctx.Done():
return
case newInterval := <-intervalC:
if newInterval <= 0 {
newInterval = 2 * time.Hour
}
// 重置 ticker
m.healthMu.Lock()
m.healthInterval = newInterval
if m.healthTicker != nil {
m.healthTicker.Stop()
}
m.healthTicker = time.NewTicker(newInterval)
ticker = m.healthTicker
m.healthMu.Unlock()
if m.logger != nil {
m.logger.Info("periodic health check interval updated: ", newInterval)
}
case <-ticker.C:
m.probeAllNodes(timeout)
m.RequestProbeAllOnce(timeout)
}
}
}()
@@ -222,6 +263,41 @@ func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
}
}
// SetHealthCheckInterval updates the periodic health check interval at runtime.
// It is safe to call before StartPeriodicHealthCheck; it will be applied on start.
func (m *Manager) SetHealthCheckInterval(d time.Duration) {
if d <= 0 {
return
}
m.healthMu.Lock()
m.healthInterval = d
intervalC := m.healthIntervalC
m.healthMu.Unlock()
if intervalC != nil {
select {
case intervalC <- d:
default:
// drop if a newer update is already queued
}
}
}
// RequestProbeAllOnce triggers a full probe round at most once concurrently.
// If another full probe is already running, it returns immediately.
func (m *Manager) RequestProbeAllOnce(timeout time.Duration) {
if !m.probeReady {
return
}
if m.probeAllInFlight.Swap(true) {
return
}
go func() {
defer m.probeAllInFlight.Store(false)
m.probeAllNodes(timeout)
}()
}
// probeAllNodes checks all registered nodes concurrently.
func (m *Manager) probeAllNodes(timeout time.Duration) {
m.mu.RLock()
+24 -14
View File
@@ -213,10 +213,11 @@ type allSettingsResponse struct {
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
// Management
ManagementEnabled bool `json:"management_enabled"`
ManagementListen string `json:"management_listen"`
ManagementProbeTarget string `json:"management_probe_target"`
ManagementPassword string `json:"management_password"`
ManagementEnabled bool `json:"management_enabled"`
ManagementListen string `json:"management_listen"`
ManagementProbeTarget string `json:"management_probe_target"`
ManagementPassword string `json:"management_password"`
ManagementHealthCheckInterval string `json:"management_health_check_interval"`
// Subscription refresh
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
@@ -264,10 +265,11 @@ type allSettingsRequest struct {
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
// Management
ManagementEnabled *bool `json:"management_enabled"`
ManagementListen string `json:"management_listen"`
ManagementProbeTarget string `json:"management_probe_target"`
ManagementPassword string `json:"management_password"`
ManagementEnabled *bool `json:"management_enabled"`
ManagementListen string `json:"management_listen"`
ManagementProbeTarget string `json:"management_probe_target"`
ManagementPassword string `json:"management_password"`
ManagementHealthCheckInterval string `json:"management_health_check_interval"`
// Subscription refresh
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
@@ -326,10 +328,11 @@ func (s *Server) getAllSettings() allSettingsResponse {
PoolFailureThreshold: c.Pool.FailureThreshold,
PoolBlacklistDuration: c.Pool.BlacklistDuration.String(),
ManagementEnabled: mgmtEnabled,
ManagementListen: c.Management.Listen,
ManagementProbeTarget: c.Management.ProbeTarget,
ManagementPassword: c.Management.Password,
ManagementEnabled: mgmtEnabled,
ManagementListen: c.Management.Listen,
ManagementProbeTarget: c.Management.ProbeTarget,
ManagementPassword: c.Management.Password,
ManagementHealthCheckInterval: c.Management.HealthCheckInterval.String(),
SubRefreshEnabled: c.SubscriptionRefresh.Enabled,
SubRefreshInterval: c.SubscriptionRefresh.Interval.String(),
@@ -355,7 +358,7 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
req.ListenerProtocol, req.MultiPortProtocol,
req.PoolBlacklistDuration, req.SubRefreshInterval, req.SubRefreshTimeout,
req.SubRefreshHealthCheckTimeout, req.SubRefreshDrainTimeout,
req.GeoIPAutoUpdateInterval,
req.GeoIPAutoUpdateInterval, req.ManagementHealthCheckInterval,
); err != nil {
return fmt.Errorf("参数验证失败: %w", err)
}
@@ -410,6 +413,9 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
c.Management.Listen = req.ManagementListen
c.Management.ProbeTarget = strings.TrimSpace(req.ManagementProbeTarget)
c.Management.Password = req.ManagementPassword
if d, err := time.ParseDuration(req.ManagementHealthCheckInterval); err == nil && d > 0 {
c.Management.HealthCheckInterval = d
}
// Subscription refresh
c.SubscriptionRefresh.Enabled = req.SubRefreshEnabled
@@ -456,6 +462,10 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
s.logger.Printf("更新探测目标失败: %v", err)
}
}
// 动态更新周期健康检查间隔,使其立即生效
if c.Management.HealthCheckInterval > 0 && s.mgr != nil {
s.mgr.SetHealthCheckInterval(c.Management.HealthCheckInterval)
}
s.logger.Printf("✅ 设置已保存并同步到运行时")
return nil
@@ -1086,7 +1096,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"message": "设置已保存",
"need_reload": true,
"need_reload": false,
})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
+1 -1
View File
@@ -185,7 +185,7 @@ func loadNodesFromLegacyFile(path string) ([]string, error) {
// isProxyURI checks if a string looks like a proxy URI.
func isProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
"hysteria://", "hysteria2://", "hy2://", "http://", "socks5://"}
"hysteria://", "hysteria2://", "hy2://", "anytls://", "http://", "socks5://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {
+1 -1
View File
@@ -659,7 +659,7 @@ func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
}
func isProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://"}
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://", "anytls://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {