This commit is contained in:
2026-07-28 10:32:21 +08:00
parent fc8c23beee
commit 389c0d172a
17 changed files with 678 additions and 157 deletions
+11 -3
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
@@ -9,8 +10,8 @@ import (
"time"
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/peterqiu0516/sub-store/internal/config"
@@ -76,13 +77,20 @@ func startFiber(cfg *config.Config, db *sqlx.DB) error {
WriteTimeout: cfg.Server.WriteTimeout,
BodyLimit: cfg.Server.BodyLimit,
ErrorHandler: func(c fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
message := "Internal server error"
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
code = fiberErr.Code
message = fiberErr.Message
}
logrus.WithError(err).WithFields(logrus.Fields{
"method": c.Method(),
"path": c.Path(),
}).Error("request error")
return c.Status(500).JSON(fiber.Map{
return c.Status(code).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": 500, "message": "Internal server error"},
"error": fiber.Map{"code": code, "message": message},
})
},
})
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub-Store</title>
<script src="/config.js"></script>
<script type="module" crossorigin src="/assets/index-CtUpN0Ye.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2qQf5Bht.css">
<script type="module" crossorigin src="/assets/index-CPGYuQco.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkTyHILH.css">
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
+1
View File
@@ -86,5 +86,6 @@ export const parseProxy = (data) => api.post('/proxy/parse', data)
export const parseRule = (data) => api.post('/rule/parse', data)
export const exportProxyUri = (node) => api.post('/utils/proxy-uri', node)
export const getNodeInfo = (server) => api.post('/utils/node-info', { server })
export const getEgressInfo = (node) => api.post('/utils/egress-info', node)
export default api
+1 -18
View File
@@ -5,9 +5,8 @@
<div class="px-5 py-4 border-b border-gray-700">
<h1 class="text-lg font-bold text-white flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-primary-500 rounded-full"></span>
Sub-Store
SubStore
</h1>
<p class="text-xs text-gray-500 mt-0.5" v-if="env">{{ env.backend }} · {{ env.version }}</p>
</div>
<nav class="flex-1 py-3 overflow-y-auto">
<router-link
@@ -23,9 +22,6 @@
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="px-5 py-3 border-t border-gray-700 text-xs text-gray-500">
<p>SQLite · Go</p>
</div>
</aside>
<!-- Main content -->
@@ -38,10 +34,6 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getEnv } from '@/api'
const env = ref(null)
const menu = [
{ to: '/', icon: '📊', label: '概览' },
{ to: '/sources', icon: '📡', label: '订阅源' },
@@ -51,13 +43,4 @@ const menu = [
{ to: '/tools', icon: '🔧', label: '工具' },
{ to: '/settings', icon: '⚙️', label: '设置' },
]
onMounted(async () => {
try {
const res = await getEnv()
env.value = res.data
} catch {
// ignore
}
})
</script>
+168 -21
View File
@@ -9,25 +9,75 @@
<div v-else-if="collections.length === 0" class="text-gray-400 text-sm py-8 text-center">暂无合集</div>
<div v-else class="space-y-2">
<div v-for="col in collections" :key="col.id"
class="bg-white rounded-lg p-4 shadow-sm border flex items-center justify-between hover:shadow-md transition-shadow">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ col.name }}</span>
<span v-if="!col.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
class="bg-white rounded-lg shadow-sm border overflow-hidden hover:shadow-md transition-shadow">
<div class="p-4 flex items-center justify-between cursor-pointer" @click="toggleExpand(col)">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ col.name }}</span>
<span v-if="!col.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
</div>
<div class="flex gap-3 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[col.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[col.id]?.processed ?? '-' }}</span>
<span>订阅源: {{ col.sourceIds?.length || 0 }}</span>
<span>过滤器: {{ col.filters?.length || 0 }}</span>
<span>模板: {{ col.templateId || 'default' }}</span>
<span v-if="col.ignoreFailed">忽略失败</span>
</div>
</div>
<div class="flex gap-3 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[col.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[col.id]?.processed ?? '-' }}</span>
<span>订阅源: {{ col.sourceIds?.length || 0 }}</span>
<span>过滤器: {{ col.filters?.length || 0 }}</span>
<span>模板: {{ col.templateId || 'default' }}</span>
<span v-if="col.ignoreFailed">忽略失败</span>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600 whitespace-nowrap"
:disabled="!!egressLoadingId" @click.stop="probeCollectionEgress(col)">
{{ egressLoadingId === col.id ? `探测 ${egressProgress}` : '探测出口' }}
</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click.stop="copyLink(col)">复制链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click.stop="editCol(col)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click.stop="remove(col)">删除</button>
</div>
</div>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click="copyLink(col)">链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click="editCol(col)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click="remove(col)">删除</button>
<div v-if="expandedCollectionId === col.id" class="border-t bg-gray-50" @click.stop>
<div v-if="expandedLoading" class="text-gray-400 text-sm py-4 text-center">解析中...</div>
<div v-else-if="expandedData">
<div class="overflow-x-auto rounded-lg bg-white">
<table class="w-full text-left text-xs">
<thead class="sticky top-0 bg-gray-100 text-gray-600">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Server</th>
<th class="px-3 py-2 font-medium">Port</th>
<th class="px-3 py-2 font-medium">Transport</th>
<th class="px-3 py-2 font-medium">Tls</th>
<th class="px-3 py-2 font-medium">Delay</th>
<th class="px-3 py-2 font-medium">Speed</th>
<th class="px-3 py-2 font-medium">Egress IP</th>
<th class="px-3 py-2 font-medium">Country</th>
<th class="px-3 py-2 font-medium">Region</th>
<th class="px-3 py-2 font-medium">Link</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="(node, i) in (expandedData.processed || expandedData.original || [])" :key="i">
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'type') }}</td>
<td class="px-3 py-2 text-blue-600">{{ nodeField(node, 'name', 'remarks') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'server', 'address') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'port') }}</td>
<td class="px-3 py-2 text-gray-500">{{ formatTransport(node) }}</td>
<td class="px-3 py-2 text-gray-500">{{ formatTls(node) }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'latencyMs', 'delay', 'latency', 'ping') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'speed', 'downloadSpeed') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'egressIp') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressCountry', 'country') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressRegion', 'region') }}</td>
<td class="px-3 py-2">
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600 whitespace-nowrap" @click="copyNodeLink(node)">复制</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="(expandedData.processed || expandedData.original || []).length === 0" class="text-xs text-gray-400 mt-2">暂无节点</p>
</div>
</div>
</div>
</div>
@@ -99,7 +149,7 @@
import { ref, reactive, onMounted } from 'vue'
import Modal from '@/components/Modal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { listCollections, createCollection, updateCollection, deleteCollection, listSources, listTemplates, getLinkCollection, previewCollection } from '@/api'
import { listCollections, createCollection, updateCollection, deleteCollection, listSources, listTemplates, getLinkCollection, previewCollection, exportProxyUri, getEgressInfo } from '@/api'
import { copyText } from '@/utils/clipboard'
const collections = ref([])
@@ -108,6 +158,11 @@ const allTemplates = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(false)
const expandedCollectionId = ref('')
const expandedLoading = ref(false)
const expandedData = ref(null)
const egressLoadingId = ref('')
const egressProgress = ref('')
const toast = ref(null)
const nodeCounts = reactive({})
const confirmDialog = ref({ show: false, title: '', message: '', danger: false, action: null })
@@ -124,6 +179,37 @@ function showToast(msg, type = 'success') {
setTimeout(() => toast.value = null, 2500)
}
function nodeField(node, ...keys) {
for (const key of keys) {
const value = node?.[key]
if (value !== undefined && value !== null && value !== '') return value
}
return '-'
}
function formatTls(node) {
if (node?.['reality-opts'] || node?.security === 'reality') return 'reality'
const value = nodeField(node, 'tls', 'security')
if (value === true) return 'TLS'
if (value === false) return '-'
if (value === 'none') return '-'
return value
}
function formatTransport(node) {
const transport = node?.transport
if (typeof transport === 'string') return transport
if (transport?.type) return transport.type
return nodeField(node, 'network', 'net')
}
function countsFromPreview(data) {
return {
original: data?.originalCount ?? (Array.isArray(data?.original) ? data.original.length : (data?.nodes ?? 0)),
processed: Array.isArray(data?.processed) ? data.processed.length : 0,
}
}
function openCreate() {
Object.assign(form, emptyForm())
editing.value = false
@@ -184,6 +270,70 @@ async function copyLink(col) {
}
}
async function toggleExpand(col) {
if (expandedCollectionId.value === col.id) {
expandedCollectionId.value = ''
expandedData.value = null
return
}
expandedCollectionId.value = col.id
expandedLoading.value = true
expandedData.value = null
try {
const res = await previewCollection(col)
expandedData.value = res.data
nodeCounts[col.id] = countsFromPreview(res.data)
} catch (e) {
showToast(e.message, 'error')
expandedCollectionId.value = ''
} finally {
expandedLoading.value = false
}
}
async function copyNodeLink(node) {
try {
const res = await exportProxyUri(node)
await copyText(res.data.uri)
showToast('节点链接已复制')
} catch (e) {
showToast(e.message, 'error')
}
}
async function probeCollectionEgress(col) {
if (egressLoadingId.value) return
egressLoadingId.value = col.id
egressProgress.value = ''
try {
const preview = await previewCollection(col)
const nodes = preview.data?.processed || preview.data?.original || []
nodeCounts[col.id] = countsFromPreview(preview.data)
if (expandedCollectionId.value === col.id) {
expandedData.value = preview.data
}
for (let i = 0; i < nodes.length; i += 1) {
egressProgress.value = `${i + 1}/${nodes.length}`
try {
const res = await getEgressInfo(nodes[i])
Object.assign(nodes[i], {
egressIp: res.data.egressIp,
egressCountry: res.data.country,
egressRegion: res.data.region,
latencyMs: res.data.latencyMs,
})
} catch {
Object.assign(nodes[i], { egressIp: '-', egressCountry: '-', egressRegion: '-' })
}
}
showToast('出口探测完成')
} finally {
egressLoadingId.value = ''
egressProgress.value = ''
}
}
async function load() {
loading.value = true
try {
@@ -202,10 +352,7 @@ async function load() {
async function loadNodeCount(col) {
try {
const res = await previewCollection(col)
nodeCounts[col.id] = {
original: res.data.originalCount ?? res.data.nodes ?? 0,
processed: Array.isArray(res.data.processed) ? res.data.processed.length : 0,
}
nodeCounts[col.id] = countsFromPreview(res.data)
} catch {
nodeCounts[col.id] = { original: '-', processed: '-' }
}
-28
View File
@@ -43,34 +43,6 @@
<p class="text-xs text-gray-400 mt-1">URL {ip} 会被替换为查询的 IP</p>
</div>
<!-- Theme -->
<div class="bg-white rounded-lg p-4 shadow-sm border">
<h3 class="font-semibold mb-3 text-sm">主题</h3>
<div class="flex items-center gap-2 mb-2">
<input type="checkbox" id="theme-auto" v-model="settings.theme.auto" class="rounded" />
<label for="theme-auto" class="text-sm">自动跟随系统</label>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-500">亮色主题</label>
<input v-model="settings.theme.light" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
<div>
<label class="text-xs text-gray-500">暗色主题</label>
<input v-model="settings.theme.dark" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
</div>
</div>
<!-- App name -->
<div class="bg-white rounded-lg p-4 shadow-sm border">
<h3 class="font-semibold mb-3 text-sm">应用</h3>
<div>
<label class="block text-sm mb-1">应用名称</label>
<input v-model="settings.appName" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
</div>
<button class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700" @click="save">保存设置</button>
</div>
+57 -4
View File
@@ -28,6 +28,10 @@
<p v-if="flowSummary(src)" class="text-xs text-gray-500 mt-1 truncate">{{ flowSummary(src) }}</p>
</div>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600 whitespace-nowrap"
:disabled="!!egressLoadingId" @click.stop="probeSourceEgress(src)">
{{ egressLoadingId === src.id ? `探测 ${egressProgress}` : '探测出口' }}
</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click.stop="copyLink(src)">复制链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click.stop="editSource(src)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click.stop="remove(src)">删除</button>
@@ -48,6 +52,9 @@
<th class="px-3 py-2 font-medium">Tls</th>
<th class="px-3 py-2 font-medium">Delay</th>
<th class="px-3 py-2 font-medium">Speed</th>
<th class="px-3 py-2 font-medium">Egress IP</th>
<th class="px-3 py-2 font-medium">Country</th>
<th class="px-3 py-2 font-medium">Region</th>
<th class="px-3 py-2 font-medium">Link</th>
</tr>
</thead>
@@ -57,12 +64,15 @@
<td class="px-3 py-2 text-blue-600">{{ nodeField(node, 'name', 'remarks') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'server', 'address') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'port') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'transport', 'network') }}</td>
<td class="px-3 py-2 text-gray-500">{{ formatTransport(node) }}</td>
<td class="px-3 py-2 text-gray-500">{{ formatTls(node) }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'delay', 'latency', 'ping') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'latencyMs', 'delay', 'latency', 'ping') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'speed', 'downloadSpeed') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'egressIp') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressCountry', 'country') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressRegion', 'region') }}</td>
<td class="px-3 py-2">
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600" @click="copyNodeLink(node)">复制</button>
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600 whitespace-nowrap" @click="copyNodeLink(node)">复制</button>
</td>
</tr>
</tbody>
@@ -171,7 +181,7 @@
import { ref, reactive, onMounted } from 'vue'
import Modal from '@/components/Modal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { listSources, createSource, updateSource, deleteSource, previewSource, getLinkSource, getFlowInfo, exportProxyUri } from '@/api'
import { listSources, createSource, updateSource, deleteSource, previewSource, getLinkSource, getFlowInfo, exportProxyUri, getEgressInfo } from '@/api'
import { copyText } from '@/utils/clipboard'
const sources = ref([])
@@ -181,6 +191,8 @@ const editing = ref(false)
const expandedSourceId = ref('')
const expandedLoading = ref(false)
const expandedData = ref(null)
const egressLoadingId = ref('')
const egressProgress = ref('')
const nodeCounts = reactive({})
const flowInfos = reactive({})
const toast = ref(null)
@@ -253,12 +265,21 @@ function nodeField(node, ...keys) {
}
function formatTls(node) {
if (node?.['reality-opts'] || node?.security === 'reality') return 'reality'
const value = nodeField(node, 'tls', 'security')
if (value === true) return 'TLS'
if (value === false) return '-'
if (value === 'none') return '-'
return value
}
function formatTransport(node) {
const transport = node?.transport
if (typeof transport === 'string') return transport
if (transport?.type) return transport.type
return nodeField(node, 'network', 'net')
}
function flowSummary(src) {
const flow = flowInfos[src.id]
if (!flow) return ''
@@ -394,6 +415,38 @@ async function copyNodeLink(node) {
}
}
async function probeSourceEgress(src) {
if (egressLoadingId.value) return
egressLoadingId.value = src.id
egressProgress.value = ''
try {
const preview = await previewSource(sourcePreviewPayload(src))
const nodes = preview.data?.processed || preview.data?.original || []
nodeCounts[src.id] = countsFromPreview(preview.data)
if (expandedSourceId.value === src.id) {
expandedData.value = preview.data
}
for (let i = 0; i < nodes.length; i += 1) {
egressProgress.value = `${i + 1}/${nodes.length}`
try {
const res = await getEgressInfo(nodes[i])
Object.assign(nodes[i], {
egressIp: res.data.egressIp,
egressCountry: res.data.country,
egressRegion: res.data.region,
latencyMs: res.data.latencyMs,
})
} catch {
Object.assign(nodes[i], { egressIp: '-', egressCountry: '-', egressRegion: '-' })
}
}
showToast('出口探测完成')
} finally {
egressLoadingId.value = ''
egressProgress.value = ''
}
}
async function load() {
loading.value = true
try {
+4 -4
View File
@@ -168,8 +168,8 @@ func (d *Deps) HandlePreviewSource(c fiber.Ctx) error {
applyFiltersSafe(original, rec.Filters, settings, "json", rec.ID),
)
return success(c, fiber.Map{
"original": proxy.AddPreviewIds(original),
"processed": proxy.AddPreviewIds(processed),
"original": d.addCachedEgressInfo(proxy.AddPreviewIds(original)),
"processed": d.addCachedEgressInfo(proxy.AddPreviewIds(processed)),
})
}
@@ -190,7 +190,7 @@ func (d *Deps) HandlePreviewSource(c fiber.Ctx) error {
"body": result.Body,
"nodes": result.Nodes,
"originalCount": result.OriginalNodes,
"processed": previewNodesFromBody(result.Body),
"processed": d.addCachedEgressInfo(previewNodesFromBody(result.Body)),
})
}
@@ -218,7 +218,7 @@ func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error {
"body": result.Body,
"nodes": result.Nodes,
"originalCount": result.OriginalNodes,
"processed": previewNodesFromBody(result.Body),
"processed": d.addCachedEgressInfo(previewNodesFromBody(result.Body)),
})
}
+257
View File
@@ -0,0 +1,257 @@
package handler
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/render"
"github.com/peterqiu0516/sub-store/internal/util"
)
func (d *Deps) HandleEgressInfo(c fiber.Ctx) error {
var node model.ProxyNode
if err := json.Unmarshal(c.Body(), &node); err != nil {
return failed(c, "Invalid JSON", 400)
}
if getStringValue(node["server"]) == "" {
node["server"] = getStringValue(node["address"])
}
if getStringValue(node["name"]) == "" {
node["name"] = "PROXY"
}
cacheKey := egressCacheKey(node)
if entry, ok := d.CacheRepo.SafeGet(cacheKey); ok {
var cached map[string]any
if json.Unmarshal([]byte(entry.Content), &cached) == nil {
cached["cached"] = true
return success(c, cached)
}
}
latencyMs, latencyErr := probeServerPortLatency(node, 5*time.Second)
port, err := freeLocalPort()
if err != nil {
return failed(c, err.Error(), 500)
}
configData, err := buildEgressProbeConfig(node, port)
if err != nil {
return failed(c, err.Error(), 400)
}
info, err := runEgressProbe(configData, port)
if err != nil {
info = fiber.Map{"egressError": err.Error()}
}
if latencyMs >= 0 {
info["latencyMs"] = latencyMs
}
if latencyErr != "" {
info["latencyError"] = latencyErr
}
info["cached"] = false
if data, err := json.Marshal(info); err == nil {
ttl := int(d.Cfg.Fetcher.CacheTTL.Seconds())
if ttl <= 0 {
ttl = 300
}
d.CacheRepo.SafePut(cacheKey, string(data), nil, ttl)
}
return success(c, info)
}
func egressCacheKey(node model.ProxyNode) string {
clean := model.ProxyNode{}
skip := map[string]bool{
"id": true, "latencyMs": true, "latencyError": true,
"egressIp": true, "egressCountry": true, "egressRegion": true, "egressError": true,
"country": true, "region": true, "city": true, "isp": true, "cached": true,
}
for k, v := range node {
if !skip[k] {
clean[k] = v
}
}
data, _ := json.Marshal(clean)
sum := sha256.Sum256(data)
return fmt.Sprintf("egress:%x", sum)
}
func (d *Deps) addCachedEgressInfo(nodes []model.ProxyNode) []model.ProxyNode {
for _, node := range nodes {
entry, ok := d.CacheRepo.SafeGet(egressCacheKey(node))
if !ok {
continue
}
var cached map[string]any
if json.Unmarshal([]byte(entry.Content), &cached) != nil {
continue
}
for _, key := range []string{"egressIp", "country", "region", "city", "isp", "latencyMs", "latencyError", "egressError"} {
if v, ok := cached[key]; ok {
node[key] = v
}
}
node["cached"] = true
}
return nodes
}
func probeServerPortLatency(node model.ProxyNode, timeout time.Duration) (int64, string) {
server := getStringValue(node["server"])
port := toIntSafe(node["port"])
if server == "" || port <= 0 {
return -1, "missing server or port"
}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(server, fmt.Sprint(port)), timeout)
if err != nil {
return -1, err.Error()
}
_ = conn.Close()
return time.Since(start).Milliseconds(), ""
}
func buildEgressProbeConfig(node model.ProxyNode, port int) ([]byte, error) {
probeNode := model.ProxyNode{}
for k, v := range node {
probeNode[k] = v
}
probeNode["name"] = "PROXY"
outbound := render.ToSingBoxOutbound(probeNode)
if outbound == nil {
return nil, fmt.Errorf("Unsupported proxy node for egress probe")
}
doc := map[string]any{
"log": map[string]any{"level": "warn"},
"inbounds": []any{
map[string]any{
"type": "mixed",
"tag": "mixed-in",
"listen": "127.0.0.1",
"listen_port": port,
},
},
"outbounds": []any{
outbound,
map[string]any{"type": "direct", "tag": "DIRECT"},
},
"route": map[string]any{
"auto_detect_interface": true,
"final": "PROXY",
"rules": []any{map[string]any{"action": "sniff"}},
},
}
return json.Marshal(doc)
}
func runEgressProbe(configData []byte, port int) (fiber.Map, error) {
singBox, err := exec.LookPath("sing-box")
if err != nil {
return nil, fmt.Errorf("sing-box executable not found")
}
dir, err := os.MkdirTemp("", "sub-store-egress-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, configData, 0o600); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, singBox, "run", "-c", configPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
defer func() {
cancel()
_ = cmd.Wait()
}()
if err := waitTCP("127.0.0.1", port, 5*time.Second); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("%s", msg)
}
return nil, err
}
proxyURL, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}
resp, err := client.Get("https://ipwho.is/?lang=en")
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes))
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("Invalid egress info response")
}
if success, ok := data["success"].(bool); ok && !success {
msg := getStringValue(data["message"])
if msg == "" {
msg = "Egress info lookup failed"
}
return nil, fmt.Errorf("%s", msg)
}
connection, _ := data["connection"].(map[string]any)
return fiber.Map{
"egressIp": data["ip"],
"country": data["country"],
"region": data["region"],
"city": data["city"],
"isp": connection["isp"],
}, nil
}
func freeLocalPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
func waitTCP(host string, port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("%s:%d", host, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("Timed out waiting for sing-box")
}
+75
View File
@@ -4,11 +4,13 @@ import (
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/jmoiron/sqlx"
@@ -105,6 +107,7 @@ func registerHandlers(app *fiber.App, deps *Deps) {
app.Post("/api/rule/parse", deps.HandleRuleParse)
app.Post("/api/utils/proxy-uri", deps.HandleProxyURI)
app.Post("/api/utils/node-info", deps.HandleNodeInfo)
app.Post("/api/utils/egress-info", deps.HandleEgressInfo)
app.Get("/sources/:name/:token", deps.HandleDownloadSource)
app.Get("/collections/:name/:token", deps.HandleDownloadCollection)
@@ -2065,6 +2068,78 @@ func TestHandleNodeInfoServerWithBrackets(t *testing.T) {
}
}
func TestBuildEgressProbeConfig(t *testing.T) {
data, err := buildEgressProbeConfig(model.ProxyNode{
"type": "ss",
"name": "demo",
"server": "127.0.0.1",
"port": 8388,
"cipher": "aes-256-gcm",
"password": "pass",
}, 19090)
if err != nil {
t.Fatalf("buildEgressProbeConfig returned error: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatalf("invalid config JSON: %v", err)
}
route := doc["route"].(map[string]any)
if route["final"] != "PROXY" {
t.Fatalf("route.final = %v, want PROXY", route["final"])
}
inbound := doc["inbounds"].([]any)[0].(map[string]any)
if inbound["listen"] != "127.0.0.1" || inbound["listen_port"].(float64) != 19090 {
t.Fatalf("unexpected inbound: %v", inbound)
}
}
func TestHandleEgressInfoUnsupported(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
code, _ := doRequest(t, app, "POST", "/api/utils/egress-info", `{"type":"unknown","server":"1.2.3.4","port":443}`, nil)
assertStatus(t, "EgressInfo unsupported", code, 400)
}
func TestProbeServerPortLatency(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err == nil {
conn.Close()
}
}()
latency, msg := probeServerPortLatency(model.ProxyNode{
"server": "127.0.0.1",
"port": ln.Addr().(*net.TCPAddr).Port,
}, time.Second)
if msg != "" {
t.Fatalf("unexpected latency error: %s", msg)
}
if latency < 0 {
t.Fatalf("latency = %d, want >= 0", latency)
}
}
func TestAddCachedEgressInfo(t *testing.T) {
deps := newTestDeps(t)
node := model.ProxyNode{
"type": "ss",
"name": "n",
"server": "127.0.0.1",
"port": 8388,
}
deps.CacheRepo.SafePut(egressCacheKey(node), `{"egressIp":"1.1.1.1","country":"Japan","region":"Tokyo","latencyMs":12}`, nil, 300)
nodes := deps.addCachedEgressInfo([]model.ProxyNode{node})
if nodes[0]["egressIp"] != "1.1.1.1" || nodes[0]["latencyMs"].(float64) != 12 {
t.Fatalf("cached egress not merged: %v", nodes[0])
}
}
// ---------------------------------------------------------------------------
// RegisterRoutes integration (uses real middleware)
// ---------------------------------------------------------------------------
+1
View File
@@ -98,6 +98,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config, db *sqlx.DB) {
api.Post("/rule/parse", deps.HandleRuleParse)
api.Post("/utils/proxy-uri", deps.HandleProxyURI)
api.Post("/utils/node-info", deps.HandleNodeInfo)
api.Post("/utils/egress-info", deps.HandleEgressInfo)
// Public download routes — no admin token required, uses download token
app.Get("/sources/:name/:token", deps.HandleDownloadSource)
+14
View File
@@ -96,6 +96,13 @@ func TestParseVMess(t *testing.T) {
if n["network"] != "ws" {
t.Errorf("expected network ws, got %v", n["network"])
}
ws, ok := n["ws-opts"].(map[string]any)
if !ok {
t.Fatalf("expected ws-opts map, got %T", n["ws-opts"])
}
if ws["path"] != "/path" {
t.Errorf("expected ws path /path, got %v", ws["path"])
}
if n["tls"] != true {
t.Errorf("expected tls true, got %v", n["tls"])
}
@@ -117,6 +124,13 @@ func TestParseVLESS(t *testing.T) {
if n["network"] != "ws" {
t.Errorf("expected network ws, got %v", n["network"])
}
ws, ok := n["ws-opts"].(map[string]any)
if !ok {
t.Fatalf("expected ws-opts map, got %T", n["ws-opts"])
}
if ws["path"] != "/path" {
t.Errorf("expected ws path /path, got %v", ws["path"])
}
if n["tls"] != true {
t.Errorf("expected tls true, got %v", n["tls"])
}
+49 -39
View File
@@ -142,19 +142,29 @@ func ParseVless(line string, index int) model.ProxyNode {
}
node := map[string]any{
"name": fragmentName(u, fmt.Sprintf("vless-%d", index+1)),
"type": "vless",
"server": u.Hostname(),
"port": portFromURL(u, 443),
"uuid": userInfo(u),
"udp": true,
"flow": params.Get("flow"),
"network": orDefault(params.Get("type"), "tcp"),
"tls": security != "none",
"name": fragmentName(u, fmt.Sprintf("vless-%d", index+1)),
"type": "vless",
"server": u.Hostname(),
"port": portFromURL(u, 443),
"uuid": userInfo(u),
"udp": true,
"flow": params.Get("flow"),
"network": orDefault(params.Get("type"), "tcp"),
"tls": security != "none",
"servername": params.Get("sni"),
"encryption": orDefault(params.Get("encryption"), "none"),
"encryption": orDefault(params.Get("encryption"), "none"),
"client-fingerprint": orDefault(params.Get("fp"), "chrome"),
}
if node["network"] == "ws" {
wsOpts := map[string]any{
"path": orDefault(params.Get("path"), "/"),
}
host := params.Get("host")
if host != "" {
wsOpts["headers"] = map[string]any{"Host": host}
}
node["ws-opts"] = wsOpts
}
if publicKey != "" {
node["reality-opts"] = StripUndefined(map[string]any{
"public-key": publicKey,
@@ -251,17 +261,17 @@ func ParseVmess(line string, index int) model.ProxyNode {
port := toNumberOrUndefined(payload["port"])
node := map[string]any{
"name": orDefault(toString(payload["ps"]), fmt.Sprintf("vmess-%d", index+1)),
"type": "vmess",
"server": toString(payload["add"]),
"port": port,
"uuid": toString(payload["id"]),
"alterId": toNumberOrUndefined(payload["aid"]),
"cipher": orDefault(toString(payload["scy"]), "auto"),
"tls": toString(payload["tls"]) == "tls",
"name": orDefault(toString(payload["ps"]), fmt.Sprintf("vmess-%d", index+1)),
"type": "vmess",
"server": toString(payload["add"]),
"port": port,
"uuid": toString(payload["id"]),
"alterId": toNumberOrUndefined(payload["aid"]),
"cipher": orDefault(toString(payload["scy"]), "auto"),
"tls": toString(payload["tls"]) == "tls",
"servername": firstNonEmpty(toString(payload["sni"]), toString(payload["host"])),
"network": orDefault(toString(payload["net"]), "tcp"),
"udp": true,
"network": orDefault(toString(payload["net"]), "tcp"),
"udp": true,
}
if toString(payload["net"]) == "ws" {
wsOpts := map[string]any{
@@ -415,15 +425,15 @@ func ParseShadowsocksR(line string, index int) model.ProxyNode {
}
node := map[string]any{
"name": name,
"type": "ssr",
"server": server,
"port": port,
"cipher": method,
"name": name,
"type": "ssr",
"server": server,
"port": port,
"cipher": method,
"password": password,
"protocol": protocol,
"obfs": obfs,
"udp": true,
"obfs": obfs,
"udp": true,
}
if pp := query.Get("protoparam"); pp != "" {
@@ -490,18 +500,18 @@ func ParseHttpProxy(line string, index int) model.ProxyNode {
func ParseTuic(line string, index int) model.ProxyNode {
u := parseURL(line)
return StripUndefined(map[string]any{
"name": fragmentName(u, fmt.Sprintf("tuic-%d", index+1)),
"type": "tuic",
"server": u.Hostname(),
"port": portFromURL(u, 443),
"uuid": userInfo(u),
"password": userPassword(u),
"sni": paramGet(u, "sni"),
"alpn": CommaList(paramGet(u, "alpn")),
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "allow_insecure"), paramGet(u, "insecure"))),
"disable-sni": BoolParam(firstNonEmpty(paramGet(u, "disable_sni"), paramGet(u, "disable-sni"))),
"reduce-rtt": BoolParam(firstNonEmpty(paramGet(u, "reduce_rtt"), paramGet(u, "reduce-rtt"))),
"udp-relay-mode": firstNonEmpty(paramGet(u, "udp_relay_mode"), paramGet(u, "udp-relay-mode")),
"name": fragmentName(u, fmt.Sprintf("tuic-%d", index+1)),
"type": "tuic",
"server": u.Hostname(),
"port": portFromURL(u, 443),
"uuid": userInfo(u),
"password": userPassword(u),
"sni": paramGet(u, "sni"),
"alpn": CommaList(paramGet(u, "alpn")),
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "allow_insecure"), paramGet(u, "insecure"))),
"disable-sni": BoolParam(firstNonEmpty(paramGet(u, "disable_sni"), paramGet(u, "disable-sni"))),
"reduce-rtt": BoolParam(firstNonEmpty(paramGet(u, "reduce_rtt"), paramGet(u, "reduce-rtt"))),
"udp-relay-mode": firstNonEmpty(paramGet(u, "udp_relay_mode"), paramGet(u, "udp-relay-mode")),
"congestion-controller": firstNonEmpty(paramGet(u, "congestion_control"), paramGet(u, "congestion-controller")),
})
}