proxy extract: always return scheme://ip:port

The plain ip:port output forced consumers to guess the protocol.
The probe already knows it from the handshake, so return the full
URL directly (socks5h/http/socks4).

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 20:39:06 +08:00
co-authored by Devin
parent 70022f15d6
commit 2ab39ecfab
3 changed files with 6 additions and 16 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ 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` 纯文本(加 `&fmt=url` 返回 `scheme://ip:port`,scheme 为握手实测的 先活先回一行 `scheme://ip:port`(握手实测协议)(加 `&fmt=url` 返回 `scheme://ip:port`,scheme 为握手实测的
socks5h/http/socks4);死的顺手标 `alive=0`,最多 4 批 / 20s 总超时,全死 503; socks5h/http/socks4);死的顺手标 `alive=0`,最多 4 批 / 20s 总超时,全死 503;
- 每次成功提取 `used_count+1`、`last_used_at` 更新。 - 每次成功提取 `used_count+1`、`last_used_at` 更新。
+2 -8
View File
@@ -28,8 +28,7 @@ type extractResult struct {
// ProxyExtractHandler GET /v1/proxy?key=xxx[&fmt=url] // ProxyExtractHandler GET /v1/proxy?key=xxx[&fmt=url]
// 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。 // 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。
// 并行探测一批候选,谁先探活返回谁;默认纯文本 "ip:port", // 并行探测一批候选,谁先探活返回谁;返回带握手实测协议的 "scheme://ip:port"(socks5h/http/socks4)。
// fmt=url 时带握手实测协议返回 "scheme://ip:port"(socks5h/http/socks4)。
// 两批全死返回 503。 // 两批全死返回 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 {
@@ -44,7 +43,6 @@ 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()
@@ -61,11 +59,7 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
if p, proto, _, 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")
if withScheme { reqCtx.SetBodyString(fmt.Sprintf("%s://%s:%d\n", urlScheme(proto), p.Ip, p.Port))
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
} }
} }
+3 -7
View File
@@ -61,10 +61,8 @@ async function create() {
} }
} }
const withScheme = ref(false)
function extractUrl(k: proxyKeyView): string { function extractUrl(k: proxyKeyView): string {
return `${location.origin}/api/v1/proxy?key=${k.key}${withScheme.value ? '&fmt=url' : ''}` return `${location.origin}/api/v1/proxy?key=${k.key}`
} }
async function copyUrl(k: proxyKeyView) { async function copyUrl(k: proxyKeyView) {
@@ -102,7 +100,6 @@ function regionLabel(k: proxyKeyView): string {
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
提取密钥 <span class="hint">{{ keys.length }} 个</span> 提取密钥 <span class="hint">{{ keys.length }} 个</span>
<span class="fmt-toggle">带协议前缀 <n-switch v-model:value="withScheme" size="small" /></span>
<n-button size="small" type="primary" style="margin-left:auto" @click="openCreate"> <n-button size="small" type="primary" style="margin-left:auto" @click="openCreate">
<template #icon><n-icon><AddOutline /></n-icon></template>生成密钥 <template #icon><n-icon><AddOutline /></n-icon></template>生成密钥
</n-button> </n-button>
@@ -131,8 +128,8 @@ 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;{{ withScheme ? '&fmt=url' : '' }}</div> <div class="mono usage-line">GET /api/v1/proxy?key=&lt;密钥&gt;</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 class="dim">每次请求实时探活,返回一个可用的 <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">
@@ -178,7 +175,6 @@ function regionLabel(k: proxyKeyView): string {
.key-stat { font-size: 12px; font-weight: 700; text-align: right; flex: none; min-width: 90px; } .key-stat { font-size: 12px; font-weight: 700; text-align: right; flex: none; min-width: 90px; }
.mono { font-family: Consolas, monospace; } .mono { font-family: Consolas, monospace; }
.dim { color: var(--cb-ink-3); font-weight: 500; } .dim { color: var(--cb-ink-3); font-weight: 500; }
.fmt-toggle { display: inline-flex; align-items: center; gap: 6px; margin-left: 14px; font-size: 12px; font-weight: 700; color: var(--cb-ink-2); }
.usage { margin-top: 16px; } .usage { margin-top: 16px; }
.usage-line { margin: 8px 0 4px; font-size: 13px; } .usage-line { margin: 8px 0 4px; font-size: 13px; }
.panel-inset { .panel-inset {