feat: 代理提取接口支持 fmt=url——返回握手实测协议的 scheme://ip:port

proxyx.Check 改回返回胜出协议名(socks5/socks4/http),提取接口加 fmt=url
时输出 socks5h:// / http:// / socks4:// 完整代理 URL(socks5h 让 DNS 走代理端),
供 auto-registr 这类需要带 scheme 的消费端直接使用;默认 ip:port 行为不变。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-08 19:37:50 +08:00
co-authored by Devin
parent fa6ab5d3c7
commit c0a24faf8e
5 changed files with 39 additions and 19 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ make build # go build + vite build
- `/proxies/extract` 页生成密钥(`pk_xxx`,存 `proxy_keys` 表,绑定地区或「全部」),删除即吊销; - `/proxies/extract` 页生成密钥(`pk_xxx`,存 `proxy_keys` 表,绑定地区或「全部」),删除即吊销;
- `GET /v1/proxy?key=<密钥>` 免登录:随机抽 15 个存活候选**并行**协议级探活(3s 超时), - `GET /v1/proxy?key=<密钥>` 免登录:随机抽 15 个存活候选**并行**协议级探活(3s 超时),
先活先回一行 `ip:port` 纯文本;死的顺手标 `alive=0`,最多 4 批 / 20s 总超时,全死 503; 先活先回一行 `ip:port` 纯文本(加 `&fmt=url` 返回 `scheme://ip:port`,scheme 为握手实测的
socks5h/http/socks4);死的顺手标 `alive=0`,最多 4 批 / 20s 总超时,全死 503;
- 每次成功提取 `used_count+1`、`last_used_at` 更新。 - 每次成功提取 `used_count+1`、`last_used_at` 更新。
## 系统设置 ## 系统设置
+6 -4
View File
@@ -271,11 +271,13 @@ const probeHost = "www.gstatic.com"
const probePort = 80 const probePort = 80
// Check 协议感知测活:按 protocols 依次尝试 SOCKS5/SOCKS4/HTTP 握手, // Check 协议感知测活:按 protocols 依次尝试 SOCKS5/SOCKS4/HTTP 握手,
// 纯 TCP 连通不算数——必须说出协议正确的应答才算活。返回拨测耗时。 // 纯 TCP 连通不算数——必须说出协议正确的应答才算活。
func Check(ctx context.Context, ip string, port int, protocols string, timeout time.Duration) (int, error) { // 返回握手成功的协议名(socks5/socks4/http)与拨测耗时。
func Check(ctx context.Context, ip string, port int, protocols string, timeout time.Duration) (string, int, error) {
probes := []func(context.Context, string, int, time.Duration) error{ probes := []func(context.Context, string, int, time.Duration) error{
probeSOCKS5, probeSOCKS4, probeHTTP, probeSOCKS5, probeSOCKS4, probeHTTP,
} }
names := []string{"socks5", "socks4", "http"}
upper := strings.ToUpper(protocols) upper := strings.ToUpper(protocols)
order := []int{} order := []int{}
for i, name := range []string{"SOCKS5", "SOCKS4", "HTTP"} { for i, name := range []string{"SOCKS5", "SOCKS4", "HTTP"} {
@@ -290,12 +292,12 @@ func Check(ctx context.Context, ip string, port int, protocols string, timeout t
for _, i := range order { for _, i := range order {
start := time.Now() start := time.Now()
if err := probes[i](ctx, ip, port, timeout); err == nil { if err := probes[i](ctx, ip, port, timeout); err == nil {
return int(time.Since(start).Milliseconds()), nil return names[i], int(time.Since(start).Milliseconds()), nil
} else { } else {
lastErr = err lastErr = err
} }
} }
return 0, lastErr return "", 0, lastErr
} }
func dial(ctx context.Context, ip string, port int, timeout time.Duration) (net.Conn, error) { func dial(ctx context.Context, ip string, port int, timeout time.Duration) (net.Conn, error) {
+1 -1
View File
@@ -322,7 +322,7 @@ func runProxyCheck(ctx context.Context, st *store.Store) {
defer wg.Done() defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
latency, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, proxyCheckTimeout) _, latency, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, proxyCheckTimeout)
if err != nil { if err != nil {
_ = st.MarkProxyFailed(p.Id, now, p.FailCount+1) _ = st.MarkProxyFailed(p.Id, now, p.FailCount+1)
return return
+29 -12
View File
@@ -26,9 +26,11 @@ type extractResult struct {
ok bool ok bool
} }
// ProxyExtractHandler GET /v1/proxy?key=xxx // ProxyExtractHandler GET /v1/proxy?key=xxx[&fmt=url]
// 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。 // 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。
// 并行探测一批候选,谁先探活返回谁,纯文本 "ip:port";两批全死返回 503。 // 并行探测一批候选,谁先探活返回谁;默认纯文本 "ip:port",
// fmt=url 时带握手实测协议返回 "scheme://ip:port"(socks5h/http/socks4)。
// 两批全死返回 503。
func ProxyExtractHandler(st *store.Store) fun.RouteHandler { func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
return func(rc *fun.RouteCtx) error { return func(rc *fun.RouteCtx) error {
reqCtx := rc.RequestCtx reqCtx := rc.RequestCtx
@@ -42,6 +44,7 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
routeJSONErr(reqCtx, 401, "invalid key") routeJSONErr(reqCtx, 401, "invalid key")
return nil return nil
} }
withScheme := strings.TrimSpace(string(reqCtx.QueryArgs().Peek("fmt"))) == "url"
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline)) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline))
defer cancel() defer cancel()
@@ -55,10 +58,14 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
if len(items) == 0 { if len(items) == 0 {
break break
} }
if p, _, ok := probeBatch(ctx, st, items); ok { if p, proto, _, ok := probeBatch(ctx, st, items); ok {
st.TouchProxyApiKey(k.Id) st.TouchProxyApiKey(k.Id)
reqCtx.SetContentType("text/plain; charset=utf-8") reqCtx.SetContentType("text/plain; charset=utf-8")
reqCtx.SetBodyString(fmt.Sprintf("%s:%d\n", p.Ip, p.Port)) if withScheme {
reqCtx.SetBodyString(fmt.Sprintf("%s://%s:%d\n", urlScheme(proto), p.Ip, p.Port))
} else {
reqCtx.SetBodyString(fmt.Sprintf("%s:%d\n", p.Ip, p.Port))
}
return nil return nil
} }
} }
@@ -67,11 +74,21 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
} }
} }
// urlScheme 握手协议名 → 代理 URL scheme:SOCKS5 用 socks5h(DNS 走代理端解析)。
func urlScheme(proto string) string {
if proto == "socks5" {
return "socks5h"
}
return proto
}
// probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。 // probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。
func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, int, bool) { // 返回胜出的代理及其握手协议名(socks5/socks4/http)。
func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, string, int, bool) {
type hit struct { type hit struct {
p store.RandomProxy p store.RandomProxy
lat int proto string
lat int
} }
hits := make(chan hit, 1) hits := make(chan hit, 1)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -79,13 +96,13 @@ func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy)
wg.Add(1) wg.Add(1)
go func(p store.RandomProxy) { go func(p store.RandomProxy) {
defer wg.Done() defer wg.Done()
lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO) proto, lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO)
if err != nil { if err != nil {
_ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1) _ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1)
return return
} }
select { select {
case hits <- hit{p, lat}: case hits <- hit{p, proto, lat}:
case <-ctx.Done(): case <-ctx.Done():
} }
}(p) }(p)
@@ -96,10 +113,10 @@ func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy)
case h := <-hits: case h := <-hits:
now := time.Now().Unix() now := time.Now().Unix()
_ = st.MarkProxyAlive(h.p.Id, now, now, int64(h.lat)) _ = st.MarkProxyAlive(h.p.Id, now, now, int64(h.lat))
return h.p, h.lat, true return h.p, h.proto, h.lat, true
case <-done: case <-done:
return store.RandomProxy{}, 0, false return store.RandomProxy{}, "", 0, false
case <-ctx.Done(): case <-ctx.Done():
return store.RandomProxy{}, 0, false return store.RandomProxy{}, "", 0, false
} }
} }
+1 -1
View File
@@ -129,7 +129,7 @@ function regionLabel(k: proxyKeyView): string {
<div class="usage panel-inset"> <div class="usage panel-inset">
<b>使用方式</b> <b>使用方式</b>
<div class="mono usage-line">GET /api/v1/proxy?key=&lt;密钥&gt;</div> <div class="mono usage-line">GET /api/v1/proxy?key=&lt;密钥&gt;</div>
<div class="dim">每次请求实时探活,返回一个可用的 <span class="mono">ip:port</span>(纯文本)。地区由密钥绑定,「全部地区」密钥随机返回任意国家。</div> <div class="dim">每次请求实时探活,返回一个可用的 <span class="mono">ip:port</span>(纯文本);加 <span class="mono">&amp;fmt=url</span> 返回带协议的 <span class="mono">scheme://ip:port</span>(socks5h/http/socks4)。地区由密钥绑定,「全部地区」密钥随机返回任意国家。</div>
</div> </div>
<n-modal v-model:show="createVisible" preset="card" title="生成提取密钥" style="width: 420px"> <n-modal v-model:show="createVisible" preset="card" title="生成提取密钥" style="width: 420px">