From fdb6710a609ccd05166b5cadb2deea27899bfe30 Mon Sep 17 00:00:00 2001 From: chillykidd Date: Thu, 4 Jun 2026 19:58:26 +0300 Subject: [PATCH] feat(proxy): add subscription link support Co-authored-by: Cursor --- .../methods/custom/getDashboardSections.ts | 26 + .../src/podkop/methods/shell/index.ts | 5 + fe-app-podkop/src/podkop/types.ts | 15 + .../luci-static/resources/view/podkop/main.js | 25 + .../resources/view/podkop/section.js | 547 +++++++++++++++++- podkop/files/etc/config/podkop | 2 + podkop/files/usr/bin/podkop | 138 ++++- 7 files changed, 744 insertions(+), 14 deletions(-) diff --git a/fe-app-podkop/src/podkop/methods/custom/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/custom/getDashboardSections.ts index a1f12d01..3accd672 100644 --- a/fe-app-podkop/src/podkop/methods/custom/getDashboardSections.ts +++ b/fe-app-podkop/src/podkop/methods/custom/getDashboardSections.ts @@ -88,6 +88,32 @@ export async function getDashboardSections(): Promise proxy.code === `${section['.name']}-out`, + ); + + const proxyDisplayName = + getProxyUrlName(section.subscription_proxy_link) || + outbound?.value?.name || + ''; + + return { + withTagSelect: false, + code: outbound?.code || section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: proxyDisplayName, + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + if (section.proxy_config_type === 'selector') { const selector = proxies.find( (proxy) => proxy.code === `${section['.name']}-out`, diff --git a/fe-app-podkop/src/podkop/methods/shell/index.ts b/fe-app-podkop/src/podkop/methods/shell/index.ts index e9c29406..c489d167 100644 --- a/fe-app-podkop/src/podkop/methods/shell/index.ts +++ b/fe-app-podkop/src/podkop/methods/shell/index.ts @@ -84,4 +84,9 @@ export const PodkopShellMethods = { callBaseMethod( Podkop.AvailableMethods.GET_SYSTEM_INFO, ), + getSubscriptionOutbounds: async (subscriptionUrl: string) => + callBaseMethod( + Podkop.AvailableMethods.GET_SUBSCRIPTION_OUTBOUNDS, + [subscriptionUrl], + ), }; diff --git a/fe-app-podkop/src/podkop/types.ts b/fe-app-podkop/src/podkop/types.ts index fff03c5d..20159f72 100644 --- a/fe-app-podkop/src/podkop/types.ts +++ b/fe-app-podkop/src/podkop/types.ts @@ -65,6 +65,7 @@ export namespace Podkop { SHOW_SING_BOX_CONFIG = 'show_sing_box_config', CHECK_LOGS = 'check_logs', GET_SYSTEM_INFO = 'get_system_info', + GET_SUBSCRIPTION_OUTBOUNDS = 'get_subscription_outbounds', } export enum AvailableClashAPIMethods { @@ -107,6 +108,13 @@ export namespace Podkop { proxy_string: string; } + export interface ConfigProxySubscriptionSection { + connection_type: 'proxy'; + proxy_config_type: 'subscription'; + subscription_url: string; + subscription_proxy_link: string; + } + export interface ConfigProxyOutboundSection { connection_type: 'proxy'; proxy_config_type: 'outbound'; @@ -130,6 +138,7 @@ export namespace Podkop { | ConfigProxyUrlTestSection | ConfigProxySelectorSection | ConfigProxyUrlSection + | ConfigProxySubscriptionSection | ConfigProxyOutboundSection | ConfigVpnSection | ConfigBlockSection @@ -210,6 +219,12 @@ export namespace Podkop { device_model: string; } + export interface SubscriptionOutbound { + id: number; + url: string; + name?: string; + } + export interface GetClashApiProxyLatency { delay: number; message?: string; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 009c1ae3..24aad47e 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -622,6 +622,7 @@ var Podkop; AvailableMethods2["SHOW_SING_BOX_CONFIG"] = "show_sing_box_config"; AvailableMethods2["CHECK_LOGS"] = "check_logs"; AvailableMethods2["GET_SYSTEM_INFO"] = "get_system_info"; + AvailableMethods2["GET_SUBSCRIPTION_OUTBOUNDS"] = "get_subscription_outbounds"; })(AvailableMethods = Podkop2.AvailableMethods || (Podkop2.AvailableMethods = {})); let AvailableClashAPIMethods; ((AvailableClashAPIMethods2) => { @@ -696,6 +697,10 @@ var PodkopShellMethods = { checkLogs: async () => callBaseMethod(Podkop.AvailableMethods.CHECK_LOGS), getSystemInfo: async () => callBaseMethod( Podkop.AvailableMethods.GET_SYSTEM_INFO + ), + getSubscriptionOutbounds: async (subscriptionUrl) => callBaseMethod( + Podkop.AvailableMethods.GET_SUBSCRIPTION_OUTBOUNDS, + [subscriptionUrl] ) }; @@ -762,6 +767,26 @@ async function getDashboardSections() { ] }; } + if (section.proxy_config_type === "subscription") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + const proxyDisplayName = getProxyUrlName(section.subscription_proxy_link) || outbound?.value?.name || ""; + return { + withTagSelect: false, + code: outbound?.code || section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: proxyDisplayName, + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } if (section.proxy_config_type === "selector") { const selector = proxies.find( (proxy) => proxy.code === `${section[".name"]}-out` diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js index 7dc8ccb9..d86df2a4 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js @@ -2,9 +2,395 @@ "require form"; "require baseclass"; "require ui"; +"require uci"; "require tools.widgets as widgets"; "require view.podkop.main as main"; +function getCbiWidget(section_id, option, event) { + const sectionName = typeof section_id === "string" ? section_id : ""; + const candidates = [ + `widget.cbid.podkop.${sectionName}.${option}`, + `cbid.podkop.${sectionName}.${option}`, + ]; + + for (const id of candidates) { + if (!sectionName) { + continue; + } + + const element = document.getElementById(id); + if (element) { + return element; + } + } + + const selectors = sectionName + ? [ + `[name="cbid.podkop.${sectionName}.${option}"]`, + `[id$=".${sectionName}.${option}"]`, + `[name$=".${sectionName}.${option}"]`, + ] + : []; + + for (const selector of selectors) { + const element = document.querySelector(selector); + if (element) { + return element; + } + } + + const sectionElement = event?.target?.closest?.(".cbi-section"); + return ( + sectionElement?.querySelector(`[id$=".${option}"]`) || + sectionElement?.querySelector(`[name$=".${option}"]`) || + document.querySelector(`[id$=".${option}"]`) || + document.querySelector(`[name$=".${option}"]`) + ); +} + +function getWidgetControl(element) { + if (!element) { + return null; + } + + if (element.matches?.("input, textarea, select")) { + return element; + } + + return element.querySelector?.("input, textarea, select") || null; +} + +function getWidgetValue(element) { + return getWidgetControl(element)?.value?.trim?.() || ""; +} + +function getFormOptionValue(optionContext, section_id, option) { + if (optionContext?.option === option) { + return optionContext?.formvalue?.(section_id)?.trim?.() || ""; + } + + const optionItem = optionContext?.map?.lookupOption?.( + option, + section_id, + )?.[0]; + return optionItem?.formvalue?.(section_id)?.trim?.() || ""; +} + +function getSubscriptionProxyDisplayName(item) { + return ( + main.getProxyUrlName(item.url) || + item.name || + _("Server %s").format(item.id) + ); +} + +function getSubscriptionServerFlag(displayName) { + return displayName.match(/[\u{1f1e6}-\u{1f1ff}]{2}/u)?.[0] || "🌐"; +} + +function getSubscriptionServerTitle(displayName) { + return displayName + .replace(/^.*?[\u{1f1e6}-\u{1f1ff}]{2}\s*/u, "") + .replace(/^pulsr\.\s*/i, "") + .trim(); +} + +function getSubscriptionServerSubtitle(item) { + try { + const url = new URL(item.url); + return `${url.hostname}:${url.port || ""}`.replace(/:$/, ""); + } catch (_e) { + return ""; + } +} + +function getSubscriptionServersCacheKey(section_id, subscriptionUrl) { + return `podkop.subscriptionServers.${section_id}.${subscriptionUrl}`; +} + +function getSubscriptionSelectedCacheKey(section_id, subscriptionUrl) { + return `podkop.subscriptionSelected.${section_id}.${subscriptionUrl}`; +} + +function getCachedSubscriptionServers(section_id, subscriptionUrl) { + if (!subscriptionUrl) { + return []; + } + + try { + const cached = sessionStorage.getItem( + getSubscriptionServersCacheKey(section_id, subscriptionUrl), + ); + const parsed = cached ? JSON.parse(cached) : []; + + return Array.isArray(parsed) ? parsed : []; + } catch (_e) { + return []; + } +} + +function getCachedSubscriptionSelected(section_id, subscriptionUrl) { + if (!subscriptionUrl) { + return ""; + } + + try { + return ( + sessionStorage.getItem( + getSubscriptionSelectedCacheKey(section_id, subscriptionUrl), + ) || "" + ); + } catch (_e) { + return ""; + } +} + +function setCachedSubscriptionSelected(section_id, subscriptionUrl, url) { + if (!subscriptionUrl || !url) { + return; + } + + try { + sessionStorage.setItem( + getSubscriptionSelectedCacheKey(section_id, subscriptionUrl), + url, + ); + } catch (_e) { + // Best effort cache only. + } +} + +function setCachedSubscriptionServers(section_id, subscriptionUrl, servers) { + if (!subscriptionUrl) { + return; + } + + try { + sessionStorage.setItem( + getSubscriptionServersCacheKey(section_id, subscriptionUrl), + JSON.stringify(servers), + ); + } catch (_e) { + // Best effort cache only. + } +} + +function setSubscriptionServerValue(root, section_id, url) { + if (!root) { + return; + } + + const subscriptionUrl = root.getAttribute("data-subscription-url") || ""; + root.setAttribute("data-selected-url", url); + setCachedSubscriptionSelected(section_id, subscriptionUrl, url); + + const input = + root.querySelector(`[id$=".${section_id}.subscription_proxy_link"]`) || + root.querySelector(`[name$=".${section_id}.subscription_proxy_link"]`) || + root.querySelector(`[id$=".subscription_proxy_link"]`) || + root.querySelector(`[name$=".subscription_proxy_link"]`); + + if (input) { + input.value = url; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + + try { + uci.set("podkop", section_id, "subscription_proxy_link", url); + } catch (_e) { + // The hidden input still carries the value for Save & Apply. + } + + root.querySelectorAll(".pdk-subscription-server-card").forEach((card) => { + const selected = card.getAttribute("data-url") === url; + card.style.borderColor = selected ? "#ff7a59" : "rgba(255,255,255,.12)"; + card.style.background = selected ? "rgba(255,122,89,.14)" : "#242522"; + card.querySelector(".pdk-subscription-server-card__check").textContent = + selected ? "✓" : ""; + }); +} + +function renderSubscriptionServerCards(section_id, servers, selectedUrl) { + if (!servers.length) { + return [ + E( + "div", + { + style: + "padding: 14px; border: 1px dashed rgba(255,255,255,.18); border-radius: 12px; color: #aaa;", + }, + _("Load servers from the subscription URL"), + ), + ]; + } + + return servers.map((item) => { + const displayName = getSubscriptionProxyDisplayName(item); + const title = getSubscriptionServerTitle(displayName) || displayName; + const subtitle = getSubscriptionServerSubtitle(item); + const selected = item.url === selectedUrl; + + return E( + "button", + { + type: "button", + class: "pdk-subscription-server-card", + "data-url": item.url, + style: [ + "display: grid", + "grid-template-columns: 34px minmax(0, 1fr) 22px", + "gap: 10px", + "align-items: center", + "width: 100%", + "height: 58px", + "min-height: 58px", + "flex: 0 0 58px", + "padding: 10px 12px", + "margin: 0", + "border-radius: 12px", + `border: 1px solid ${selected ? "#ff7a59" : "rgba(255,255,255,.12)"}`, + `background: ${selected ? "rgba(255,122,89,.14)" : "#242522"}`, + "color: inherit", + "text-align: left", + "cursor: pointer", + ].join("; "), + click: (event) => { + event.preventDefault(); + setSubscriptionServerValue( + event.currentTarget.closest(".pdk-subscription-server-picker"), + section_id, + item.url, + ); + }, + }, + [ + E("span", { style: "font-size: 24px; line-height: 1" }, [ + getSubscriptionServerFlag(displayName), + ]), + E("span", { style: "min-width: 0" }, [ + E( + "span", + { + style: + "display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600;", + }, + title, + ), + E( + "span", + { + style: + "display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .65; font-size: 12px; margin-top: 2px;", + }, + subtitle, + ), + ]), + E( + "span", + { + class: "pdk-subscription-server-card__check", + style: + "display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,122,89,.22); color: #ffb199; font-weight: 700;", + }, + selected ? "✓" : "", + ), + ], + ); + }); +} + +async function loadSubscriptionServers(optionContext, section_id, event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const subscriptionUrlElement = getCbiWidget( + section_id, + "subscription_url", + event, + ); + const subscriptionProxyLinkElement = getCbiWidget( + section_id, + "subscription_proxy_link", + event, + ); + const subscriptionProxyLinkControl = getWidgetControl( + subscriptionProxyLinkElement, + ); + const picker = document.getElementById( + `pdk-subscription-server-picker-${section_id}`, + ); + const cardsContainer = document.getElementById( + `pdk-subscription-server-cards-${section_id}`, + ); + const subscriptionUrl = + getFormOptionValue(optionContext, section_id, "subscription_url") || + getWidgetValue(subscriptionUrlElement); + const currentValue = + picker?.getAttribute("data-selected-url") || + getCachedSubscriptionSelected(section_id, subscriptionUrl) || + subscriptionProxyLinkControl?.value || + uci.get("podkop", section_id, "subscription_proxy_link") || + ""; + + if (!subscriptionUrl) { + ui.addNotification( + null, + E("p", {}, _("Subscription URL is required")), + "error", + ); + return; + } + + const validation = main.validateUrl(subscriptionUrl); + + if (!validation.valid) { + ui.addNotification(null, E("p", {}, validation.message), "error"); + return; + } + + const response = + await main.PodkopShellMethods.getSubscriptionOutbounds(subscriptionUrl); + + if ( + !response.success || + !Array.isArray(response.data) || + !response.data.length + ) { + ui.addNotification( + null, + E("p", {}, _("No supported proxy links were found in subscription")), + "error", + ); + return; + } + + if (!subscriptionProxyLinkControl || !picker || !cardsContainer) { + ui.addNotification( + null, + E("p", {}, _("Subscription server selector is not available")), + "error", + ); + return; + } + + setCachedSubscriptionServers(section_id, subscriptionUrl, response.data); + const selectedUrl = response.data.some((item) => item.url === currentValue) + ? currentValue + : response.data[0].url; + picker.setAttribute("data-subscription-url", subscriptionUrl); + subscriptionProxyLinkControl.value = selectedUrl; + cardsContainer.replaceChildren( + ...renderSubscriptionServerCards(section_id, response.data, selectedUrl), + ); + setSubscriptionServerValue(picker, section_id, selectedUrl); + + ui.addNotification( + null, + E("p", {}, _("Subscription servers loaded successfully")), + ); +} + function createSectionContent(section) { let o = section.option( form.ListValue, @@ -26,6 +412,7 @@ function createSectionContent(section) { o.value("url", _("Connection URL")); o.value("selector", _("Selector")); o.value("urltest", _("URLTest")); + o.value("subscription", _("Sub Link")); o.value("outbound", _("Outbound Config")); o.default = "url"; o.depends("connection_type", "proxy"); @@ -34,7 +421,7 @@ function createSectionContent(section) { form.TextValue, "proxy_string", _("Proxy Configuration URL"), - _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"), ); o.depends("proxy_config_type", "url"); o.rows = 5; @@ -59,6 +446,127 @@ function createSectionContent(section) { return validation.message; }; + o = section.option( + form.Value, + "subscription_url", + _("Subscription URL"), + _("HTTP/HTTPS subscription link from a VPN service"), + ); + o.depends("proxy_config_type", "subscription"); + o.rmempty = false; + const renderSubscriptionUrlWidget = o.renderWidget; + o.renderWidget = function (section_id, option_index, cfgvalue) { + const urlWidget = renderSubscriptionUrlWidget.apply(this, [ + section_id, + option_index, + cfgvalue, + ]); + + return E("div", { style: "display: flex; gap: 8px; align-items: center" }, [ + E("div", { style: "flex: 1 1 auto" }, urlWidget), + E( + "button", + { + type: "button", + class: "cbi-button cbi-button-apply", + style: "white-space: nowrap", + click: (event) => loadSubscriptionServers(this, section_id, event), + }, + _("Load Servers"), + ), + ]); + }; + o.validate = function (section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.Value, + "subscription_proxy_link", + _("Subscription Server"), + _("Select a server loaded from the subscription."), + ); + o.depends("proxy_config_type", "subscription"); + o.rmempty = false; + o.subscriptionServerLists = new Map(); + o.load = async function (section_id) { + const selectedProxyLink = uci.get( + "podkop", + section_id, + "subscription_proxy_link", + ); + const subscriptionUrl = uci.get("podkop", section_id, "subscription_url"); + const cachedServers = getCachedSubscriptionServers( + section_id, + subscriptionUrl, + ); + + const servers = cachedServers.length + ? cachedServers + : selectedProxyLink + ? [{ id: 1, url: selectedProxyLink }] + : []; + this.subscriptionServerLists.set(section_id, servers); + + return selectedProxyLink || ""; + }; + const renderSubscriptionProxyLinkWidget = o.renderWidget; + o.renderWidget = function (section_id, option_index, cfgvalue) { + const hiddenWidget = renderSubscriptionProxyLinkWidget.apply(this, [ + section_id, + option_index, + cfgvalue, + ]); + const servers = this.subscriptionServerLists.get(section_id) || []; + const selectedUrl = cfgvalue || servers[0]?.url || ""; + const subscriptionUrl = uci.get("podkop", section_id, "subscription_url"); + + return E( + "div", + { + id: `pdk-subscription-server-picker-${section_id}`, + class: "pdk-subscription-server-picker", + "data-subscription-url": subscriptionUrl || "", + "data-selected-url": selectedUrl, + }, + [ + E("div", { style: "display: none" }, hiddenWidget), + E( + "div", + { + id: `pdk-subscription-server-cards-${section_id}`, + style: + "display: flex; flex-direction: column; gap: 8px; height: 260px; min-height: 160px; max-height: 70vh; resize: vertical; overflow: auto; padding: 8px 8px 18px; border-radius: 14px; background: rgba(0,0,0,.14); border: 1px solid rgba(255,255,255,.08);", + }, + renderSubscriptionServerCards(section_id, servers, selectedUrl), + ), + ], + ); + }; + o.validate = function (section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + o = section.option( form.TextValue, "outbound_json", @@ -86,7 +594,7 @@ function createSectionContent(section) { form.DynamicList, "selector_proxy_links", _("Selector Proxy Links"), - _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"), ); o.depends("proxy_config_type", "selector"); o.rmempty = false; @@ -109,7 +617,7 @@ function createSectionContent(section) { form.DynamicList, "urltest_proxy_links", _("URLTest Proxy Links"), - _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"), ); o.depends("proxy_config_type", "urltest"); o.rmempty = false; @@ -132,7 +640,7 @@ function createSectionContent(section) { form.ListValue, "urltest_check_interval", _("URLTest Check Interval"), - _("The interval between connectivity tests") + _("The interval between connectivity tests"), ); o.value("30s", _("Every 30 seconds")); o.value("1m", _("Every 1 minute")); @@ -145,7 +653,9 @@ function createSectionContent(section) { form.Value, "urltest_tolerance", _("URLTest Tolerance"), - _("The maximum difference in response times (ms) allowed when comparing servers") + _( + "The maximum difference in response times (ms) allowed when comparing servers", + ), ); o.default = "50"; o.rmempty = false; @@ -157,23 +667,38 @@ function createSectionContent(section) { const parsed = parseFloat(value); - if (/^[0-9]+$/.test(value) && !isNaN(parsed) && isFinite(parsed) && parsed >= 50 && parsed <= 1000) { + if ( + /^[0-9]+$/.test(value) && + !isNaN(parsed) && + isFinite(parsed) && + parsed >= 50 && + parsed <= 1000 + ) { return true; } - return _('Must be a number in the range of 50 - 1000'); + return _("Must be a number in the range of 50 - 1000"); }; o = section.option( form.Value, "urltest_testing_url", _("URLTest Testing URL"), - _("The URL used to test server connectivity") + _("The URL used to test server connectivity"), + ); + o.value( + "https://www.gstatic.com/generate_204", + "https://www.gstatic.com/generate_204 (Google)", + ); + o.value( + "https://cp.cloudflare.com/generate_204", + "https://cp.cloudflare.com/generate_204 (Cloudflare)", ); - o.value("https://www.gstatic.com/generate_204", "https://www.gstatic.com/generate_204 (Google)"); - o.value("https://cp.cloudflare.com/generate_204", "https://cp.cloudflare.com/generate_204 (Cloudflare)"); o.value("https://captive.apple.com", "https://captive.apple.com (Apple)"); - o.value("https://connectivity-check.ubuntu.com", "https://connectivity-check.ubuntu.com (Ubuntu)") + o.value( + "https://connectivity-check.ubuntu.com", + "https://connectivity-check.ubuntu.com (Ubuntu)", + ); o.default = "https://www.gstatic.com/generate_204"; o.rmempty = false; o.depends("proxy_config_type", "urltest"); diff --git a/podkop/files/etc/config/podkop b/podkop/files/etc/config/podkop index 27003cfa..eb92e371 100644 --- a/podkop/files/etc/config/podkop +++ b/podkop/files/etc/config/podkop @@ -25,6 +25,8 @@ config section 'main' option connection_type 'proxy' option proxy_config_type 'url' option proxy_string '' + #option subscription_url 'https://example.com/subscription' + #option subscription_proxy_link 'vless://...' option enable_udp_over_tcp '0' list community_lists 'russia_inside' #option user_domain_list_type 'dynamic' diff --git a/podkop/files/usr/bin/podkop b/podkop/files/usr/bin/podkop index e3af7c06..aa168f76 100755 --- a/podkop/files/usr/bin/podkop +++ b/podkop/files/usr/bin/podkop @@ -78,23 +78,26 @@ check_requirements() { if has_outbound_section; then log "Outbound section found" "debug" else - log "Outbound section not found. Please check your configuration file (missing proxy_string, selector_proxy_links, urltest_proxy_links, outbound_json, or interface). Aborted." "error" + log "Outbound section not found. Please check your configuration file (missing proxy_string, selector_proxy_links, urltest_proxy_links, subscription_url, subscription_proxy_link, outbound_json, or interface). Aborted." "error" exit 1 fi } _check_outbound_section() { local section="$1" - local proxy_string interface outbound_json urltest_proxy_links + local proxy_string interface outbound_json urltest_proxy_links subscription_url subscription_proxy_link config_get proxy_string "$section" "proxy_string" config_get selector_proxy_links "$section" "selector_proxy_links" config_get urltest_proxy_links "$section" "urltest_proxy_links" + config_get subscription_url "$section" "subscription_url" + config_get subscription_proxy_link "$section" "subscription_proxy_link" config_get outbound_json "$section" "outbound_json" config_get interface "$section" "interface" if [ -n "$proxy_string" ] || [ -n "$selector_proxy_links" ] || [ -n "$urltest_proxy_links" ] || - [ -n "$outbound_json" ] || [ -n "$interface" ]; then + [ -n "$subscription_url" ] || [ -n "$subscription_proxy_link" ] || [ -n "$outbound_json" ] || + [ -n "$interface" ]; then section_exists=0 fi } @@ -547,6 +550,101 @@ list_update() { fi } +subscription_file_has_proxy_links() { + local filepath="$1" + + awk '{ gsub(/[[:space:],"]+/, "\n"); gsub(/\[/, "\n"); gsub(/\]/, "\n"); print }' "$filepath" | + sed 's/^[>-]*//' | + awk '/^(vless|ss|trojan|socks4|socks4a|socks5|hysteria2|hy2):\/\// { found=1; exit } END { exit !found }' +} + +extract_subscription_proxy_links() { + local source_file="$1" + local output_file="$2" + + awk '{ gsub(/[[:space:],"]+/, "\n"); gsub(/\[/, "\n"); gsub(/\]/, "\n"); print }' "$source_file" | + sed 's/^[>-]*//' | + awk '/^(vless|ss|trojan|socks4|socks4a|socks5|hysteria2|hy2):\/\// { print }' | + awk '!seen[$0]++' > "$output_file" +} + +download_subscription_to_file() { + local url="$1" + local filepath="$2" + local http_proxy_address="$3" + local retries="${4:-3}" + local wait="${5:-2}" + local attempt + + for attempt in $(seq 1 "$retries"); do + if [ -n "$http_proxy_address" ]; then + http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" wget -O "$filepath" "$url" && break + else + wget -O "$filepath" "$url" && break + fi + + log "Attempt $attempt/$retries to download subscription failed" "warn" + sleep "$wait" + done +} + +load_subscription_proxy_links() { + local subscription_url="$1" + local output_file="$2" + local tmpfile decoded_file source_file http_proxy_address + + tmpfile="$(mktemp)" + decoded_file="$(mktemp)" + http_proxy_address="$(get_service_proxy_address)" + + download_subscription_to_file "$subscription_url" "$tmpfile" "$http_proxy_address" + if [ $? -ne 0 ] || [ ! -s "$tmpfile" ]; then + log "Download subscription failed" "error" + rm -f "$tmpfile" "$decoded_file" + return 1 + fi + + convert_crlf_to_lf "$tmpfile" + source_file="$tmpfile" + + if base64 -d < "$tmpfile" > "$decoded_file" 2> /dev/null && [ -s "$decoded_file" ]; then + convert_crlf_to_lf "$decoded_file" + if subscription_file_has_proxy_links "$decoded_file"; then + source_file="$decoded_file" + fi + fi + + extract_subscription_proxy_links "$source_file" "$output_file" + + rm -f "$tmpfile" "$decoded_file" + + if [ ! -s "$output_file" ]; then + log "Subscription does not contain supported proxy links" "error" + return 1 + fi +} + +get_subscription_outbounds() { + local subscription_url="$1" + local links_file + + if [ -z "$subscription_url" ]; then + echo "[]" + return 1 + fi + + links_file="$(mktemp)" + + if ! load_subscription_proxy_links "$subscription_url" "$links_file"; then + rm -f "$links_file" + echo "[]" + return 1 + fi + + jq -R -s 'split("\n") | map(select(length > 0)) | to_entries | map({id: (.key + 1), url: .value})' "$links_file" + rm -f "$links_file" +} + # sing-box funcs sing_box_configure_service() { local sing_box_enabled sing_box_user sing_box_config_path sing_box_conffile @@ -678,6 +776,33 @@ configure_outbound_handler() { config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" \ "$default_outbound")" ;; + subscription) + log "Detected proxy configuration type: subscription" "debug" + local subscription_url subscription_proxy_link udp_over_tcp links_file + config_get subscription_url "$section" "subscription_url" + config_get subscription_proxy_link "$section" "subscription_proxy_link" + config_get udp_over_tcp "$section" "enable_udp_over_tcp" + + if [ -z "$subscription_proxy_link" ]; then + if [ -z "$subscription_url" ]; then + log "Subscription URL is not set. Aborted." "fatal" + exit 1 + fi + + links_file="$(mktemp)" + if ! load_subscription_proxy_links "$subscription_url" "$links_file"; then + rm -f "$links_file" + log "Unable to load subscription links. Aborted." "fatal" + exit 1 + fi + + subscription_proxy_link="$(sed -n '1p' "$links_file")" + rm -f "$links_file" + log "Subscription server is not selected, using the first server from subscription" "warn" + fi + + config=$(sing_box_cf_add_proxy_outbound "$config" "$section" "$subscription_proxy_link" "$udp_over_tcp") + ;; urltest) log "Detected proxy configuration type: urltest" "debug" local urltest_proxy_links udp_over_tcp i urltest_tag selector_tag outbound_tag outbound_tags \ @@ -1842,6 +1967,8 @@ show_config() { -e '/option outbound_json/,/^}/c\ option outbound_json '\''MASKED'\''' \ -e 's/\(list urltest_proxy_links\).*/\1 '\''MASKED'\''/g' \ -e 's/\(list selector_proxy_links\).*/\1 '\''MASKED'\''/g' \ + -e 's/\(option subscription_url\).*/\1 '\''MASKED'\''/g' \ + -e 's/\(option subscription_proxy_link\).*/\1 '\''MASKED'\''/g' \ -e "s@\\(option dns_server '[^/]*\\)/[^']*'@\\1/MASKED'@g" \ -e "s@\\(option domain_resolver_dns_server '[^/]*\\)/[^']*'@\\1/MASKED'@g" \ -e 's/\(option yacd_secret_key\).*/\1 '\''MASKED'\''/g' \ @@ -2677,6 +2804,8 @@ Available commands: check_sing_box_logs Show sing-box logs check_fakeip Test FakeIP on router clash_api Clash API interface for managing proxies and groups + get_subscription_outbounds + Download and list proxy links from a subscription URL show_config Display current podkop configuration show_version Show podkop version show_sing_box_config Show sing-box configuration @@ -2733,6 +2862,9 @@ check_fakeip) clash_api) clash_api "$2" "$3" "$4" ;; +get_subscription_outbounds) + get_subscription_outbounds "$2" + ;; show_config) show_config ;;