Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766ac225ef | ||
|
|
5c0c07a912 | ||
|
|
6cbb3a7b15 | ||
|
|
fee58d1cf6 | ||
|
|
284977b7a5 | ||
|
|
5484d6f65a | ||
|
|
c37f26f376 | ||
|
|
a31ac4f51a | ||
|
|
c454446fb9 | ||
|
|
20e5402854 |
@@ -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`: 节点来源(三选一或混用)
|
||||
|
||||
@@ -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 访问密码,为空则不需要密码
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+181
-32
@@ -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{
|
||||
ListenOptions: option.ListenOptions{
|
||||
Listen: listenAddr,
|
||||
ListenPort: cfg.Listener.Port,
|
||||
},
|
||||
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}}
|
||||
}
|
||||
if cfg.Listener.Username != "" {
|
||||
inboundOptions.Users = []auth.User{{
|
||||
Username: cfg.Listener.Username,
|
||||
Password: cfg.Listener.Password,
|
||||
}}
|
||||
|
||||
switch protocol {
|
||||
case config.InboundProtocolHTTP:
|
||||
opts := &option.HTTPMixedInboundOptions{
|
||||
ListenOptions: option.ListenOptions{
|
||||
Listen: listenAddr,
|
||||
ListenPort: port,
|
||||
},
|
||||
}
|
||||
if len(users) > 0 {
|
||||
opts.Users = users
|
||||
}
|
||||
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)
|
||||
}
|
||||
inbound := option.Inbound{
|
||||
Type: C.TypeHTTP,
|
||||
Tag: "http-in",
|
||||
Options: inboundOptions,
|
||||
}
|
||||
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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+114
-9
@@ -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,16 +71,18 @@ 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+37
-14
@@ -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"`
|
||||
|
||||
@@ -211,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"`
|
||||
@@ -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"`
|
||||
|
||||
@@ -260,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"`
|
||||
@@ -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,
|
||||
|
||||
@@ -320,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(),
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user