feat:1. 增加AnyTLS类型支持 2.定时5m全量探测节点健康改为2h,并增加配置可调整 3. 修复网页端修改配置文件保存失败问题
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user