feat:1. 增加AnyTLS类型支持 2.定时5m全量探测节点健康改为2h,并增加配置可调整 3. 修复网页端修改配置文件保存失败问题
This commit is contained in:
@@ -61,6 +61,7 @@ management:
|
|||||||
enabled: true # 是否启用管理面板
|
enabled: true # 是否启用管理面板
|
||||||
listen: 0.0.0.0:9888 # 监听地址
|
listen: 0.0.0.0:9888 # 监听地址
|
||||||
probe_target: http://cp.cloudflare.com/generate_204 # 健康检查目标
|
probe_target: http://cp.cloudflare.com/generate_204 # 健康检查目标
|
||||||
|
health_check_interval: 2h # 周期健康检查间隔(默认 2 小时)
|
||||||
password: "123456" # WebUI 访问密码,为空则不需要密码
|
password: "123456" # WebUI 访问密码,为空则不需要密码
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const defaultSettings: SettingsData = {
|
|||||||
management_listen: '0.0.0.0:9090',
|
management_listen: '0.0.0.0:9090',
|
||||||
management_probe_target: '',
|
management_probe_target: '',
|
||||||
management_password: '',
|
management_password: '',
|
||||||
|
management_health_check_interval: '2h0m0s',
|
||||||
|
|
||||||
sub_refresh_enabled: false,
|
sub_refresh_enabled: false,
|
||||||
sub_refresh_interval: '1h0m0s',
|
sub_refresh_interval: '1h0m0s',
|
||||||
@@ -602,6 +603,18 @@ export default function SettingsPanel() {
|
|||||||
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
|
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">健康检查间隔</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="例如: 2h, 30m, 1h30m"
|
||||||
|
value={settings.management_health_check_interval}
|
||||||
|
onChange={(e) => updateField('management_health_check_interval', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">Go duration 格式:如 2h、30m、1h30m(修改后立即生效,无需重载)</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
<fieldset className="fieldset">
|
||||||
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
|
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export interface SettingsData {
|
|||||||
management_listen: string
|
management_listen: string
|
||||||
management_probe_target: string
|
management_probe_target: string
|
||||||
management_password: string
|
management_password: string
|
||||||
|
management_health_check_interval: string
|
||||||
|
|
||||||
// Subscription refresh
|
// Subscription refresh
|
||||||
sub_refresh_enabled: boolean
|
sub_refresh_enabled: boolean
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const (
|
|||||||
defaultDrainTimeout = 10 * time.Second
|
defaultDrainTimeout = 10 * time.Second
|
||||||
defaultHealthCheckTimeout = 30 * time.Second
|
defaultHealthCheckTimeout = 30 * time.Second
|
||||||
healthCheckPollInterval = 500 * time.Millisecond
|
healthCheckPollInterval = 500 * time.Millisecond
|
||||||
periodicHealthInterval = 5 * time.Minute
|
// periodicHealthInterval is configured via cfg.Management.HealthCheckInterval
|
||||||
periodicHealthTimeout = 10 * time.Second
|
periodicHealthTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,7 +160,8 @@ func (m *Manager) Start(ctx context.Context) error {
|
|||||||
// Start periodic health check after nodes are registered
|
// Start periodic health check after nodes are registered
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if m.monitorMgr != nil && !m.healthCheckStarted {
|
if m.monitorMgr != nil && !m.healthCheckStarted {
|
||||||
m.monitorMgr.StartPeriodicHealthCheck(periodicHealthInterval, periodicHealthTimeout)
|
interval := cfg.Management.HealthCheckInterval
|
||||||
|
m.monitorMgr.StartPeriodicHealthCheck(interval, periodicHealthTimeout)
|
||||||
m.healthCheckStarted = true
|
m.healthCheckStarted = true
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -280,6 +281,10 @@ func (m *Manager) Reload(newCfg *config.Config) error {
|
|||||||
l.OnConfigUpdate(newCfg)
|
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))
|
m.logger.Infof("reload completed successfully with %d nodes", len(newCfg.Nodes))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,11 +379,59 @@ func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound
|
|||||||
return option.Outbound{}, err
|
return option.Outbound{}, err
|
||||||
}
|
}
|
||||||
return option.Outbound{Type: C.TypeVMess, Tag: tag, Options: &opts}, nil
|
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:
|
default:
|
||||||
return option.Outbound{}, fmt.Errorf("unsupported scheme %q", parsed.Scheme)
|
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) {
|
func buildVLESSOptions(u *url.URL, skipCertVerify bool) (option.VLESSOutboundOptions, error) {
|
||||||
uuid := u.User.Username()
|
uuid := u.User.Username()
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -77,10 +78,11 @@ type MultiPortConfig struct {
|
|||||||
|
|
||||||
// ManagementConfig controls the monitoring HTTP endpoint.
|
// ManagementConfig controls the monitoring HTTP endpoint.
|
||||||
type ManagementConfig struct {
|
type ManagementConfig struct {
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
Listen string `yaml:"listen"`
|
Listen string `yaml:"listen"`
|
||||||
ProbeTarget string `yaml:"probe_target"`
|
ProbeTarget string `yaml:"probe_target"`
|
||||||
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
|
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
|
||||||
|
HealthCheckInterval time.Duration `yaml:"health_check_interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscriptionRefreshConfig controls subscription auto-refresh and reload settings.
|
// SubscriptionRefreshConfig controls subscription auto-refresh and reload settings.
|
||||||
@@ -266,6 +268,9 @@ func (c *Config) applyDefaults() error {
|
|||||||
defaultEnabled := true
|
defaultEnabled := true
|
||||||
c.Management.Enabled = &defaultEnabled
|
c.Management.Enabled = &defaultEnabled
|
||||||
}
|
}
|
||||||
|
if c.Management.HealthCheckInterval <= 0 {
|
||||||
|
c.Management.HealthCheckInterval = 2 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
// Subscription refresh defaults
|
// Subscription refresh defaults
|
||||||
if c.SubscriptionRefresh.Interval <= 0 {
|
if c.SubscriptionRefresh.Interval <= 0 {
|
||||||
@@ -678,7 +683,7 @@ func isBase64(s string) bool {
|
|||||||
|
|
||||||
// IsProxyURI checks if a string is a valid proxy URI
|
// IsProxyURI checks if a string is a valid proxy URI
|
||||||
func IsProxyURI(s string) bool {
|
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 {
|
for _, scheme := range schemes {
|
||||||
if strings.HasPrefix(strings.ToLower(s), scheme) {
|
if strings.HasPrefix(strings.ToLower(s), scheme) {
|
||||||
return true
|
return true
|
||||||
@@ -764,6 +769,8 @@ func convertClashProxyToURI(p clashProxy) string {
|
|||||||
return buildShadowsocksURI(p)
|
return buildShadowsocksURI(p)
|
||||||
case "hysteria2", "hy2":
|
case "hysteria2", "hy2":
|
||||||
return buildHysteria2URI(p)
|
return buildHysteria2URI(p)
|
||||||
|
case "anytls":
|
||||||
|
return buildAnyTLSURI(p)
|
||||||
default:
|
default:
|
||||||
return ""
|
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))
|
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.
|
// RLock acquires a read lock on the config.
|
||||||
func (c *Config) RLock() {
|
func (c *Config) RLock() {
|
||||||
if c != nil {
|
if c != nil {
|
||||||
@@ -1044,7 +1070,7 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
|
|||||||
listenerProtocol, multiPortProtocol,
|
listenerProtocol, multiPortProtocol,
|
||||||
poolBlacklistDuration, subRefreshInterval, subRefreshTimeout,
|
poolBlacklistDuration, subRefreshInterval, subRefreshTimeout,
|
||||||
subRefreshHealthCheckTimeout, subRefreshDrainTimeout,
|
subRefreshHealthCheckTimeout, subRefreshDrainTimeout,
|
||||||
geoIPAutoUpdateInterval string) error {
|
geoIPAutoUpdateInterval, managementHealthCheckInterval string) error {
|
||||||
|
|
||||||
// Validate mode
|
// Validate mode
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -1080,6 +1106,7 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
|
|||||||
{"健康检查超时", subRefreshHealthCheckTimeout},
|
{"健康检查超时", subRefreshHealthCheckTimeout},
|
||||||
{"排空超时", subRefreshDrainTimeout},
|
{"排空超时", subRefreshDrainTimeout},
|
||||||
{"GeoIP 更新间隔", geoIPAutoUpdateInterval},
|
{"GeoIP 更新间隔", geoIPAutoUpdateInterval},
|
||||||
|
{"周期健康检查间隔", managementHealthCheckInterval},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, field := range durationFields {
|
for _, field := range durationFields {
|
||||||
@@ -1153,6 +1180,22 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
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)
|
return fmt.Errorf("rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,13 +495,14 @@ func extractHostFromURI(uri string) string {
|
|||||||
// Handle different URI schemes
|
// Handle different URI schemes
|
||||||
lowerURI := strings.ToLower(uri)
|
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://") ||
|
if strings.HasPrefix(lowerURI, "vmess://") ||
|
||||||
strings.HasPrefix(lowerURI, "vless://") ||
|
strings.HasPrefix(lowerURI, "vless://") ||
|
||||||
strings.HasPrefix(lowerURI, "trojan://") ||
|
strings.HasPrefix(lowerURI, "trojan://") ||
|
||||||
strings.HasPrefix(lowerURI, "hysteria://") ||
|
strings.HasPrefix(lowerURI, "hysteria://") ||
|
||||||
strings.HasPrefix(lowerURI, "hysteria2://") ||
|
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
|
// Standard URL format: scheme://user@host:port?params#fragment
|
||||||
parsed, err := url.Parse(uri)
|
parsed, err := url.Parse(uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ type Manager struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
logger Logger
|
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
|
// Logger interface for logging
|
||||||
@@ -199,20 +207,53 @@ func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
|
|||||||
}
|
}
|
||||||
return
|
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() {
|
go func() {
|
||||||
// 启动后立即进行一次检查
|
// 启动后立即进行一次检查(走去重)
|
||||||
m.probeAllNodes(timeout)
|
m.RequestProbeAllOnce(timeout)
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-m.ctx.Done():
|
case <-m.ctx.Done():
|
||||||
return
|
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:
|
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.
|
// probeAllNodes checks all registered nodes concurrently.
|
||||||
func (m *Manager) probeAllNodes(timeout time.Duration) {
|
func (m *Manager) probeAllNodes(timeout time.Duration) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
+24
-14
@@ -213,10 +213,11 @@ type allSettingsResponse struct {
|
|||||||
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
|
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
|
||||||
|
|
||||||
// Management
|
// Management
|
||||||
ManagementEnabled bool `json:"management_enabled"`
|
ManagementEnabled bool `json:"management_enabled"`
|
||||||
ManagementListen string `json:"management_listen"`
|
ManagementListen string `json:"management_listen"`
|
||||||
ManagementProbeTarget string `json:"management_probe_target"`
|
ManagementProbeTarget string `json:"management_probe_target"`
|
||||||
ManagementPassword string `json:"management_password"`
|
ManagementPassword string `json:"management_password"`
|
||||||
|
ManagementHealthCheckInterval string `json:"management_health_check_interval"`
|
||||||
|
|
||||||
// Subscription refresh
|
// Subscription refresh
|
||||||
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
|
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
|
||||||
@@ -264,10 +265,11 @@ type allSettingsRequest struct {
|
|||||||
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
|
PoolBlacklistDuration string `json:"pool_blacklist_duration"`
|
||||||
|
|
||||||
// Management
|
// Management
|
||||||
ManagementEnabled *bool `json:"management_enabled"`
|
ManagementEnabled *bool `json:"management_enabled"`
|
||||||
ManagementListen string `json:"management_listen"`
|
ManagementListen string `json:"management_listen"`
|
||||||
ManagementProbeTarget string `json:"management_probe_target"`
|
ManagementProbeTarget string `json:"management_probe_target"`
|
||||||
ManagementPassword string `json:"management_password"`
|
ManagementPassword string `json:"management_password"`
|
||||||
|
ManagementHealthCheckInterval string `json:"management_health_check_interval"`
|
||||||
|
|
||||||
// Subscription refresh
|
// Subscription refresh
|
||||||
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
|
SubRefreshEnabled bool `json:"sub_refresh_enabled"`
|
||||||
@@ -326,10 +328,11 @@ func (s *Server) getAllSettings() allSettingsResponse {
|
|||||||
PoolFailureThreshold: c.Pool.FailureThreshold,
|
PoolFailureThreshold: c.Pool.FailureThreshold,
|
||||||
PoolBlacklistDuration: c.Pool.BlacklistDuration.String(),
|
PoolBlacklistDuration: c.Pool.BlacklistDuration.String(),
|
||||||
|
|
||||||
ManagementEnabled: mgmtEnabled,
|
ManagementEnabled: mgmtEnabled,
|
||||||
ManagementListen: c.Management.Listen,
|
ManagementListen: c.Management.Listen,
|
||||||
ManagementProbeTarget: c.Management.ProbeTarget,
|
ManagementProbeTarget: c.Management.ProbeTarget,
|
||||||
ManagementPassword: c.Management.Password,
|
ManagementPassword: c.Management.Password,
|
||||||
|
ManagementHealthCheckInterval: c.Management.HealthCheckInterval.String(),
|
||||||
|
|
||||||
SubRefreshEnabled: c.SubscriptionRefresh.Enabled,
|
SubRefreshEnabled: c.SubscriptionRefresh.Enabled,
|
||||||
SubRefreshInterval: c.SubscriptionRefresh.Interval.String(),
|
SubRefreshInterval: c.SubscriptionRefresh.Interval.String(),
|
||||||
@@ -355,7 +358,7 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
|
|||||||
req.ListenerProtocol, req.MultiPortProtocol,
|
req.ListenerProtocol, req.MultiPortProtocol,
|
||||||
req.PoolBlacklistDuration, req.SubRefreshInterval, req.SubRefreshTimeout,
|
req.PoolBlacklistDuration, req.SubRefreshInterval, req.SubRefreshTimeout,
|
||||||
req.SubRefreshHealthCheckTimeout, req.SubRefreshDrainTimeout,
|
req.SubRefreshHealthCheckTimeout, req.SubRefreshDrainTimeout,
|
||||||
req.GeoIPAutoUpdateInterval,
|
req.GeoIPAutoUpdateInterval, req.ManagementHealthCheckInterval,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("参数验证失败: %w", err)
|
return fmt.Errorf("参数验证失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -410,6 +413,9 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
|
|||||||
c.Management.Listen = req.ManagementListen
|
c.Management.Listen = req.ManagementListen
|
||||||
c.Management.ProbeTarget = strings.TrimSpace(req.ManagementProbeTarget)
|
c.Management.ProbeTarget = strings.TrimSpace(req.ManagementProbeTarget)
|
||||||
c.Management.Password = req.ManagementPassword
|
c.Management.Password = req.ManagementPassword
|
||||||
|
if d, err := time.ParseDuration(req.ManagementHealthCheckInterval); err == nil && d > 0 {
|
||||||
|
c.Management.HealthCheckInterval = d
|
||||||
|
}
|
||||||
|
|
||||||
// Subscription refresh
|
// Subscription refresh
|
||||||
c.SubscriptionRefresh.Enabled = req.SubRefreshEnabled
|
c.SubscriptionRefresh.Enabled = req.SubRefreshEnabled
|
||||||
@@ -456,6 +462,10 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
|
|||||||
s.logger.Printf("更新探测目标失败: %v", err)
|
s.logger.Printf("更新探测目标失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 动态更新周期健康检查间隔,使其立即生效
|
||||||
|
if c.Management.HealthCheckInterval > 0 && s.mgr != nil {
|
||||||
|
s.mgr.SetHealthCheckInterval(c.Management.HealthCheckInterval)
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Printf("✅ 设置已保存并同步到运行时")
|
s.logger.Printf("✅ 设置已保存并同步到运行时")
|
||||||
return nil
|
return nil
|
||||||
@@ -1086,7 +1096,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"message": "设置已保存",
|
"message": "设置已保存",
|
||||||
"need_reload": true,
|
"need_reload": false,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func loadNodesFromLegacyFile(path string) ([]string, error) {
|
|||||||
// isProxyURI checks if a string looks like a proxy URI.
|
// isProxyURI checks if a string looks like a proxy URI.
|
||||||
func isProxyURI(s string) bool {
|
func isProxyURI(s string) bool {
|
||||||
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
|
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
|
||||||
"hysteria://", "hysteria2://", "hy2://", "http://", "socks5://"}
|
"hysteria://", "hysteria2://", "hy2://", "anytls://", "http://", "socks5://"}
|
||||||
lower := strings.ToLower(s)
|
lower := strings.ToLower(s)
|
||||||
for _, scheme := range schemes {
|
for _, scheme := range schemes {
|
||||||
if strings.HasPrefix(lower, scheme) {
|
if strings.HasPrefix(lower, scheme) {
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isProxyURI(s string) bool {
|
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)
|
lower := strings.ToLower(s)
|
||||||
for _, scheme := range schemes {
|
for _, scheme := range schemes {
|
||||||
if strings.HasPrefix(lower, scheme) {
|
if strings.HasPrefix(lower, scheme) {
|
||||||
|
|||||||
Reference in New Issue
Block a user