Compare commits

..
10 Commits
Author SHA1 Message Date
root 766ac225ef feat: improve vmess structured editor toggling 2026-03-26 00:15:23 +08:00
夏目侧耳andGitHub 5c0c07a912 Merge pull request #5 from HexBen123/feat/http-socks5-upstream
feat: 支持 HTTP 和 SOCKS5 上游节点 URI
2026-03-14 21:59:32 +08:00
XHB 6cbb3a7b15 feat: support http and socks5 upstream node uris 2026-03-14 03:41:46 +08:00
夏目侧耳andGitHub fee58d1cf6 Merge pull request #3 from momo-z/feat/anytls-geoip-savefix
feat:1. 增加AnyTLS类型支持 2.定时5m全量探测节点健康改为2h,并增加配置可调整 3. 修复网页端修改配置文件保存失败问题
2026-03-12 16:28:18 +08:00
zhangyongsheng 284977b7a5 feat:1. 增加AnyTLS类型支持 2.定时5m全量探测节点健康改为2h,并增加配置可调整 3. 修复网页端修改配置文件保存失败问题 2026-03-12 16:21:34 +08:00
xiamuceer-j 5484d6f65a feat: add socks5 listener support 2026-03-06 16:13:41 +08:00
夏目侧耳andGitHub c37f26f376 Merge pull request #1 from RSJWY/main
fix:打开#settings页面时后端在没有订阅链接的情况下返回 null,导致页面异常
2026-03-06 15:59:00 +08:00
xiamuceer-j a31ac4f51a update: 更新支持socket 5端口监听 2026-03-06 15:17:49 +08:00
RSJWY c454446fb9 fix:
在打开/#settings页面时控制台“Uncaught TypeError: Cannot read properties of null (reading 'length')”报错
2026-03-05 21:29:09 +08:00
xiamuceer-j 20e5402854 update: 更新readme文档 2026-03-02 14:13:33 +08:00
28 changed files with 916 additions and 130 deletions
+11 -1
View File
@@ -5,6 +5,15 @@ EasyProxiesV2 是一个轻量级、高性能的代理池与订阅管理工具,
> 二开声明:本项目基于 [jasonwong1991/easy_proxies](https://github.com/jasonwong1991/easy_proxies) 二次开发,V2 版本重点重构了前端与工程化流程。
## ❤️ 赞助木木
木木是独立开发者 / 开源爱好者,长期投入开源项目维护与迭代。
如果 EasyProxiesV2 对你有帮助,或者你认可我的工作,欢迎请我喝杯咖啡。你的支持是我持续创造的动力源泉 ⚡
- [赞助地址](https://mumuverse.space:1588/)
---
## ✨ 核心特性
- 现代化 Web UI(React + Vite + Tailwind + DaisyUI)
@@ -92,7 +101,8 @@ cp ./config.example.yaml ./config.yaml
配置模板见 `config.example.yaml`,重点关注:
- `mode`: `pool` / `multi-port` / `hybrid`
- `listener`: 代理入口监听与认证
- `listener`: 代理入口监听与认证(新增 `listener.protocol`: `http` / `socks5` / `mixed`)
- `multi_port`: 多端口入口参数(新增 `multi_port.protocol`: `http` / `socks5` / `mixed`)
- `management.listen`: Web 管理面板地址(默认 `0.0.0.0:9888`)
- `management.password`: 面板登录密码(为空则不需要登录)
- `subscriptions` / `nodes_file` / `nodes`: 节点来源(三选一或混用)
+3
View File
@@ -29,6 +29,7 @@ skip_cert_verify: false
listener:
address: 0.0.0.0 # 监听地址
port: 2323 # 监听端口
protocol: http # 监听协议: http | socks5 | mixed
username: username # 代理认证用户名
password: password # 代理认证密码
@@ -49,6 +50,7 @@ pool:
multi_port:
address: 0.0.0.0 # 监听地址
base_port: 24000 # 起始端口号
protocol: http # 监听协议: http | socks5 | mixed
username: mpuser # 默认认证用户名
password: mppass # 默认认证密码
@@ -59,6 +61,7 @@ management:
enabled: true # 是否启用管理面板
listen: 0.0.0.0:9888 # 监听地址
probe_target: http://cp.cloudflare.com/generate_204 # 健康检查目标
health_check_interval: 2h # 周期健康检查间隔(默认 2 小时)
password: "123456" # WebUI 访问密码,为空则不需要密码
# ───────────────────────────────────────────────────────────────
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"version": "1.0.1",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
+4 -1
View File
@@ -5,6 +5,7 @@ import DebugPanel from './components/DebugPanel'
import SettingsPanel from './components/SettingsPanel'
import LoginPage from './components/LoginPage'
import { checkAuth, getToken, logout } from './api/client'
import packageJson from '../package.json'
type AuthState = 'loading' | 'need_login' | 'authenticated'
type TabId = 'monitor' | 'manage' | 'debug' | 'settings'
@@ -65,6 +66,8 @@ const MENU_ITEMS: { id: TabId; label: string; icon: React.ReactNode; desc: strin
const VALID_TABS: TabId[] = ['monitor', 'manage', 'debug', 'settings']
const THEME_STORAGE_KEY = 'ep-theme'
const APP_VERSION = `v${packageJson.version}`
const DARK_THEMES = new Set([
'dark', 'synthwave', 'halloween', 'forest', 'black', 'luxury',
'dracula', 'business', 'night', 'coffee', 'dim', 'sunset', 'abyss',
@@ -379,7 +382,7 @@ function App() {
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-base-content/80">系统运行正常</span>
<span className="text-xs text-base-content/40">v1.0.0</span>
<span className="text-xs text-base-content/40">{APP_VERSION}</span>
</div>
</div>
</div>
+277 -2
View File
@@ -125,6 +125,139 @@ const emptyPayload: ConfigNodePayload = {
password: '',
}
interface VmessFormFields {
ps: string
add: string
port: string
id: string
aid: string
scy: string
net: string
type: string
host: string
path: string
tls: string
sni: string
alpn: string
}
const emptyVmessFields: VmessFormFields = {
ps: '',
add: '',
port: '443',
id: '',
aid: '0',
scy: 'auto',
net: 'ws',
type: 'none',
host: '',
path: '',
tls: 'tls',
sni: '',
alpn: 'http/1.1',
}
interface ParsedShareLink {
name?: string
protocol?: string
valid: boolean
vmess?: VmessFormFields
}
function safeBase64Decode(value: string): string | null {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
return atob(padded)
} catch {
return null
}
}
function safeBase64Encode(value: string): string {
return btoa(unescape(encodeURIComponent(value)))
}
function parseVmessUri(uri: string): VmessFormFields | null {
const encoded = uri.slice('vmess://'.length).trim()
if (!encoded) return null
const decoded = safeBase64Decode(encoded)
if (!decoded) return null
try {
const payload = JSON.parse(decoded) as Record<string, unknown>
return {
ps: String(payload.ps || ''),
add: String(payload.add || ''),
port: String(payload.port || '443'),
id: String(payload.id || ''),
aid: String(payload.aid || '0'),
scy: String(payload.scy || 'auto'),
net: String(payload.net || 'ws'),
type: String(payload.type || 'none'),
host: String(payload.host || ''),
path: String(payload.path || ''),
tls: String(payload.tls || ''),
sni: String(payload.sni || ''),
alpn: String(payload.alpn || ''),
}
} catch {
return null
}
}
function buildVmessUri(fields: VmessFormFields): string {
const payload: Record<string, string> = {
v: '2',
ps: fields.ps.trim(),
add: fields.add.trim(),
port: String(fields.port || '443').trim(),
id: fields.id.trim(),
aid: String(fields.aid || '0').trim(),
scy: fields.scy.trim() || 'auto',
net: fields.net.trim() || 'ws',
type: fields.type.trim() || 'none',
host: fields.host.trim(),
path: fields.path.trim(),
tls: fields.tls.trim(),
}
if (fields.sni.trim()) payload.sni = fields.sni.trim()
if (fields.alpn.trim()) payload.alpn = fields.alpn.trim()
return `vmess://${safeBase64Encode(JSON.stringify(payload))}`
}
function parseGenericName(uri: string): string | undefined {
try {
const parsed = new URL(uri)
return parsed.hash ? decodeURIComponent(parsed.hash.slice(1)).trim() || undefined : undefined
} catch {
return undefined
}
}
function parseShareLink(uri: string): ParsedShareLink {
const trimmed = uri.trim()
if (!trimmed) return { valid: false }
const schemeMatch = trimmed.match(/^([a-z0-9+.-]+):\/\//i)
const protocol = schemeMatch?.[1]?.toLowerCase()
if (!protocol) return { valid: false }
if (protocol === 'vmess') {
const vmess = parseVmessUri(trimmed)
return {
protocol,
vmess: vmess || undefined,
name: vmess?.ps?.trim() || undefined,
valid: !!vmess,
}
}
return { protocol, name: parseGenericName(trimmed), valid: true }
}
// ---- Component ----
export default function ManagePanel() {
@@ -141,6 +274,10 @@ export default function ManagePanel() {
const [form, setForm] = useState<ConfigNodePayload>(emptyPayload)
const [formError, setFormError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [nameManuallyEdited, setNameManuallyEdited] = useState(false)
const [parseHint, setParseHint] = useState('')
const [vmessFields, setVmessFields] = useState<VmessFormFields>(emptyVmessFields)
const [vmessEditorOpen, setVmessEditorOpen] = useState(false)
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
@@ -320,10 +457,41 @@ export default function ManagePanel() {
}
}
const applyParsedLink = (value: string, allowAutoRename: boolean) => {
const parsed = parseShareLink(value)
setParseHint(
!value.trim()
? ''
: parsed.valid
? `已识别协议:${parsed.protocol || '未知'}${parsed.name ? `,建议名称:${parsed.name}` : ',未从链接中提取到名称'}`
: '未识别为有效的分享链接格式'
)
if (parsed.protocol === 'vmess' && parsed.vmess) {
setVmessFields(parsed.vmess)
setVmessEditorOpen(true)
} else if (!value.trim()) {
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
}
setForm(prev => {
const next: ConfigNodePayload = { ...prev, uri: value }
if (allowAutoRename && parsed.name) {
next.name = parsed.name
}
return next
})
}
const openCreateModal = () => {
setEditingNode(null)
setForm(emptyPayload)
setFormError('')
setNameManuallyEdited(false)
setParseHint('')
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
setModalOpen(true)
}
@@ -337,9 +505,31 @@ export default function ManagePanel() {
password: node.password || '',
})
setFormError('')
setNameManuallyEdited(false)
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
applyParsedLink(node.uri, true)
setModalOpen(true)
}
const handleUriInputChange = (value: string) => {
applyParsedLink(value, !nameManuallyEdited)
}
const updateVmessFields = (patch: Partial<VmessFormFields>) => {
setVmessFields(prev => {
const next = { ...prev, ...patch }
const nextUri = buildVmessUri(next)
setForm(current => ({
...current,
uri: nextUri,
name: !nameManuallyEdited ? (next.ps || current.name) : current.name,
}))
setParseHint(`已识别协议:vmess${next.ps ? `,建议名称:${next.ps}` : ''}`)
return next
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) { setFormError('节点名称不能为空'); return }
@@ -958,7 +1148,14 @@ export default function ManagePanel() {
<input
type="text" className="input input-sm w-full" placeholder="节点名称"
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
onChange={(e) => {
const value = e.target.value
setNameManuallyEdited(true)
setForm(f => ({ ...f, name: value }))
if (vmessEditorOpen) {
setVmessFields(prev => ({ ...prev, ps: value }))
}
}}
disabled={!!editingNode}
/>
</fieldset>
@@ -968,9 +1165,87 @@ export default function ManagePanel() {
type="text" className="input input-sm w-full font-mono text-xs"
placeholder="trojan://password@host:port?..."
value={form.uri}
onChange={(e) => setForm(f => ({ ...f, uri: e.target.value }))}
onChange={(e) => handleUriInputChange(e.target.value)}
/>
<div className="label text-base-content/50 mt-1 flex flex-col items-start gap-1">
<span>粘贴分享链接后会自动解析并回填字段;vmess 支持结构化编辑,新增/编辑都支持。</span>
{parseHint && <span className="text-info">{parseHint}</span>}
{parseShareLink(form.uri).protocol === 'vmess' && (
<button
type="button"
className="btn btn-xs btn-outline btn-info mt-1"
onClick={() => setVmessEditorOpen(open => !open)}
>
{vmessEditorOpen ? '收起结构化字段' : '展开结构化字段'}
</button>
)}
</div>
</fieldset>
{vmessEditorOpen && parseShareLink(form.uri).protocol === 'vmess' && (
<div className="rounded-xl border border-info/20 bg-info/5 p-4 mb-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm">VMess 结构化字段</div>
<div className="text-xs text-base-content/50">修改下面字段时,会自动重新生成上方 URI。</div>
</div>
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setVmessEditorOpen(false)}>收起</button>
</div>
<div className="grid grid-cols-2 gap-3">
<fieldset className="fieldset">
<legend className="fieldset-legend">节点名称</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.ps} onChange={(e) => updateVmessFields({ ps: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">地址</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.add} onChange={(e) => updateVmessFields({ add: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">端口</legend>
<input type="number" className="input input-sm w-full" value={vmessFields.port} onChange={(e) => updateVmessFields({ port: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">UUID</legend>
<input type="text" className="input input-sm w-full font-mono text-xs" value={vmessFields.id} onChange={(e) => updateVmessFields({ id: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">加密</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.scy} onChange={(e) => updateVmessFields({ scy: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">alterId</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.aid} onChange={(e) => updateVmessFields({ aid: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">传输</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.net} onChange={(e) => updateVmessFields({ net: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">type</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.type} onChange={(e) => updateVmessFields({ type: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Host</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.host} onChange={(e) => updateVmessFields({ host: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Path</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.path} onChange={(e) => updateVmessFields({ path: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">TLS</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.tls} onChange={(e) => updateVmessFields({ tls: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">SNI</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.sni} onChange={(e) => updateVmessFields({ sni: e.target.value })} />
</fieldset>
<fieldset className="fieldset col-span-2">
<legend className="fieldset-legend">ALPN</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.alpn} onChange={(e) => updateVmessFields({ alpn: e.target.value })} placeholder="如 http/1.1 或 h2,http/1.1" />
</fieldset>
</div>
</div>
)}
<fieldset className="fieldset mb-3">
<legend className="fieldset-legend">本地代理端口</legend>
<input
+45 -1
View File
@@ -10,11 +10,13 @@ const defaultSettings: SettingsData = {
listener_address: '0.0.0.0',
listener_port: 2323,
listener_protocol: 'http',
listener_username: '',
listener_password: '',
multi_port_address: '0.0.0.0',
multi_port_base_port: 24000,
multi_port_protocol: 'http',
multi_port_username: '',
multi_port_password: '',
@@ -26,6 +28,7 @@ const defaultSettings: SettingsData = {
management_listen: '0.0.0.0:9090',
management_probe_target: '',
management_password: '',
management_health_check_interval: '2h0m0s',
sub_refresh_enabled: false,
sub_refresh_interval: '1h0m0s',
@@ -76,7 +79,8 @@ export default function SettingsPanel() {
fetchSettings(),
refreshSubStatus(),
])
const merged = { ...defaultSettings, ...settingsData }
const subscriptions = settingsData.subscriptions || []
const merged = { ...defaultSettings, ...settingsData, subscriptions }
setSettings(merged)
setSavedSettings(merged)
setIsDirty(false)
@@ -387,6 +391,20 @@ export default function SettingsPanel() {
</fieldset>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听协议</legend>
<select
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.listener_protocol}
onChange={(e) => updateField('listener_protocol', e.target.value)}
>
<option value="http">http</option>
<option value="socks5">socks5</option>
<option value="mixed">mixed (HTTP + SOCKS5)</option>
</select>
<p className="label text-base-content/50 mt-1">mixed 表示同端口同时支持 HTTP 与 SOCKS5</p>
</fieldset>
<div className="grid grid-cols-2 gap-4 pt-2">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">代理用户名</legend>
@@ -448,6 +466,20 @@ export default function SettingsPanel() {
</fieldset>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听协议</legend>
<select
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.multi_port_protocol}
onChange={(e) => updateField('multi_port_protocol', e.target.value)}
>
<option value="http">http</option>
<option value="socks5">socks5</option>
<option value="mixed">mixed (HTTP + SOCKS5)</option>
</select>
<p className="label text-base-content/50 mt-1">应用于 multi-port / hybrid 的每个节点入口</p>
</fieldset>
<div className="grid grid-cols-2 gap-4 pt-2">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">默认用户名</legend>
@@ -571,6 +603,18 @@ export default function SettingsPanel() {
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
</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">
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
<input
+3
View File
@@ -86,12 +86,14 @@ export interface SettingsData {
// Listener
listener_address: string
listener_port: number
listener_protocol: string
listener_username: string
listener_password: string
// Multi-port
multi_port_address: string
multi_port_base_port: number
multi_port_protocol: string
multi_port_username: string
multi_port_password: string
@@ -105,6 +107,7 @@ export interface SettingsData {
management_listen: string
management_probe_target: string
management_password: string
management_health_check_interval: string
// Subscription refresh
sub_refresh_enabled: boolean
+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
}
+177 -28
View File
@@ -199,23 +199,19 @@ func Build(cfg *config.Config) (option.Options, error) {
Options: &perOptions,
}
outbounds = append(outbounds, perPool)
inboundOptions := &option.HTTPMixedInboundOptions{
ListenOptions: option.ListenOptions{
Listen: addr,
ListenPort: meta.Port,
},
}
username := cfg.MultiPort.Username
password := cfg.MultiPort.Password
if username != "" {
inboundOptions.Users = []auth.User{{Username: username, Password: password}}
}
inboundTag := fmt.Sprintf("in-%s", tag)
inbounds = append(inbounds, option.Inbound{
Type: C.TypeHTTP,
Tag: inboundTag,
Options: inboundOptions,
})
inbound, err := buildInboundByProtocol(
cfg.MultiPort.Protocol,
addr,
meta.Port,
cfg.MultiPort.Username,
cfg.MultiPort.Password,
inboundTag,
)
if err != nil {
return option.Options{}, fmt.Errorf("build multi-port inbound for %s: %w", tag, err)
}
inbounds = append(inbounds, inbound)
route.Rules = append(route.Rules, option.Rule{
Type: C.RuleTypeDefault,
DefaultOptions: option.DefaultRule{
@@ -292,24 +288,59 @@ func buildPoolInbound(cfg *config.Config) (option.Inbound, error) {
if err != nil {
return option.Inbound{}, fmt.Errorf("parse listener address: %w", err)
}
inboundOptions := &option.HTTPMixedInboundOptions{
return buildInboundByProtocol(
cfg.Listener.Protocol,
listenAddr,
cfg.Listener.Port,
cfg.Listener.Username,
cfg.Listener.Password,
"http-in",
)
}
func buildInboundByProtocol(protocol string, listenAddr *badoption.Addr, port uint16, username, password, tag string) (option.Inbound, error) {
users := []auth.User(nil)
if username != "" {
users = []auth.User{{Username: username, Password: password}}
}
switch protocol {
case config.InboundProtocolHTTP:
opts := &option.HTTPMixedInboundOptions{
ListenOptions: option.ListenOptions{
Listen: listenAddr,
ListenPort: cfg.Listener.Port,
ListenPort: port,
},
}
if cfg.Listener.Username != "" {
inboundOptions.Users = []auth.User{{
Username: cfg.Listener.Username,
Password: cfg.Listener.Password,
}}
if len(users) > 0 {
opts.Users = users
}
inbound := option.Inbound{
Type: C.TypeHTTP,
Tag: "http-in",
Options: inboundOptions,
return option.Inbound{Type: C.TypeHTTP, Tag: tag, Options: opts}, nil
case config.InboundProtocolSOCKS5:
opts := &option.SocksInboundOptions{
ListenOptions: option.ListenOptions{
Listen: listenAddr,
ListenPort: port,
},
}
if len(users) > 0 {
opts.Users = users
}
return option.Inbound{Type: C.TypeSOCKS, Tag: tag, Options: opts}, nil
case config.InboundProtocolMixed:
opts := &option.HTTPMixedInboundOptions{
ListenOptions: option.ListenOptions{
Listen: listenAddr,
ListenPort: port,
},
}
if len(users) > 0 {
opts.Users = users
}
return option.Inbound{Type: C.TypeMixed, Tag: tag, Options: opts}, nil
default:
return option.Inbound{}, fmt.Errorf("unsupported inbound protocol %q", protocol)
}
return inbound, nil
}
func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound, error) {
@@ -318,6 +349,18 @@ func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound
return option.Outbound{}, fmt.Errorf("parse uri: %w", err)
}
switch strings.ToLower(parsed.Scheme) {
case "http":
opts, err := buildHTTPOptions(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeHTTP, Tag: tag, Options: &opts}, nil
case "socks5":
opts, err := buildSOCKSOptions(parsed)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeSOCKS, Tag: tag, Options: &opts}, nil
case "vless":
opts, err := buildVLESSOptions(parsed, skipCertVerify)
if err != nil {
@@ -348,11 +391,117 @@ 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 buildHTTPOptions(u *url.URL, skipCertVerify bool) (option.HTTPOutboundOptions, error) {
query := u.Query()
defaultPort := 80
if strings.EqualFold(query.Get("security"), "tls") {
defaultPort = 443
}
server, port, err := hostPort(u, defaultPort)
if err != nil {
return option.HTTPOutboundOptions{}, err
}
opts := option.HTTPOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
}
if u.User != nil {
opts.Username = u.User.Username()
if password, ok := u.User.Password(); ok {
opts.Password = password
}
}
if path := u.EscapedPath(); path != "" {
opts.Path = path
}
if host := strings.TrimSpace(query.Get("host")); host != "" {
opts.Headers = badoption.HTTPHeader{"Host": {host}}
}
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
return option.HTTPOutboundOptions{}, err
} else if tlsOptions != nil {
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
return opts, nil
}
func buildSOCKSOptions(u *url.URL) (option.SOCKSOutboundOptions, error) {
server, port, err := hostPort(u, 1080)
if err != nil {
return option.SOCKSOutboundOptions{}, err
}
opts := option.SOCKSOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Version: "5",
}
if u.User != nil {
opts.Username = u.User.Username()
if password, ok := u.User.Password(); ok {
opts.Password = password
}
}
if network := strings.ToLower(strings.TrimSpace(u.Query().Get("network"))); network != "" {
opts.Network = option.NetworkList(network)
}
return opts, nil
}
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 == "" {
+68
View File
@@ -0,0 +1,68 @@
package builder
import (
"testing"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
)
func TestBuildNodeOutboundSupportsSOCKS5(t *testing.T) {
outbound, err := buildNodeOutbound("socks-node", "socks5://demo:secret@99.144.123.135:30350", false)
if err != nil {
t.Fatalf("buildNodeOutbound returned error: %v", err)
}
if outbound.Type != C.TypeSOCKS {
t.Fatalf("outbound type = %q, want %q", outbound.Type, C.TypeSOCKS)
}
opts, ok := outbound.Options.(*option.SOCKSOutboundOptions)
if !ok {
t.Fatalf("outbound options type = %T, want *option.SOCKSOutboundOptions", outbound.Options)
}
if opts.Server != "99.144.123.135" {
t.Fatalf("server = %q, want %q", opts.Server, "99.144.123.135")
}
if opts.ServerPort != 30350 {
t.Fatalf("server port = %d, want %d", opts.ServerPort, 30350)
}
if opts.Username != "demo" {
t.Fatalf("username = %q, want %q", opts.Username, "demo")
}
if opts.Password != "secret" {
t.Fatalf("password = %q, want %q", opts.Password, "secret")
}
if opts.Version != "5" {
t.Fatalf("version = %q, want %q", opts.Version, "5")
}
}
func TestBuildNodeOutboundSupportsHTTP(t *testing.T) {
outbound, err := buildNodeOutbound("http-node", "http://alice:wonderland@example.com:8080/proxy", false)
if err != nil {
t.Fatalf("buildNodeOutbound returned error: %v", err)
}
if outbound.Type != C.TypeHTTP {
t.Fatalf("outbound type = %q, want %q", outbound.Type, C.TypeHTTP)
}
opts, ok := outbound.Options.(*option.HTTPOutboundOptions)
if !ok {
t.Fatalf("outbound options type = %T, want *option.HTTPOutboundOptions", outbound.Options)
}
if opts.Server != "example.com" {
t.Fatalf("server = %q, want %q", opts.Server, "example.com")
}
if opts.ServerPort != 8080 {
t.Fatalf("server port = %d, want %d", opts.ServerPort, 8080)
}
if opts.Username != "alice" {
t.Fatalf("username = %q, want %q", opts.Username, "alice")
}
if opts.Password != "wonderland" {
t.Fatalf("password = %q, want %q", opts.Password, "wonderland")
}
if opts.Path != "/proxy" {
t.Fatalf("path = %q, want %q", opts.Path, "/proxy")
}
}
+110 -5
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"gopkg.in/yaml.v3"
@@ -50,10 +51,11 @@ type GeoIPConfig struct {
AutoUpdateInterval time.Duration `yaml:"auto_update_interval"` // 自动更新间隔,默认 24 小时
}
// ListenerConfig defines how the HTTP proxy should listen for clients.
// ListenerConfig defines how the proxy should listen for clients.
type ListenerConfig struct {
Address string `yaml:"address"`
Port uint16 `yaml:"port"`
Protocol string `yaml:"protocol"` // http, socks5, mixed
Username string `yaml:"username"`
Password string `yaml:"password"`
}
@@ -69,6 +71,7 @@ type PoolConfig struct {
type MultiPortConfig struct {
Address string `yaml:"address"`
BasePort uint16 `yaml:"base_port"`
Protocol string `yaml:"protocol"` // http, socks5, mixed
Username string `yaml:"username"`
Password string `yaml:"password"`
}
@@ -79,6 +82,7 @@ type ManagementConfig struct {
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.
@@ -101,6 +105,41 @@ const (
NodeSourceManual NodeSource = "manual" // Added manually via WebUI
)
const (
InboundProtocolHTTP = "http"
InboundProtocolSOCKS5 = "socks5"
InboundProtocolMixed = "mixed"
)
var supportedProxyURISchemes = []string{
"vmess://",
"vless://",
"trojan://",
"ss://",
"ssr://",
"hysteria://",
"hysteria2://",
"hy2://",
"anytls://",
"http://",
"socks5://",
}
// NormalizeInboundProtocol normalizes inbound protocol aliases and validates the value.
func NormalizeInboundProtocol(value string) (string, error) {
protocol := strings.ToLower(strings.TrimSpace(value))
switch protocol {
case "socks":
protocol = InboundProtocolSOCKS5
}
switch protocol {
case InboundProtocolHTTP, InboundProtocolSOCKS5, InboundProtocolMixed:
return protocol, nil
default:
return "", fmt.Errorf("不支持的监听协议: %q(仅支持 http/socks5/mixed)", value)
}
}
// NodeConfig describes a single upstream proxy endpoint expressed as URI.
type NodeConfig struct {
Name string `yaml:"name" json:"name"`
@@ -202,6 +241,14 @@ func (c *Config) applyDefaults() error {
if c.Listener.Port == 0 {
c.Listener.Port = 2323
}
if c.Listener.Protocol == "" {
c.Listener.Protocol = InboundProtocolHTTP
}
listenerProtocol, err := NormalizeInboundProtocol(c.Listener.Protocol)
if err != nil {
return err
}
c.Listener.Protocol = listenerProtocol
if c.Pool.Mode == "" {
c.Pool.Mode = "sequential"
}
@@ -217,6 +264,14 @@ func (c *Config) applyDefaults() error {
if c.MultiPort.BasePort == 0 {
c.MultiPort.BasePort = 28000
}
if c.MultiPort.Protocol == "" {
c.MultiPort.Protocol = InboundProtocolHTTP
}
multiPortProtocol, err := NormalizeInboundProtocol(c.MultiPort.Protocol)
if err != nil {
return err
}
c.MultiPort.Protocol = multiPortProtocol
if c.Management.Listen == "" {
c.Management.Listen = "127.0.0.1:9090"
}
@@ -227,6 +282,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 {
@@ -639,9 +697,9 @@ 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://"}
for _, scheme := range schemes {
if strings.HasPrefix(strings.ToLower(s), scheme) {
lower := strings.ToLower(strings.TrimSpace(s))
for _, scheme := range supportedProxyURISchemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
@@ -725,6 +783,8 @@ func convertClashProxyToURI(p clashProxy) string {
return buildShadowsocksURI(p)
case "hysteria2", "hy2":
return buildHysteria2URI(p)
case "anytls":
return buildAnyTLSURI(p)
default:
return ""
}
@@ -869,6 +929,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 {
@@ -1002,9 +1081,10 @@ func (c *Config) SaveSettings() error {
// ValidateSettingsRequest validates the settings request fields and returns
// a descriptive error for any invalid values, instead of silently ignoring them.
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 {
@@ -1021,6 +1101,14 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
return fmt.Errorf("多端口起始端口不能为 0")
}
// Validate inbound protocols
if _, err := NormalizeInboundProtocol(listenerProtocol); err != nil {
return fmt.Errorf("监听入口协议无效: %w", err)
}
if _, err := NormalizeInboundProtocol(multiPortProtocol); err != nil {
return fmt.Errorf("多端口入口协议无效: %w", err)
}
// Validate duration fields
durationFields := []struct {
name string
@@ -1032,6 +1120,7 @@ func ValidateSettingsRequest(mode string, listenerPort, multiPortBasePort uint16
{"健康检查超时", subRefreshHealthCheckTimeout},
{"排空超时", subRefreshDrainTimeout},
{"GeoIP 更新间隔", geoIPAutoUpdateInterval},
{"周期健康检查间隔", managementHealthCheckInterval},
}
for _, field := range durationFields {
@@ -1105,6 +1194,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)
}
+22
View File
@@ -0,0 +1,22 @@
package config
import "testing"
func TestIsProxyURIRecognizesHTTPAndSOCKS5(t *testing.T) {
tests := []struct {
name string
uri string
want bool
}{
{name: "http", uri: "http://alice:secret@example.com:8080", want: true},
{name: "socks5", uri: "socks5://alice:secret@example.com:1080", want: true},
{name: "vmess", uri: "vmess://example", want: true},
{name: "invalid", uri: "ftp://example.com", want: false},
}
for _, tt := range tests {
if got := IsProxyURI(tt.uri); got != tt.want {
t.Fatalf("%s: IsProxyURI(%q) = %v, want %v", tt.name, tt.uri, got, tt.want)
}
}
}
+5 -2
View File
@@ -495,13 +495,16 @@ func extractHostFromURI(uri string) string {
// Handle different URI schemes
lowerURI := strings.ToLower(uri)
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2://
// Standard URL formats, including HTTP and SOCKS upstream proxies.
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://") ||
strings.HasPrefix(lowerURI, "http://") ||
strings.HasPrefix(lowerURI, "socks5://") {
// Standard URL format: scheme://user@host:port?params#fragment
parsed, err := url.Parse(uri)
if err != nil {
+20
View File
@@ -0,0 +1,20 @@
package geoip
import "testing"
func TestExtractHostFromURISupportsHTTPAndSOCKS5(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{name: "http", uri: "http://alice:secret@example.com:8080", want: "example.com"},
{name: "socks5", uri: "socks5://alice:secret@99.144.123.135:30350", want: "99.144.123.135"},
}
for _, tt := range tests {
if got := extractHostFromURI(tt.uri); got != tt.want {
t.Fatalf("%s: extractHostFromURI(%q) = %q, want %q", tt.name, tt.uri, got, tt.want)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Easy Proxies</title>
<script type="module" crossorigin src="/assets/index-BkdNNtGR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
<script type="module" crossorigin src="/assets/index-CT8A1vEX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GEylGL-C.css">
</head>
<body>
<div id="root"></div>
+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()
+25 -2
View File
@@ -196,12 +196,14 @@ type allSettingsResponse struct {
// Listener
ListenerAddress string `json:"listener_address"`
ListenerPort uint16 `json:"listener_port"`
ListenerProtocol string `json:"listener_protocol"`
ListenerUsername string `json:"listener_username"`
ListenerPassword string `json:"listener_password"`
// Multi-port
MultiPortAddress string `json:"multi_port_address"`
MultiPortBasePort uint16 `json:"multi_port_base_port"`
MultiPortProtocol string `json:"multi_port_protocol"`
MultiPortUsername string `json:"multi_port_username"`
MultiPortPassword string `json:"multi_port_password"`
@@ -215,6 +217,7 @@ type allSettingsResponse struct {
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"`
@@ -245,12 +248,14 @@ type allSettingsRequest struct {
// Listener
ListenerAddress string `json:"listener_address"`
ListenerPort uint16 `json:"listener_port"`
ListenerProtocol string `json:"listener_protocol"`
ListenerUsername string `json:"listener_username"`
ListenerPassword string `json:"listener_password"`
// Multi-port
MultiPortAddress string `json:"multi_port_address"`
MultiPortBasePort uint16 `json:"multi_port_base_port"`
MultiPortProtocol string `json:"multi_port_protocol"`
MultiPortUsername string `json:"multi_port_username"`
MultiPortPassword string `json:"multi_port_password"`
@@ -264,6 +269,7 @@ type allSettingsRequest struct {
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"`
@@ -308,11 +314,13 @@ func (s *Server) getAllSettings() allSettingsResponse {
ListenerAddress: c.Listener.Address,
ListenerPort: c.Listener.Port,
ListenerProtocol: c.Listener.Protocol,
ListenerUsername: c.Listener.Username,
ListenerPassword: c.Listener.Password,
MultiPortAddress: c.MultiPort.Address,
MultiPortBasePort: c.MultiPort.BasePort,
MultiPortProtocol: c.MultiPort.Protocol,
MultiPortUsername: c.MultiPort.Username,
MultiPortPassword: c.MultiPort.Password,
@@ -324,6 +332,7 @@ func (s *Server) getAllSettings() allSettingsResponse {
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(),
@@ -346,9 +355,10 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
// Validate request before applying
if err := config.ValidateSettingsRequest(
req.Mode, req.ListenerPort, req.MultiPortBasePort,
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)
}
@@ -374,12 +384,18 @@ func (s *Server) updateAllSettings(req allSettingsRequest) error {
// Listener
c.Listener.Address = req.ListenerAddress
c.Listener.Port = req.ListenerPort
if p, err := config.NormalizeInboundProtocol(req.ListenerProtocol); err == nil {
c.Listener.Protocol = p
}
c.Listener.Username = req.ListenerUsername
c.Listener.Password = req.ListenerPassword
// Multi-port
c.MultiPort.Address = req.MultiPortAddress
c.MultiPort.BasePort = req.MultiPortBasePort
if p, err := config.NormalizeInboundProtocol(req.MultiPortProtocol); err == nil {
c.MultiPort.Protocol = p
}
c.MultiPort.Username = req.MultiPortUsername
c.MultiPort.Password = req.MultiPortPassword
@@ -397,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
@@ -443,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
@@ -1073,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)
+2 -14
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"easy_proxies/internal/config"
"fmt"
"log"
"net/url"
@@ -175,26 +176,13 @@ func loadNodesFromLegacyFile(path string) ([]string, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if isProxyURI(line) {
if config.IsProxyURI(line) {
uris = append(uris, line)
}
}
return uris, nil
}
// 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://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
return false
}
// extractNameFromURI extracts a name from the URI fragment (#name).
func extractNameFromURI(uri string) string {
parsed, err := url.Parse(uri)
+1 -12
View File
@@ -651,24 +651,13 @@ func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if isProxyURI(line) {
if config.IsProxyURI(line) {
nodes = append(nodes, config.NodeConfig{URI: line})
}
}
return nodes, nil
}
func isProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
return false
}
type defaultLogger struct{}
func (defaultLogger) Infof(format string, args ...any) {