feat(stats): add dashboard analytics

This commit is contained in:
Aethersailor
2026-05-26 09:24:48 +08:00
parent 91969356ea
commit 91f0287751
22 changed files with 1391 additions and 40 deletions

View File

@@ -72,9 +72,11 @@ ADD_EXECUTABLE(${BUILD_TARGET_NAME}
src/generator/config/ruleconvert.cpp
src/generator/config/subexport.cpp
src/generator/template/templates.cpp
src/handler/dashboard_page.cpp
src/handler/inspect_page.cpp
src/handler/interfaces.cpp
src/handler/multithread.cpp
src/handler/statistics.cpp
src/handler/upload.cpp
src/handler/version_page.cpp
src/handler/webget.cpp

View File

@@ -164,6 +164,18 @@ profile=lan
;Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
allow_public_upload=false
[statistics]
;Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled.
enabled=false
;Put this directory on a Docker volume if statistics should survive restarts.
data_dir=stats
;Minimum seconds between persistence writes.
flush_interval=5
;header uses country-only headers such as CF-IPCountry and never stores IPs.
;none records all countries as unknown.
geo_provider=header
country_headers=CF-IPCountry,X-Geo-Country,X-Vercel-IP-Country,CloudFront-Viewer-Country
[emojis]
add_emoji=false
remove_old_emoji=true

View File

@@ -183,6 +183,21 @@ profile = "lan"
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
allow_public_upload = false
[statistics]
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
# and avoids registering the dashboard or statistics-enabled request handler.
enabled = false
# Put this directory on a Docker volume if statistics should survive restarts.
data_dir = "stats"
# Minimum seconds between persistence writes.
flush_interval = 5
[statistics.geo]
# header uses country-only headers such as CF-IPCountry and never stores IPs.
# none records all countries as unknown.
provider = "header"
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
[emojis]
add_emoji = false
remove_old_emoji = true

View File

@@ -83,6 +83,19 @@ security:
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
allow_public_upload: false
statistics:
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled.
enabled: false
# Put this directory on a Docker volume if statistics should survive restarts.
data_dir: stats
# Minimum seconds between persistence writes.
flush_interval: 5
geo:
# header uses country-only headers such as CF-IPCountry and never stores IPs.
# none records all countries as unknown.
provider: header
country_headers: ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
emojis:
add_emoji: false
remove_old_emoji: true

View File

@@ -15,6 +15,9 @@ services:
MANAGED_CONFIG_PREFIX: "http://your-domain-or-ip:25500"
volumes:
- "./base/pref.toml:/base/pref.toml:ro"
# 可选:持久化 /dashboard 统计数据;删除该目录即可清空统计。
# Optional: persist /dashboard statistics; delete this directory to reset.
- "./stats:/base/stats"
logging:
driver: "json-file"
options:

View File

@@ -158,7 +158,7 @@ static std::string transformRuleToCommon(string_view_array &temp, const std::str
return strLine;
}
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name)
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats)
{
string_array allRules;
std::string rule_group, retrieved_rules, strLine;
@@ -187,6 +187,8 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
strLine = appendClashRuleTarget(strLine, rule_group);
allRules.emplace_back(strLine);
total_rules++;
if(stats)
stats->add();
continue;
}
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
@@ -212,6 +214,9 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
}
strLine = appendClashRuleTarget(strLine, rule_group);
allRules.emplace_back(strLine);
total_rules++;
if(stats)
stats->add();
}
}
@@ -223,7 +228,7 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
base_rule[field_name] = rules;
}
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name)
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats)
{
std::string rule_group, retrieved_rules, strLine;
std::stringstream strStrm;
@@ -255,6 +260,8 @@ std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent>
strLine = appendClashRuleTarget(strLine, rule_group);
output_content += " - " + strLine + "\n";
total_rules++;
if(stats)
stats->add();
continue;
}
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
@@ -282,12 +289,14 @@ std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent>
strLine = appendClashRuleTarget(strLine, rule_group);
output_content += " - " + strLine + "\n";
total_rules++;
if(stats)
stats->add();
}
}
return output_content;
}
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string &remote_path_prefix)
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string &remote_path_prefix, RuleConversionStats *stats)
{
string_array allRules;
std::string rule_group, rule_path, rule_path_typed, retrieved_rules, strLine;
@@ -352,6 +361,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
strLine = replaceAllDistinct(strLine, ",,", ",");
allRules.emplace_back(strLine);
total_rules++;
if(stats)
stats->add();
continue;
}
else
@@ -360,6 +371,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
{
strLine = rule_path + ", tag=" + rule_group + ", force-policy=" + rule_group + ", enabled=true";
base_rule.set("filter_remote", "{NONAME}", strLine);
if(stats)
stats->add();
continue;
}
if(fileExist(rule_path))
@@ -370,6 +383,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
if(x.update_interval)
strLine += ",update-interval=" + std::to_string(x.update_interval);
allRules.emplace_back(strLine);
if(stats)
stats->add();
continue;
}
else if(surge_ver == -1 && !remote_path_prefix.empty())
@@ -377,12 +392,16 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
strLine = remote_path_prefix + "/getruleset?type=2&url=" + urlSafeBase64Encode(rule_path_typed) + "&group=" + urlSafeBase64Encode(rule_group);
strLine += ", tag=" + rule_group + ", enabled=true";
base_rule.set("filter_remote", "{NONAME}", strLine);
if(stats)
stats->add();
continue;
}
else if(surge_ver == -4 && !remote_path_prefix.empty())
{
strLine = remote_path_prefix + "/getruleset?type=1&url=" + urlSafeBase64Encode(rule_path_typed) + "," + rule_group;
base_rule.set("Remote Rule", "{NONAME}", strLine);
if(stats)
stats->add();
continue;
}
}
@@ -404,6 +423,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
strLine += ",update-interval=" + std::to_string(x.update_interval);
allRules.emplace_back(strLine);
if(stats)
stats->add();
continue;
}
else if(surge_ver == -1 && !remote_path_prefix.empty())
@@ -411,12 +432,16 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
strLine = remote_path_prefix + "/getruleset?type=2&url=" + urlSafeBase64Encode(rule_path_typed) + "&group=" + urlSafeBase64Encode(rule_group);
strLine += ", tag=" + rule_group + ", enabled=true";
base_rule.set("filter_remote", "{NONAME}", strLine);
if(stats)
stats->add();
continue;
}
else if(surge_ver == -4)
{
strLine = rule_path + "," + rule_group;
base_rule.set("Remote Rule", "{NONAME}", strLine);
if(stats)
stats->add();
continue;
}
}
@@ -491,6 +516,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
}
allRules.emplace_back(strLine);
total_rules++;
if(stats)
stats->add();
}
}
}
@@ -527,18 +554,18 @@ static rapidjson::Value transformRuleToSingBox(std::vector<std::string_view> &ar
return rule_obj;
}
static void appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Value &rules, const std::string& rule, rapidjson::MemoryPoolAllocator<>& allocator)
static bool appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Value &rules, const std::string& rule, rapidjson::MemoryPoolAllocator<>& allocator)
{
using namespace rapidjson_ext;
args.clear();
split(args, rule, ',');
if (args.size() < 2) return;
if (args.size() < 2) return false;
auto type = args[0];
// std::string_view option;
// if (args.size() >= 3) option = args[2];
if (none_of(SingBoxRuleTypes, [&](const std::string& t){ return type == t; }))
return;
return false;
auto realType = toLower(std::string(type));
auto value = toLower(std::string(args[1]));
@@ -546,9 +573,10 @@ static void appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Va
realType = replaceAllDistinct(realType, "ip_cidr6", "ip_cidr");
rules | AppendToArray(realType.c_str(), rapidjson::Value(value.c_str(), value.size(), allocator), allocator);
return true;
}
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules)
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, RuleConversionStats *stats)
{
using namespace rapidjson_ext;
std::string rule_group, retrieved_rules, strLine, final;
@@ -596,6 +624,8 @@ void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent
}
rules.PushBack(transformRuleToSingBox(temp, strLine, rule_group, allocator), allocator);
total_rules++;
if(stats)
stats->add();
continue;
}
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
@@ -620,7 +650,12 @@ void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent
strLine.erase(strLine.find("//"));
strLine = trimWhitespace(strLine);
}
appendSingBoxRule(temp, rule, strLine, allocator);
if (appendSingBoxRule(temp, rule, strLine, allocator))
{
total_rules++;
if(stats)
stats->add();
}
}
if (rule.ObjectEmpty()) continue;
rule.AddMember("outbound", rapidjson::Value(rule_group.c_str(), allocator), allocator);

View File

@@ -4,6 +4,7 @@
#include <string>
#include <vector>
#include <future>
#include <cstdint>
#include <yaml-cpp/yaml.h>
#include <rapidjson/document.h>
@@ -29,11 +30,17 @@ struct RulesetContent
int update_interval = 0;
};
struct RuleConversionStats
{
uint64_t rules = 0;
void add(uint64_t count = 1) { rules += count; }
};
std::string convertRuleset(const std::string &content, int type);
std::string appendClashRuleTarget(const std::string &rule, const std::string &target, bool no_resolve_only = false);
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name);
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name);
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string& remote_path_prefix);
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules);
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr);
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr);
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string& remote_path_prefix, RuleConversionStats *stats = nullptr);
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, RuleConversionStats *stats = nullptr);
#endif // RULECONVERT_H_INCLUDED

View File

@@ -1207,7 +1207,7 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
/*
if(ext.enable_rule_generator)
rulesetToClash(yamlnode, ruleset_content_array,
ext.overwrite_original_rules, ext.clash_new_field_name);
ext.overwrite_original_rules, ext.clash_new_field_name, ext.rule_stats);
return YAML::Dump(yamlnode);
*/
@@ -1235,7 +1235,7 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
renderClashScript(yamlnode, ruleset_content_array,
ext.managed_config_prefix, ext.clash_script,
ext.overwrite_original_rules,
ext.clash_classical_ruleset);
ext.clash_classical_ruleset, ext.rule_stats);
std::string result = YAML::Dump(yamlnode);
bool has_providers = !proxy_providers_yaml.empty();
if (has_providers) {
@@ -1250,7 +1250,8 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
std::string output_content =
rulesetToClashStr(yamlnode, ruleset_content_array,
ext.overwrite_original_rules, ext.clash_new_field_name);
ext.overwrite_original_rules, ext.clash_new_field_name,
ext.rule_stats);
// 提取 proxy-providers手动控制输出顺序
// 使用之前在 998-1002 行已提取的 proxy_providers_yaml
@@ -1700,7 +1701,8 @@ std::string proxyToSurge(std::vector<Proxy> &nodes,
if (ext.enable_rule_generator)
rulesetToSurge(ini, ruleset_content_array, surge_ver,
ext.overwrite_original_rules, ext.managed_config_prefix);
ext.overwrite_original_rules, ext.managed_config_prefix,
ext.rule_stats);
return ini.to_string();
}
@@ -2195,7 +2197,7 @@ void proxyToQuan(std::vector<Proxy> &nodes, INIReader &ini,
if (ext.enable_rule_generator)
rulesetToSurge(ini, ruleset_content_array, -2, ext.overwrite_original_rules,
"");
"", ext.rule_stats);
}
std::string proxyToQuanX(std::vector<Proxy> &nodes,
@@ -2482,7 +2484,7 @@ void proxyToQuanX(std::vector<Proxy> &nodes, INIReader &ini,
if (ext.enable_rule_generator)
rulesetToSurge(ini, ruleset_content_array, -1, ext.overwrite_original_rules,
ext.managed_config_prefix);
ext.managed_config_prefix, ext.rule_stats);
}
std::string proxyToSSD(std::vector<Proxy> &nodes, std::string &group,
@@ -2745,7 +2747,7 @@ void proxyToMellow(std::vector<Proxy> &nodes, INIReader &ini,
if (ext.enable_rule_generator)
rulesetToSurge(ini, ruleset_content_array, 0, ext.overwrite_original_rules,
"");
"", ext.rule_stats);
}
std::string proxyToLoon(std::vector<Proxy> &nodes, const std::string &base_conf,
@@ -3049,7 +3051,7 @@ std::string proxyToLoon(std::vector<Proxy> &nodes, const std::string &base_conf,
if (ext.enable_rule_generator)
rulesetToSurge(ini, ruleset_content_array, -4, ext.overwrite_original_rules,
ext.managed_config_prefix);
ext.managed_config_prefix, ext.rule_stats);
return ini.to_string();
}
@@ -3703,7 +3705,8 @@ std::string proxyToSingBox(std::vector<Proxy> &nodes,
if (ext.nodelist || !ext.enable_rule_generator)
return json | SerializeObject();
rulesetToSingBox(json, ruleset_content_array, ext.overwrite_original_rules);
rulesetToSingBox(json, ruleset_content_array, ext.overwrite_original_rules,
ext.rule_stats);
return json | SerializeObject();
}

View File

@@ -58,6 +58,7 @@ struct extra_settings {
bool provider_proxy_direct = true; // proxy-provider 默认使用 DIRECT 更新
std::vector<ProxyProvider> providers; // provider 列表
bool authorized = false;
RuleConversionStats *rule_stats = nullptr;
extra_settings() = default;
extra_settings(const extra_settings &) = delete;

View File

@@ -397,7 +397,7 @@ std::string findFileName(const std::string &path)
return path.substr(pos + 1, pos2 - pos - 1);
}
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classical_ruleset)
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classical_ruleset, RuleConversionStats *stats)
{
nlohmann::json data;
std::string match_group, geoips, retrieved_rules;
@@ -439,6 +439,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
strLine = "MATCH";
strLine = appendClashRuleTarget(strLine, rule_group);
rules.emplace_back(std::move(strLine));
if(stats)
stats->add();
continue;
}
else
@@ -466,7 +468,11 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
break;
}
if(!script)
{
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
if(stats)
stats->add();
}
groups.emplace_back(rule_name);
continue;
}
@@ -486,7 +492,11 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
if(clash_classical_ruleset)
{
if(!script)
{
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
if(stats)
stats->add();
}
groups.emplace_back(rule_name);
continue;
}
@@ -545,6 +555,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
strLine += "," + vArray[2];
}
rules.emplace_back(strLine);
if(stats)
stats->add();
}
}
else if(!has_domain[rule_name] && (startsWith(strLine, "DOMAIN,") || startsWith(strLine, "DOMAIN-SUFFIX,")))
@@ -557,16 +569,26 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
}
}
if(has_domain[rule_name] && !script)
{
rules.emplace_back("RULE-SET," + rule_name + " (Domain)," + rule_group);
if(stats)
stats->add();
}
if(has_ipcidr[rule_name] && !script)
{
if(has_no_resolve)
rules.emplace_back("RULE-SET," + rule_name + " (IP-CIDR)," + rule_group + ",no-resolve");
else
rules.emplace_back("RULE-SET," + rule_name + " (IP-CIDR)," + rule_group);
if(stats)
stats->add();
}
if(!has_domain[rule_name] && !has_ipcidr[rule_name] && !script)
{
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
if(stats)
stats->add();
}
if(std::find(groups.begin(), groups.end(), rule_name) == groups.end())
groups.emplace_back(rule_name);
}
@@ -622,6 +644,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
}
if(script)
{
if(stats)
stats->add();
std::string json_path = "rules." + std::to_string(index) + ".";
parse_json_pointer(data, json_path + "has_domain", group_has_domain ? "true" : "false");
parse_json_pointer(data, json_path + "has_ipcidr", group_has_ipcidr ? "true" : "false");

View File

@@ -20,6 +20,6 @@ int render_template(const std::string &content, const template_args &vars,
std::string &output,
const std::string &include_scope = "templates",
FetchContext context = FetchContext::TrustedConfig);
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classic_ruleset);
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classic_ruleset, RuleConversionStats *stats = nullptr);
#endif // TEMPLATES_H_INCLUDED

View File

@@ -0,0 +1,623 @@
#include "handler/dashboard_page.h"
namespace dashboard_page {
std::string page(Request &, Response &response) {
response.headers["X-Robots-Tag"] =
"noindex, nofollow, noarchive, nosnippet, noimageindex";
return R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<meta name="color-scheme" content="light dark">
<title>SubConverter-Extended Dashboard</title>
<script>
(function () {
var saved = localStorage.getItem("sce-dashboard-lang");
if (saved) {
document.documentElement.lang = saved;
return;
}
var languages = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
document.documentElement.lang = languages.some(function (language) { return /^zh\b/i.test(language); }) ? "zh-CN" : "en";
})();
</script>
<link rel="icon" type="image/svg+xml" href="/version/favicon-dark.svg">
<link rel="icon" type="image/svg+xml" href="/version/favicon-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="/version/favicon-dark.svg" media="(prefers-color-scheme: dark)">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-gradient: linear-gradient(135deg, #f8fafc 0%, #eef2f7 48%, #e2e8f0 100%);
--bg-grid: rgba(15, 23, 42, 0.055);
--bg-sheen: linear-gradient(115deg, transparent 0%, transparent 33%, rgba(14, 165, 233, 0.11) 48%, rgba(132, 204, 22, 0.09) 58%, transparent 73%, transparent 100%);
--surface: rgba(255, 255, 255, 0.82);
--surface-strong: rgba(255, 255, 255, 0.92);
--surface-border: rgba(15, 23, 42, 0.1);
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-muted: #64748b;
--accent: #0284c7;
--accent-2: #65a30d;
--accent-gradient: linear-gradient(135deg, #0284c7 0%, #0891b2 45%, #65a30d 100%);
--shadow: 0 28px 70px rgba(15, 23, 42, 0.13);
--control-bg: rgba(255, 255, 255, 0.72);
--control-hover: rgba(255, 255, 255, 0.92);
--control-border: rgba(26, 32, 44, 0.12);
--map-fill: #dbe6ef;
--map-stroke: rgba(255, 255, 255, 0.85);
--map-empty: #d4dde8;
--danger: #dc2626;
--warn: #b45309;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-gradient: linear-gradient(135deg, #05070b 0%, #0d111a 46%, #111827 100%);
--bg-grid: rgba(148, 163, 184, 0.075);
--bg-sheen: linear-gradient(115deg, transparent 0%, transparent 31%, rgba(34, 211, 238, 0.12) 47%, rgba(132, 204, 22, 0.08) 58%, transparent 74%, transparent 100%);
--surface: rgba(15, 23, 42, 0.72);
--surface-strong: rgba(21, 29, 43, 0.9);
--surface-border: rgba(148, 163, 184, 0.18);
--text-primary: #f8f9fa;
--text-secondary: #a0aec0;
--text-muted: #7f8ea3;
--accent: #38bdf8;
--accent-2: #84cc16;
--accent-gradient: linear-gradient(135deg, #38bdf8 0%, #22d3ee 42%, #84cc16 100%);
--shadow: 0 32px 80px rgba(0, 0, 0, 0.62);
--control-bg: rgba(20, 24, 33, 0.7);
--control-hover: rgba(35, 42, 56, 0.86);
--control-border: rgba(255, 255, 255, 0.16);
--map-fill: #243245;
--map-stroke: rgba(2, 6, 23, 0.9);
--map-empty: #253244;
--danger: #f87171;
--warn: #fbbf24;
}
}
html[lang^="zh"] [data-lang="en"],
html:not([lang^="zh"]) [data-lang="zh"] { display: none; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
color: var(--text-primary);
background: var(--bg-gradient);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
position: relative;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
body::before {
background-image: linear-gradient(var(--bg-grid) 1px, transparent 1px), linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
opacity: 0.58;
}
body::after { background: var(--bg-sheen); opacity: 0.82; }
.shell {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 42px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand img {
width: 48px;
height: 48px;
flex: 0 0 auto;
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
}
h1 {
margin: 0;
font-size: 1.8rem;
line-height: 1.08;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.subtitle {
margin-top: 5px;
color: var(--text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
button {
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text-primary);
border-radius: 999px;
min-height: 40px;
padding: 9px 13px;
font: inherit;
font-size: 0.88rem;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transition: background 0.2s ease, transform 0.2s ease;
}
button:hover { background: var(--control-hover); transform: translateY(-1px); }
button:focus-visible { outline: 3px solid rgba(99, 179, 237, 0.35); outline-offset: 2px; }
.panel {
background: var(--surface);
border: 1px solid var(--surface-border);
box-shadow: var(--shadow);
border-radius: 28px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
overflow: hidden;
}
.hero {
padding: 24px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
}
.metric {
min-height: 126px;
padding: 17px;
border: 1px solid var(--surface-border);
border-radius: 18px;
background: rgba(255, 255, 255, 0.32);
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media (prefers-color-scheme: dark) {
.metric { background: rgba(255, 255, 255, 0.045); }
}
.metric-label {
color: var(--text-muted);
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
}
.metric-value {
margin-top: 12px;
font-size: 1.8rem;
line-height: 1;
font-weight: 800;
letter-spacing: 0;
}
.metric-sub {
margin-top: 10px;
color: var(--text-secondary);
font-size: 0.86rem;
font-weight: 650;
}
.content {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
gap: 18px;
padding: 0 24px 24px;
}
.section {
border-top: 1px solid var(--surface-border);
padding-top: 22px;
min-width: 0;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
h2 {
margin: 0;
font-size: 1.05rem;
letter-spacing: 0;
}
.state-line {
color: var(--text-muted);
font-size: 0.86rem;
font-weight: 650;
}
.map-wrap {
position: relative;
min-height: 430px;
border: 1px solid var(--surface-border);
border-radius: 20px;
background: var(--surface-strong);
overflow: hidden;
}
#world-map {
width: 100%;
height: 430px;
display: block;
}
.country {
fill: var(--map-empty);
stroke: var(--map-stroke);
stroke-width: 0.45;
transition: fill 0.16s ease, opacity 0.16s ease;
}
.country.has-data { cursor: pointer; }
.country:hover { opacity: 0.82; }
.tooltip {
position: absolute;
pointer-events: none;
min-width: 170px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--surface-border);
background: var(--surface-strong);
color: var(--text-primary);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22);
transform: translate(-50%, calc(-100% - 12px));
opacity: 0;
transition: opacity 0.12s ease;
font-size: 0.86rem;
z-index: 3;
}
.tooltip.show { opacity: 1; }
.tooltip-title { font-weight: 800; margin-bottom: 4px; }
.tooltip-row { color: var(--text-secondary); display: flex; justify-content: space-between; gap: 14px; }
.chart {
height: 170px;
display: flex;
align-items: end;
gap: 5px;
padding: 18px 14px 12px;
border: 1px solid var(--surface-border);
border-radius: 20px;
background: var(--surface-strong);
}
.bar {
flex: 1 1 0;
min-width: 4px;
border-radius: 5px 5px 2px 2px;
background: var(--accent-gradient);
opacity: 0.88;
}
.country-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--surface-border);
border-radius: 18px;
overflow: hidden;
background: var(--surface-strong);
}
.country-table th,
.country-table td {
padding: 11px 12px;
text-align: left;
border-bottom: 1px solid var(--surface-border);
font-size: 0.9rem;
}
.country-table th {
color: var(--text-muted);
font-size: 0.76rem;
text-transform: uppercase;
}
.country-table tr:last-child td { border-bottom: 0; }
.country-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
margin-right: 8px;
font-size: 1.08rem;
line-height: 1;
vertical-align: -0.08em;
}
.code-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 24px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--text-primary);
font-size: 0.75rem;
font-weight: 800;
margin-right: 8px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 750;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent-2);
}
.empty {
padding: 30px;
color: var(--text-muted);
text-align: center;
font-weight: 650;
}
@media (max-width: 980px) {
.hero { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.content { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.shell { width: min(100% - 20px, 1180px); padding-top: 16px; }
.topbar { align-items: flex-start; flex-direction: column; }
.hero { grid-template-columns: 1fr 1fr; padding: 14px; }
.metric { min-height: 112px; padding: 14px; }
.metric-value { font-size: 1.42rem; }
.content { padding: 0 14px 14px; }
#world-map, .map-wrap { min-height: 310px; height: 310px; }
h1 { font-size: 1.45rem; }
}
</style>
</head>
<body>
<main class="shell">
<div class="topbar">
<div class="brand">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
</picture>
<div>
<h1>Dashboard</h1>
<div class="subtitle">
<span data-lang="en">Runtime conversion statistics</span>
<span data-lang="zh"></span>
</div>
</div>
</div>
<div class="actions">
<button type="button" id="refresh-button">
<span data-lang="en">Refresh</span><span data-lang="zh"></span>
</button>
<button type="button" id="lang-toggle">EN</button>
</div>
</div>
<section class="panel">
<div class="hero" id="metrics"></div>
<div class="content">
<section class="section">
<div class="section-head">
<h2><span data-lang="en">Country Distribution</span><span data-lang="zh"></span></h2>
<span class="status"><span class="status-dot"></span><span id="country-status">-</span></span>
</div>
<div class="map-wrap">
<svg id="world-map" role="img" aria-label="World map"></svg>
<div class="tooltip" id="tooltip"></div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2><span data-lang="en">Last 24 Hours</span><span data-lang="zh"> 24 </span></h2>
<span class="state-line" id="updated-at">-</span>
</div>
<div class="chart" id="chart"></div>
<div style="height: 16px"></div>
<table class="country-table">
<thead>
<tr>
<th><span data-lang="en">Country</span><span data-lang="zh"></span></th>
<th><span data-lang="en">Requests</span><span data-lang="zh"></span></th>
<th><span data-lang="en">Rules</span><span data-lang="zh"></span></th>
</tr>
</thead>
<tbody id="country-body"></tbody>
</table>
</section>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script>
(function () {
var ISO_N3 = {
"004":"AF","008":"AL","010":"AQ","012":"DZ","016":"AS","020":"AD","024":"AO","028":"AG","031":"AZ","032":"AR","036":"AU","040":"AT","044":"BS","048":"BH","050":"BD","051":"AM","052":"BB","056":"BE","060":"BM","064":"BT","068":"BO","070":"BA","072":"BW","074":"BV","076":"BR","084":"BZ","086":"IO","090":"SB","092":"VG","096":"BN","100":"BG","104":"MM","108":"BI","112":"BY","116":"KH","120":"CM","124":"CA","132":"CV","136":"KY","140":"CF","144":"LK","148":"TD","152":"CL","156":"CN","158":"TW","162":"CX","166":"CC","170":"CO","174":"KM","175":"YT","178":"CG","180":"CD","184":"CK","188":"CR","191":"HR","192":"CU","196":"CY","203":"CZ","204":"BJ","208":"DK","212":"DM","214":"DO","218":"EC","222":"SV","226":"GQ","231":"ET","232":"ER","233":"EE","234":"FO","238":"FK","239":"GS","242":"FJ","246":"FI","248":"AX","250":"FR","254":"GF","258":"PF","260":"TF","262":"DJ","266":"GA","268":"GE","270":"GM","275":"PS","276":"DE","288":"GH","292":"GI","296":"KI","300":"GR","304":"GL","308":"GD","312":"GP","316":"GU","320":"GT","324":"GN","328":"GY","332":"HT","334":"HM","336":"VA","340":"HN","344":"HK","348":"HU","352":"IS","356":"IN","360":"ID","364":"IR","368":"IQ","372":"IE","376":"IL","380":"IT","384":"CI","388":"JM","392":"JP","398":"KZ","400":"JO","404":"KE","408":"KP","410":"KR","414":"KW","417":"KG","418":"LA","422":"LB","426":"LS","428":"LV","430":"LR","434":"LY","438":"LI","440":"LT","442":"LU","446":"MO","450":"MG","454":"MW","458":"MY","462":"MV","466":"ML","470":"MT","474":"MQ","478":"MR","480":"MU","484":"MX","492":"MC","496":"MN","498":"MD","499":"ME","500":"MS","504":"MA","508":"MZ","512":"OM","516":"NA","520":"NR","524":"NP","528":"NL","531":"CW","533":"AW","534":"SX","535":"BQ","540":"NC","548":"VU","554":"NZ","558":"NI","562":"NE","566":"NG","570":"NU","574":"NF","578":"NO","580":"MP","581":"UM","583":"FM","584":"MH","585":"PW","586":"PK","591":"PA","598":"PG","600":"PY","604":"PE","608":"PH","612":"PN","616":"PL","620":"PT","624":"GW","626":"TL","630":"PR","634":"QA","638":"RE","642":"RO","643":"RU","646":"RW","652":"BL","654":"SH","659":"KN","660":"AI","662":"LC","663":"MF","666":"PM","670":"VC","674":"SM","678":"ST","682":"SA","686":"SN","688":"RS","690":"SC","694":"SL","702":"SG","703":"SK","704":"VN","705":"SI","706":"SO","710":"ZA","716":"ZW","724":"ES","728":"SS","729":"SD","732":"EH","740":"SR","744":"SJ","748":"SZ","752":"SE","756":"CH","760":"SY","762":"TJ","764":"TH","768":"TG","772":"TK","776":"TO","780":"TT","784":"AE","788":"TN","792":"TR","795":"TM","796":"TC","798":"TV","800":"UG","804":"UA","807":"MK","818":"EG","826":"GB","831":"GG","832":"JE","833":"IM","834":"TZ","840":"US","850":"VI","854":"BF","858":"UY","860":"UZ","862":"VE","876":"WF","882":"WS","887":"YE","894":"ZM"
};
var metricsEl = document.getElementById("metrics");
var countryBody = document.getElementById("country-body");
var countryStatus = document.getElementById("country-status");
var updatedAt = document.getElementById("updated-at");
var chart = document.getElementById("chart");
var tooltip = document.getElementById("tooltip");
var mapData = null;
var latest = null;
var countryMap = new Map();
function isZh() { return /^zh\b/i.test(document.documentElement.lang); }
function text(en, zh) { return isZh() ? zh : en; }
function number(value) { return new Intl.NumberFormat(isZh() ? "zh-CN" : "en").format(value || 0); }
function countryName(code) {
if (code === "ZZ" || code === "XX") return text("Unknown", "未知");
if (code === "T1") return text("Tor network", "Tor 网络");
try { return new Intl.DisplayNames([isZh() ? "zh-CN" : "en"], { type: "region" }).of(code) || code; }
catch (error) { return code; }
}
function countryIcon(code) {
if (!/^[A-Z]{2}$/.test(code) || code === "ZZ" || code === "XX") return String.fromCodePoint(0x25CC);
return String.fromCodePoint(code.charCodeAt(0) + 127397, code.charCodeAt(1) + 127397);
}
function fmtTime(seconds) {
if (!seconds) return "-";
return new Intl.DateTimeFormat(isZh() ? "zh-CN" : "en", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(seconds * 1000));
}
function metric(labelEn, labelZh, data) {
var total = data || {};
var el = document.createElement("article");
el.className = "metric";
el.innerHTML = '<div class="metric-label">' + text(labelEn, labelZh) + '</div>' +
'<div class="metric-value">' + number(total.subscription_requests) + '</div>' +
'<div class="metric-sub">' + text("Rules ", " ") + number(total.rule_conversions) + '</div>';
return el;
}
function renderMetrics(data) {
var windows = data.windows || {};
metricsEl.textContent = "";
[
["Since Start", "本次启动", windows.startup],
["1 Hour", "1 小时", windows.hour],
["1 Day", "1 天", windows.day],
["7 Days", "7 天", windows.seven_days],
["30 Days", "30 天", windows.thirty_days],
["Lifetime", "历史总计", windows.lifetime]
].forEach(function (item) { metricsEl.appendChild(metric(item[0], item[1], item[2])); });
}
function renderChart(series) {
chart.textContent = "";
var max = Math.max(1, ...series.map(function (item) { return item.subscription_requests || 0; }));
series.forEach(function (item) {
var bar = document.createElement("div");
bar.className = "bar";
bar.style.height = Math.max(4, Math.round(((item.subscription_requests || 0) / max) * 132)) + "px";
bar.title = fmtTime(item.time) + " / " + number(item.subscription_requests);
chart.appendChild(bar);
});
}
function renderCountries(countries) {
countryMap = new Map();
countries.forEach(function (item) { countryMap.set(item.code, item); });
countryStatus.textContent = text("Countries ", "国家 ") + number(countries.filter(function (item) { return item.code !== "ZZ" && item.code !== "XX"; }).length);
countryBody.textContent = "";
if (!countries.length) {
var row = document.createElement("tr");
var cell = document.createElement("td");
cell.colSpan = 3;
cell.className = "empty";
cell.textContent = text("No conversion data yet", "暂无转换数据");
row.appendChild(cell);
countryBody.appendChild(row);
return;
}
countries.slice(0, 12).forEach(function (item) {
var row = document.createElement("tr");
var name = document.createElement("td");
name.innerHTML = '<span class="country-icon">' + countryIcon(item.code) + '</span><span class="code-badge">' + item.code + '</span>' + countryName(item.code);
var req = document.createElement("td");
req.textContent = number(item.subscription_requests);
var rules = document.createElement("td");
rules.textContent = number(item.rule_conversions);
row.appendChild(name);
row.appendChild(req);
row.appendChild(rules);
countryBody.appendChild(row);
});
}
function renderMap() {
if (!mapData || !window.d3 || !window.topojson) return;
var svg = d3.select("#world-map");
var node = svg.node();
var width = node.clientWidth || 800;
var height = node.clientHeight || 430;
svg.attr("viewBox", "0 0 " + width + " " + height);
svg.selectAll("*").remove();
var projection = d3.geoNaturalEarth1().fitSize([width, height], { type: "Sphere" });
var path = d3.geoPath(projection);
var features = topojson.feature(mapData, mapData.objects.countries).features;
var max = Math.max(1, ...Array.from(countryMap.values()).map(function (item) { return item.subscription_requests || 0; }));
var color = d3.scaleSequential([0, Math.log10(max + 1)], d3.interpolateYlGnBu);
svg.append("path").datum({ type: "Sphere" }).attr("d", path).attr("fill", "transparent");
svg.selectAll("path.country")
.data(features)
.enter()
.append("path")
.attr("class", function (d) {
var code = ISO_N3[String(d.id).padStart(3, "0")];
return "country" + (countryMap.has(code) ? " has-data" : "");
})
.attr("d", path)
.attr("fill", function (d) {
var code = ISO_N3[String(d.id).padStart(3, "0")];
var item = countryMap.get(code);
if (!item) return "var(--map-empty)";
return color(Math.log10((item.subscription_requests || 0) + 1));
})
.on("mousemove", function (event, d) {
var code = ISO_N3[String(d.id).padStart(3, "0")] || "ZZ";
var item = countryMap.get(code) || { subscription_requests: 0, rule_conversions: 0 };
tooltip.innerHTML = '<div class="tooltip-title"><span class="country-icon">' + countryIcon(code) + '</span>' + countryName(code) + ' · ' + code + '</div>' +
'<div class="tooltip-row"><span>' + text("Requests", "") + '</span><strong>' + number(item.subscription_requests) + '</strong></div>' +
'<div class="tooltip-row"><span>' + text("Rules", "") + '</span><strong>' + number(item.rule_conversions) + '</strong></div>';
tooltip.style.left = event.offsetX + "px";
tooltip.style.top = event.offsetY + "px";
tooltip.classList.add("show");
})
.on("mouseleave", function () { tooltip.classList.remove("show"); });
}
function render(data) {
latest = data;
renderMetrics(data);
renderChart(data.series || []);
renderCountries(data.countries || []);
updatedAt.textContent = text("Updated ", "更新 ") + fmtTime(data.generated_at);
renderMap();
}
async function refresh() {
var response = await fetch("/dashboard/data", { cache: "no-store", headers: { "Accept": "application/json" } });
render(await response.json());
}
document.getElementById("lang-toggle").addEventListener("click", function () {
document.documentElement.lang = isZh() ? "en" : "zh-CN";
localStorage.setItem("sce-dashboard-lang", document.documentElement.lang);
document.getElementById("lang-toggle").textContent = isZh() ? "" : "EN";
if (latest) render(latest);
});
document.getElementById("refresh-button").addEventListener("click", refresh);
document.getElementById("lang-toggle").textContent = isZh() ? "" : "EN";
Promise.all([
fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json").then(function (response) { return response.json(); }).catch(function () { return null; }),
refresh()
]).then(function (values) {
mapData = values[0];
renderMap();
});
window.addEventListener("resize", function () { renderMap(); });
setInterval(refresh, 30000);
})();
</script>
</body>
</html>)html";
}
} // namespace dashboard_page

View File

@@ -0,0 +1,14 @@
#ifndef DASHBOARD_PAGE_H_INCLUDED
#define DASHBOARD_PAGE_H_INCLUDED
#include <string>
#include "server/webserver.h"
namespace dashboard_page {
std::string page(Request &request, Response &response);
} // namespace dashboard_page
#endif // DASHBOARD_PAGE_H_INCLUDED

View File

@@ -29,6 +29,7 @@
#include "script/script_quickjs.h"
#include "server/webserver.h"
#include "settings.h"
#include "statistics.h"
#include "upload.h"
#include "utils/time_compat.h"
@@ -613,7 +614,8 @@ static std::string sanitizeProviderName(const std::string &input) {
return cleaned;
}
static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS);
static std::string subconverter_impl(Request &request, Response &response,
RuleConversionStats *rule_stats = nullptr);
namespace {
@@ -622,6 +624,7 @@ struct CoalescedResponse {
std::string content_type;
string_icase_map headers;
std::string body;
uint64_t rule_conversions = 0;
};
struct InflightSubRequest {
@@ -980,12 +983,14 @@ static void copyCoalescedToResponse(const CoalescedResponse &result,
}
static CoalescedResponse makeCoalescedResult(const std::string &body,
const Response &response) {
const Response &response,
uint64_t rule_conversions) {
CoalescedResponse result;
result.status_code = response.status_code;
result.content_type = response.content_type;
result.headers = response.headers;
result.body = body;
result.rule_conversions = rule_conversions;
return result;
}
@@ -1041,11 +1046,16 @@ static void storeCachedSubResponse(const std::string &key,
}
static std::string runSubconverterImplWithRetry(const Request &original,
Response &response) {
Response &response,
RuleConversionStats *stats) {
Request first_request = original;
Response first_response;
std::string body = subconverter_impl(first_request, first_response);
RuleConversionStats first_stats;
std::string body = subconverter_impl(first_request, first_response,
stats ? &first_stats : nullptr);
if (first_response.status_code < 500 || !global.coalesceRetryOn5xx) {
if (stats)
*stats = first_stats;
response = first_response;
return body;
}
@@ -1055,30 +1065,57 @@ static std::string runSubconverterImplWithRetry(const Request &original,
LOG_LEVEL_WARNING);
Request retry_request = original;
Response retry_response;
std::string retry_body = subconverter_impl(retry_request, retry_response);
RuleConversionStats retry_stats;
std::string retry_body = subconverter_impl(retry_request, retry_response,
stats ? &retry_stats : nullptr);
if (retry_response.status_code < 500) {
if (stats)
*stats = retry_stats;
response = retry_response;
return retry_body;
}
if (stats)
*stats = first_stats;
response = first_response;
return body;
}
} // namespace
static void recordTrackedSubRequest(bool track, const Request &request,
const Response &response,
uint64_t rule_conversions) {
if (!track)
return;
if (response.status_code < 200 || response.status_code >= 300)
return;
statistics::recordSubscriptionConversion(request, rule_conversions);
}
std::string subconverter(RESPONSE_CALLBACK_ARGS) {
if (!shouldCoalesceSubRequest(request))
return subconverter_impl(request, response);
static std::string subconverterEntry(Request &request, Response &response,
bool track) {
if (!shouldCoalesceSubRequest(request)) {
RuleConversionStats stats;
std::string body =
subconverter_impl(request, response, track ? &stats : nullptr);
recordTrackedSubRequest(track, request, response, stats.rules);
return body;
}
std::string key = buildSubRequestKey(request);
if (key.empty())
return subconverter_impl(request, response);
if (key.empty()) {
RuleConversionStats stats;
std::string body =
subconverter_impl(request, response, track ? &stats : nullptr);
recordTrackedSubRequest(track, request, response, stats.rules);
return body;
}
CoalescedResponse cached_result;
if (getCachedSubResponse(key, cached_result)) {
writeLog(0, "/sub 响应微缓存命中。", LOG_LEVEL_DEBUG);
copyCoalescedToResponse(cached_result, response);
recordTrackedSubRequest(track, request, response,
cached_result.rule_conversions);
return cached_result.body;
}
@@ -1104,6 +1141,8 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
if (call->exception)
std::rethrow_exception(call->exception);
copyCoalescedToResponse(call->result, response);
recordTrackedSubRequest(track, request, response,
call->result.rule_conversions);
return call->result.body;
}
@@ -1111,9 +1150,11 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
writeLog(0, "/sub 请求成为同 key 转换 owner。", LOG_LEVEL_DEBUG);
Request original_request = request;
Response owner_response;
std::string body =
runSubconverterImplWithRetry(original_request, owner_response);
CoalescedResponse result = makeCoalescedResult(body, owner_response);
RuleConversionStats stats;
std::string body = runSubconverterImplWithRetry(
original_request, owner_response, track ? &stats : nullptr);
CoalescedResponse result =
makeCoalescedResult(body, owner_response, stats.rules);
response = owner_response;
{
std::lock_guard<std::mutex> lock(call->mutex);
@@ -1126,6 +1167,7 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
}
storeCachedSubResponse(key, result);
call->cv.notify_all();
recordTrackedSubRequest(track, request, response, result.rule_conversions);
return body;
} catch (...) {
{
@@ -1142,7 +1184,18 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
}
}
static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
} // namespace
std::string subconverter(RESPONSE_CALLBACK_ARGS) {
return subconverterEntry(request, response, false);
}
std::string subconverterTracked(RESPONSE_CALLBACK_ARGS) {
return subconverterEntry(request, response, true);
}
static std::string subconverter_impl(Request &request, Response &response,
RuleConversionStats *rule_stats) {
auto &argument = request.argument;
int *status_code = &response.status_code;
@@ -1261,6 +1314,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
lExcludeRemarks = global.excludeRemarks;
std::vector<RulesetContent> lRulesetContent;
extra_settings ext;
ext.rule_stats = rule_stats;
std::string subInfo, dummy;
int interval = !argUpdateInterval.empty()
? to_int(argUpdateInterval, global.updateInterval)

View File

@@ -23,6 +23,7 @@ std::string getProfile(RESPONSE_CALLBACK_ARGS);
std::string getRuleset(RESPONSE_CALLBACK_ARGS);
std::string subconverter(RESPONSE_CALLBACK_ARGS);
std::string subconverterTracked(RESPONSE_CALLBACK_ARGS);
std::string simpleToClashR(RESPONSE_CALLBACK_ARGS);
std::string surgeConfToClash(RESPONSE_CALLBACK_ARGS);

View File

@@ -1,3 +1,4 @@
#include <algorithm>
#include <mutex>
#include <string>
#include <filesystem>
@@ -684,6 +685,21 @@ void readYAMLConf(YAML::Node &node) {
node["advanced"]["coalesce_retry_on_5xx"] >> global.coalesceRetryOn5xx;
node["advanced"]["response_cache_ttl"] >> global.responseCacheTtl;
}
if (node["statistics"].IsDefined()) {
YAML::Node stats = node["statistics"];
stats["enabled"] >> global.statisticsEnabled;
stats["data_dir"] >> global.statisticsDataDir;
stats["flush_interval"] >> global.statisticsFlushInterval;
if (stats["geo"].IsDefined()) {
stats["geo"]["provider"] >> global.statisticsGeoProvider;
if (stats["geo"]["country_headers"].IsSequence()) {
string_array country_headers;
stats["geo"]["country_headers"] >> country_headers;
if (!country_headers.empty())
global.statisticsCountryHeaders = country_headers;
}
}
}
if (node["security"].IsDefined()) {
node["security"]["profile"] >> global.securityProfile;
node["security"]["allow_public_upload"] >> global.allowPublicUpload;
@@ -894,6 +910,20 @@ void readTOMLConf(toml::value &root) {
global.cacheSubscription = global.cacheConfig = global.cacheRuleset = 0;
}
auto section_statistics =
toml::find_or(root, "statistics", toml::value(toml::table()));
find_if_exist(section_statistics, "enabled", global.statisticsEnabled,
"data_dir", global.statisticsDataDir, "flush_interval",
global.statisticsFlushInterval);
auto section_statistics_geo =
toml::find_or(section_statistics, "geo", toml::value(toml::table()));
find_if_exist(section_statistics_geo, "provider",
global.statisticsGeoProvider);
string_array country_headers = toml::find_or<string_array>(
section_statistics_geo, "country_headers", string_array{});
if (!country_headers.empty())
global.statisticsCountryHeaders = country_headers;
auto section_security =
toml::find_or(root, "security", toml::value(toml::table()));
find_if_exist(section_security, "profile", global.securityProfile,
@@ -912,6 +942,13 @@ void readConf() {
eraseElements(global.includeRemarks);
eraseElements(global.customProxyGroups);
eraseElements(global.customRulesets);
global.statisticsEnabled = false;
global.statisticsDataDir = "stats";
global.statisticsFlushInterval = 5;
global.statisticsGeoProvider = "header";
global.statisticsCountryHeaders = {"CF-IPCountry", "X-Geo-Country",
"X-Vercel-IP-Country",
"CloudFront-Viewer-Country"};
try {
std::string prefdata = fileGet(global.prefPath, false);
@@ -1184,6 +1221,25 @@ void readConf() {
ini.get_bool_if_exist("coalesce_retry_on_5xx", global.coalesceRetryOn5xx);
ini.get_int_if_exist("response_cache_ttl", global.responseCacheTtl);
if (ini.section_exist("statistics")) {
ini.enter_section("statistics");
ini.get_bool_if_exist("enabled", global.statisticsEnabled);
ini.get_if_exist("data_dir", global.statisticsDataDir);
ini.get_int_if_exist("flush_interval", global.statisticsFlushInterval);
ini.get_if_exist("geo_provider", global.statisticsGeoProvider);
if (ini.item_exist("country_headers")) {
string_array country_headers = split(ini.get("country_headers"), ",");
for (std::string &header : country_headers)
header = trimWhitespace(header, true, true);
country_headers.erase(
std::remove_if(country_headers.begin(), country_headers.end(),
[](const std::string &value) { return value.empty(); }),
country_headers.end());
if (!country_headers.empty())
global.statisticsCountryHeaders = country_headers;
}
}
if (ini.section_exist("security")) {
ini.enter_section("security");
ini.get_if_exist("profile", global.securityProfile);

View File

@@ -82,6 +82,15 @@ struct Settings {
int responseCacheTtl = 0;
unsigned long long configGeneration = 0;
// opt-in privacy-preserving statistics and dashboard
bool statisticsEnabled = false;
std::string statisticsDataDir = "stats";
int statisticsFlushInterval = 5;
std::string statisticsGeoProvider = "header";
string_array statisticsCountryHeaders = {
"CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country",
"CloudFront-Viewer-Country"};
// limits
size_t maxAllowedRulesets = 64, maxAllowedRules = 32768;
bool scriptCleanContext = false;

438
src/handler/statistics.cpp Normal file
View File

@@ -0,0 +1,438 @@
#include "handler/statistics.h"
#include <algorithm>
#include <array>
#include <cerrno>
#include <cctype>
#include <cstdio>
#include <ctime>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#ifdef _WIN32
#include <direct.h>
#else
#include <sys/stat.h>
#endif
#include <nlohmann/json.hpp>
#include "handler/settings.h"
#include "utils/file.h"
#include "utils/logger.h"
#include "utils/string.h"
namespace {
using json = nlohmann::json;
constexpr size_t kBucketCount = 30 * 24 * 60;
struct Counters {
uint64_t subscription_requests = 0;
uint64_t rule_conversions = 0;
};
struct Bucket {
int64_t minute = 0;
Counters counters;
};
struct CountryCounters {
uint64_t subscription_requests = 0;
uint64_t rule_conversions = 0;
};
struct SnapshotCountry {
std::string code;
CountryCounters counters;
};
struct State {
bool initialized = false;
bool dirty = false;
int64_t started_at = 0;
int64_t last_flush = 0;
Counters startup;
Counters lifetime;
std::array<Bucket, kBucketCount> buckets;
std::map<std::string, CountryCounters> countries;
};
std::mutex g_mutex;
std::unique_ptr<State> g_state;
int64_t nowSeconds() { return static_cast<int64_t>(std::time(nullptr)); }
std::string normalizePath(std::string path) {
for (char &ch : path) {
if (ch == '\\')
ch = '/';
}
while (path.size() > 1 && path.back() == '/')
path.pop_back();
return path;
}
bool pathIsSafe(const std::string &path) {
if (path.empty())
return false;
if (path.find("..") != std::string::npos)
return false;
#ifdef _WIN32
if (path.size() > 1 && path[1] == ':')
return false;
#else
if (!path.empty() && path[0] == '/')
return false;
#endif
return true;
}
bool ensureDirectory(const std::string &raw_path) {
std::string path = normalizePath(raw_path);
if (!pathIsSafe(path))
return false;
std::string current;
size_t pos = 0;
while (pos <= path.size()) {
size_t next = path.find('/', pos);
std::string part =
path.substr(pos, next == std::string::npos ? path.size() - pos
: next - pos);
if (!part.empty()) {
if (!current.empty())
current += '/';
current += part;
#ifdef _WIN32
if (_mkdir(current.c_str()) != 0 && errno != EEXIST)
return false;
#else
if (mkdir(current.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) != 0 &&
errno != EEXIST)
return false;
#endif
}
if (next == std::string::npos)
break;
pos = next + 1;
}
return true;
}
bool writeTextFile(const std::string &path, const std::string &content) {
std::FILE *fp = std::fopen(path.c_str(), "wb");
if (!fp)
return false;
size_t written = std::fwrite(content.c_str(), 1, content.size(), fp);
std::fclose(fp);
return written == content.size();
}
std::string dataFilePath() {
std::string dir = normalizePath(global.statisticsDataDir);
if (dir.empty())
dir = "stats";
return dir + "/statistics.json";
}
bool validCountryCode(const std::string &value) {
if (value == "T1" || value == "XX")
return true;
if (value.size() != 2)
return false;
return std::isalpha(static_cast<unsigned char>(value[0])) &&
std::isalpha(static_cast<unsigned char>(value[1]));
}
std::string normalizeCountryCode(std::string value) {
value = toUpper(trimWhitespace(value, true, true));
if (!validCountryCode(value))
return "ZZ";
return value;
}
std::string countryFromHeaders(const Request &request) {
if (toLower(global.statisticsGeoProvider) == "none")
return "ZZ";
for (const std::string &header : global.statisticsCountryHeaders) {
auto iter = request.headers.find(header);
if (iter == request.headers.end())
continue;
std::string code = normalizeCountryCode(iter->second);
if (code != "ZZ")
return code;
}
return "ZZ";
}
void addCounters(Counters &target, uint64_t requests, uint64_t rules) {
target.subscription_requests += requests;
target.rule_conversions += rules;
}
Counters windowCountersLocked(int64_t now_minute, int minutes) {
Counters result;
if (!g_state)
return result;
int64_t earliest = now_minute - minutes + 1;
for (const Bucket &bucket : g_state->buckets) {
if (bucket.minute >= earliest && bucket.minute <= now_minute) {
addCounters(result, bucket.counters.subscription_requests,
bucket.counters.rule_conversions);
}
}
return result;
}
std::vector<Counters> hourlySeriesLocked(int64_t now_minute, int hours) {
std::vector<Counters> result(static_cast<size_t>(hours));
if (!g_state)
return result;
int64_t current_hour = now_minute / 60;
int64_t first_hour = current_hour - hours + 1;
for (const Bucket &bucket : g_state->buckets) {
if (bucket.minute <= 0)
continue;
int64_t hour = bucket.minute / 60;
if (hour < first_hour || hour > current_hour)
continue;
size_t index = static_cast<size_t>(hour - first_hour);
addCounters(result[index], bucket.counters.subscription_requests,
bucket.counters.rule_conversions);
}
return result;
}
json countersJson(const Counters &counters) {
return json{{"subscription_requests", counters.subscription_requests},
{"rule_conversions", counters.rule_conversions}};
}
void loadLocked() {
std::string content = fileGet(dataFilePath(), false);
if (content.empty())
return;
try {
json root = json::parse(content);
if (root.value("schema", 0) != 1)
return;
auto lifetime = root.value("lifetime", json::object());
g_state->lifetime.subscription_requests =
lifetime.value("subscription_requests", 0ULL);
g_state->lifetime.rule_conversions =
lifetime.value("rule_conversions", 0ULL);
auto countries = root.value("countries", json::object());
for (auto iter = countries.begin(); iter != countries.end(); ++iter) {
std::string code = normalizeCountryCode(iter.key());
CountryCounters counters;
counters.subscription_requests =
iter.value().value("subscription_requests", 0ULL);
counters.rule_conversions = iter.value().value("rule_conversions", 0ULL);
if (counters.subscription_requests || counters.rule_conversions)
g_state->countries[code] = counters;
}
auto buckets = root.value("buckets", json::array());
for (const auto &item : buckets) {
int64_t minute = item.value("minute", 0LL);
if (minute <= 0)
continue;
size_t index = static_cast<size_t>(minute % kBucketCount);
g_state->buckets[index].minute = minute;
g_state->buckets[index].counters.subscription_requests =
item.value("subscription_requests", 0ULL);
g_state->buckets[index].counters.rule_conversions =
item.value("rule_conversions", 0ULL);
}
} catch (const std::exception &e) {
writeLog(0, "统计数据加载失败:" + std::string(e.what()), LOG_LEVEL_WARNING);
}
}
bool flushLocked() {
std::string path = dataFilePath();
std::string dir = normalizePath(global.statisticsDataDir);
if (dir.empty())
dir = "stats";
if (!ensureDirectory(dir)) {
writeLog(0, "无法创建统计数据目录:" + dir, LOG_LEVEL_WARNING);
return false;
}
json root;
root["schema"] = 1;
root["updated_at"] = nowSeconds();
root["lifetime"] = countersJson(g_state->lifetime);
json countries = json::object();
for (const auto &entry : g_state->countries) {
countries[entry.first] = {
{"subscription_requests", entry.second.subscription_requests},
{"rule_conversions", entry.second.rule_conversions}};
}
root["countries"] = countries;
json buckets = json::array();
for (const Bucket &bucket : g_state->buckets) {
if (bucket.minute <= 0)
continue;
if (!bucket.counters.subscription_requests &&
!bucket.counters.rule_conversions)
continue;
buckets.push_back({{"minute", bucket.minute},
{"subscription_requests",
bucket.counters.subscription_requests},
{"rule_conversions",
bucket.counters.rule_conversions}});
}
root["buckets"] = buckets;
std::string tmp = path + ".tmp";
if (!writeTextFile(tmp, root.dump()))
return false;
std::remove(path.c_str());
if (std::rename(tmp.c_str(), path.c_str()) != 0) {
std::remove(tmp.c_str());
return false;
}
g_state->dirty = false;
g_state->last_flush = nowSeconds();
return true;
}
} // namespace
namespace statistics {
void initialize() {
if (!global.statisticsEnabled)
return;
std::lock_guard<std::mutex> lock(g_mutex);
if (g_state && g_state->initialized)
return;
g_state.reset(new State());
g_state->initialized = true;
g_state->started_at = nowSeconds();
loadLocked();
writeLog(0, "统计数据已启用,数据目录:" + global.statisticsDataDir,
LOG_LEVEL_INFO);
}
void shutdown() {
if (!global.statisticsEnabled)
return;
std::lock_guard<std::mutex> lock(g_mutex);
if (g_state && g_state->initialized && g_state->dirty)
flushLocked();
}
bool isEnabled() { return global.statisticsEnabled; }
void recordSubscriptionConversion(const Request &request,
uint64_t rule_conversions) {
if (!global.statisticsEnabled || request.method != "GET")
return;
int64_t now = nowSeconds();
int64_t minute = now / 60;
std::string country = countryFromHeaders(request);
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_state || !g_state->initialized)
return;
addCounters(g_state->startup, 1, rule_conversions);
addCounters(g_state->lifetime, 1, rule_conversions);
size_t index = static_cast<size_t>(minute % kBucketCount);
if (g_state->buckets[index].minute != minute) {
g_state->buckets[index].minute = minute;
g_state->buckets[index].counters = Counters();
}
addCounters(g_state->buckets[index].counters, 1, rule_conversions);
CountryCounters &country_counters = g_state->countries[country];
country_counters.subscription_requests++;
country_counters.rule_conversions += rule_conversions;
g_state->dirty = true;
int flush_interval = std::max(1, global.statisticsFlushInterval);
if (now - g_state->last_flush >= flush_interval)
flushLocked();
}
std::string dashboardData(RESPONSE_CALLBACK_ARGS) {
response.headers["Cache-Control"] = "no-store";
response.headers["X-Robots-Tag"] =
"noindex, nofollow, noarchive, nosnippet, noimageindex";
response.content_type = "application/json; charset=utf-8";
std::lock_guard<std::mutex> lock(g_mutex);
int64_t now = nowSeconds();
int64_t now_minute = now / 60;
json root;
root["enabled"] = global.statisticsEnabled;
root["generated_at"] = now;
root["started_at"] = g_state ? g_state->started_at : 0;
root["windows"] = {
{"startup", countersJson(g_state ? g_state->startup : Counters())},
{"hour", countersJson(windowCountersLocked(now_minute, 60))},
{"day", countersJson(windowCountersLocked(now_minute, 24 * 60))},
{"seven_days", countersJson(windowCountersLocked(now_minute, 7 * 24 * 60))},
{"thirty_days",
countersJson(windowCountersLocked(now_minute, 30 * 24 * 60))},
{"lifetime", countersJson(g_state ? g_state->lifetime : Counters())}};
std::vector<SnapshotCountry> country_list;
country_list.reserve(g_state ? g_state->countries.size() : 0);
if (g_state) {
for (const auto &entry : g_state->countries)
country_list.push_back({entry.first, entry.second});
}
std::sort(country_list.begin(), country_list.end(),
[](const SnapshotCountry &lhs, const SnapshotCountry &rhs) {
if (lhs.counters.subscription_requests !=
rhs.counters.subscription_requests)
return lhs.counters.subscription_requests >
rhs.counters.subscription_requests;
return lhs.code < rhs.code;
});
json countries = json::array();
for (const SnapshotCountry &country : country_list) {
countries.push_back({{"code", country.code},
{"subscription_requests",
country.counters.subscription_requests},
{"rule_conversions",
country.counters.rule_conversions}});
}
root["countries"] = countries;
json series = json::array();
std::vector<Counters> hourly = hourlySeriesLocked(now_minute, 24);
int64_t current_hour = now_minute / 60;
int64_t first_hour = current_hour - 24 + 1;
for (size_t i = 0; i < hourly.size(); ++i) {
int64_t hour = first_hour + static_cast<int64_t>(i);
series.push_back({{"time", hour * 3600},
{"subscription_requests",
hourly[i].subscription_requests},
{"rule_conversions", hourly[i].rule_conversions}});
}
root["series"] = series;
return root.dump();
}
} // namespace statistics

22
src/handler/statistics.h Normal file
View File

@@ -0,0 +1,22 @@
#ifndef STATISTICS_H_INCLUDED
#define STATISTICS_H_INCLUDED
#include <cstdint>
#include <string>
#include "server/webserver.h"
namespace statistics {
void initialize();
void shutdown();
bool isEnabled();
void recordSubscriptionConversion(const Request &request,
uint64_t rule_conversions);
std::string dashboardData(RESPONSE_CALLBACK_ARGS);
} // namespace statistics
#endif // STATISTICS_H_INCLUDED

View File

@@ -7,9 +7,11 @@
#include <sys/types.h>
#include "config/ruleset.h"
#include "handler/dashboard_page.h"
#include "handler/inspect_page.h"
#include "handler/interfaces.h"
#include "handler/settings.h"
#include "handler/statistics.h"
#include "handler/version_page.h"
#include "handler/webget.h"
#include "script/cron.h"
@@ -145,6 +147,7 @@ int main(int argc, char *argv[]) {
SetConsoleTitle("SubConverter-Extended " VERSION);
readConf();
statistics::initialize();
// vfs::vfs_read("vfs.ini");
if (!global.updateRulesetOnRequest)
refreshRulesets(global.customRulesets, global.rulesetsContent);
@@ -197,6 +200,13 @@ int main(int argc, char *argv[]) {
version_page::page);
webServer.append_response("GET", "/inspect", "text/html; charset=utf-8",
inspect_page::page);
if (global.statisticsEnabled) {
webServer.append_response("GET", "/dashboard", "text/html; charset=utf-8",
dashboard_page::page);
webServer.append_response("GET", "/dashboard/data",
"application/json; charset=utf-8",
statistics::dashboardData);
}
webServer.append_response(
"GET", "/robots.txt", "text/plain; charset=utf-8",
@@ -204,6 +214,7 @@ int main(int argc, char *argv[]) {
return "User-agent: *\n"
"Disallow: /version\n"
"Disallow: /inspect\n"
"Disallow: /dashboard\n"
"Disallow: /v\n";
});
@@ -267,9 +278,12 @@ int main(int argc, char *argv[]) {
*/
webServer.append_response("GET", "/sub", "text/plain;charset=utf-8",
subconverter);
global.statisticsEnabled ? subconverterTracked
: subconverter);
webServer.append_response("HEAD", "/sub", "text/plain", subconverter);
webServer.append_response("HEAD", "/sub", "text/plain",
global.statisticsEnabled ? subconverterTracked
: subconverter);
/*
webServer.append_response("GET", "/sub2clashr", "text/plain;charset=utf-8",
@@ -331,6 +345,7 @@ int main(int argc, char *argv[]) {
std::to_string(global.listenPort),
LOG_LEVEL_INFO);
int ret = webServer.start_web_server_multi(&args);
statistics::shutdown();
#ifdef _WIN32
WSACleanup();

View File

@@ -14,6 +14,8 @@ struct Request
{
std::string method;
std::string url;
std::string remote_addr;
int remote_port = 0;
string_multimap argument;
string_icase_map headers;
std::string postdata;

View File

@@ -40,6 +40,8 @@ static httplib::Server::Handler makeHandler(const responseRoute &rr) {
Response resp;
req.method = request.method;
req.url = request.path;
req.remote_addr = request.remote_addr;
req.remote_port = request.remote_port;
for (auto &h : request.headers) {
if (startsWith(h.first, "LOCAL_") || startsWith(h.first, "REMOTE_") ||
is_request_header_blacklisted(h.first)) {