diff --git a/CMakeLists.txt b/CMakeLists.txt index 1dc870f..b208202 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/base/pref.example.ini b/base/pref.example.ini index beca2a5..829823d 100644 --- a/base/pref.example.ini +++ b/base/pref.example.ini @@ -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 diff --git a/base/pref.example.toml b/base/pref.example.toml index 76d3f25..4bc741c 100644 --- a/base/pref.example.toml +++ b/base/pref.example.toml @@ -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 diff --git a/base/pref.example.yml b/base/pref.example.yml index 99b3a39..3ad20ba 100644 --- a/base/pref.example.yml +++ b/base/pref.example.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 889acaf..e0c2f0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/generator/config/ruleconvert.cpp b/src/generator/config/ruleconvert.cpp index 82c1659..fb993d2 100644 --- a/src/generator/config/ruleconvert.cpp +++ b/src/generator/config/ruleconvert.cpp @@ -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 &ruleset_content_array, bool overwrite_original_rules, bool new_field_name) +void rulesetToClash(YAML::Node &base_rule, std::vector &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 &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 &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 &ruleset_ base_rule[field_name] = rules; } -std::string rulesetToClashStr(YAML::Node &base_rule, std::vector &ruleset_content_array, bool overwrite_original_rules, bool new_field_name) +std::string rulesetToClashStr(YAML::Node &base_rule, std::vector &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 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 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 &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string &remote_path_prefix) +void rulesetToSurge(INIReader &base_rule, std::vector &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 &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 &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 &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 &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 &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 &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 &ruleset_c } allRules.emplace_back(strLine); total_rules++; + if(stats) + stats->add(); } } } @@ -527,18 +554,18 @@ static rapidjson::Value transformRuleToSingBox(std::vector &ar return rule_obj; } -static void appendSingBoxRule(std::vector &args, rapidjson::Value &rules, const std::string& rule, rapidjson::MemoryPoolAllocator<>& allocator) +static bool appendSingBoxRule(std::vector &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 &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 &ruleset_content_array, bool overwrite_original_rules) +void rulesetToSingBox(rapidjson::Document &base_rule, std::vector &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::vectoradd(); continue; } retrieved_rules = convertRuleset(retrieved_rules, x.rule_type); @@ -620,7 +650,12 @@ void rulesetToSingBox(rapidjson::Document &base_rule, std::vectoradd(); + } } if (rule.ObjectEmpty()) continue; rule.AddMember("outbound", rapidjson::Value(rule_group.c_str(), allocator), allocator); diff --git a/src/generator/config/ruleconvert.h b/src/generator/config/ruleconvert.h index ad17b88..5e49ec2 100644 --- a/src/generator/config/ruleconvert.h +++ b/src/generator/config/ruleconvert.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -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 &ruleset_content_array, bool overwrite_original_rules, bool new_field_name); -std::string rulesetToClashStr(YAML::Node &base_rule, std::vector &ruleset_content_array, bool overwrite_original_rules, bool new_field_name); -void rulesetToSurge(INIReader &base_rule, std::vector &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string& remote_path_prefix); -void rulesetToSingBox(rapidjson::Document &base_rule, std::vector &ruleset_content_array, bool overwrite_original_rules); +void rulesetToClash(YAML::Node &base_rule, std::vector &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr); +std::string rulesetToClashStr(YAML::Node &base_rule, std::vector &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr); +void rulesetToSurge(INIReader &base_rule, std::vector &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 &ruleset_content_array, bool overwrite_original_rules, RuleConversionStats *stats = nullptr); #endif // RULECONVERT_H_INCLUDED diff --git a/src/generator/config/subexport.cpp b/src/generator/config/subexport.cpp index e86c427..94a072b 100644 --- a/src/generator/config/subexport.cpp +++ b/src/generator/config/subexport.cpp @@ -1207,7 +1207,7 @@ std::string proxyToClash(std::vector &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 &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 &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 &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 &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 &nodes, @@ -2482,7 +2484,7 @@ void proxyToQuanX(std::vector &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 &nodes, std::string &group, @@ -2745,7 +2747,7 @@ void proxyToMellow(std::vector &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 &nodes, const std::string &base_conf, @@ -3049,7 +3051,7 @@ std::string proxyToLoon(std::vector &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 &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(); } diff --git a/src/generator/config/subexport.h b/src/generator/config/subexport.h index 5d1ea76..d62210a 100644 --- a/src/generator/config/subexport.h +++ b/src/generator/config/subexport.h @@ -58,6 +58,7 @@ struct extra_settings { bool provider_proxy_direct = true; // proxy-provider 默认使用 DIRECT 更新 std::vector providers; // provider 列表 bool authorized = false; + RuleConversionStats *rule_stats = nullptr; extra_settings() = default; extra_settings(const extra_settings &) = delete; diff --git a/src/generator/template/templates.cpp b/src/generator/template/templates.cpp index 8540c67..5f2b621 100644 --- a/src/generator/template/templates.cpp +++ b/src/generator/template/templates.cpp @@ -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 &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 &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 &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 &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 &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 &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 &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 &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"); diff --git a/src/generator/template/templates.h b/src/generator/template/templates.h index b0edaa3..a355245 100644 --- a/src/generator/template/templates.h +++ b/src/generator/template/templates.h @@ -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 &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 &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 diff --git a/src/handler/dashboard_page.cpp b/src/handler/dashboard_page.cpp new file mode 100644 index 0000000..2002d15 --- /dev/null +++ b/src/handler/dashboard_page.cpp @@ -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( + + + + + + + SubConverter-Extended Dashboard + + + + + + + + + + +
+
+
+ + + SubConverter-Extended + +
+

Dashboard

+
+ Runtime conversion statistics + 运行期转换统计 +
+
+
+
+ + +
+
+ +
+
+
+
+
+

Country Distribution国家分布

+ - +
+
+ +
+
+
+
+
+

Last 24 Hours最近 24 小时

+ - +
+
+
+ + + + + + + + + +
Country国家Requests请求Rules规则
+
+
+
+
+ + + + + +)html"; +} + +} // namespace dashboard_page diff --git a/src/handler/dashboard_page.h b/src/handler/dashboard_page.h new file mode 100644 index 0000000..ea05f9a --- /dev/null +++ b/src/handler/dashboard_page.h @@ -0,0 +1,14 @@ +#ifndef DASHBOARD_PAGE_H_INCLUDED +#define DASHBOARD_PAGE_H_INCLUDED + +#include + +#include "server/webserver.h" + +namespace dashboard_page { + +std::string page(Request &request, Response &response); + +} // namespace dashboard_page + +#endif // DASHBOARD_PAGE_H_INCLUDED diff --git a/src/handler/interfaces.cpp b/src/handler/interfaces.cpp index 407d587..2760a92 100644 --- a/src/handler/interfaces.cpp +++ b/src/handler/interfaces.cpp @@ -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 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 lRulesetContent; extra_settings ext; + ext.rule_stats = rule_stats; std::string subInfo, dummy; int interval = !argUpdateInterval.empty() ? to_int(argUpdateInterval, global.updateInterval) diff --git a/src/handler/interfaces.h b/src/handler/interfaces.h index fa662ae..7bcf3bf 100644 --- a/src/handler/interfaces.h +++ b/src/handler/interfaces.h @@ -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); diff --git a/src/handler/settings.cpp b/src/handler/settings.cpp index bc400df..7b6fbec 100644 --- a/src/handler/settings.cpp +++ b/src/handler/settings.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -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( + 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); diff --git a/src/handler/settings.h b/src/handler/settings.h index aa42110..e20df78 100644 --- a/src/handler/settings.h +++ b/src/handler/settings.h @@ -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; diff --git a/src/handler/statistics.cpp b/src/handler/statistics.cpp new file mode 100644 index 0000000..29b7a11 --- /dev/null +++ b/src/handler/statistics.cpp @@ -0,0 +1,438 @@ +#include "handler/statistics.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#include + +#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 buckets; + std::map countries; +}; + +std::mutex g_mutex; +std::unique_ptr g_state; + +int64_t nowSeconds() { return static_cast(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(value[0])) && + std::isalpha(static_cast(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 hourlySeriesLocked(int64_t now_minute, int hours) { + std::vector result(static_cast(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(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(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 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 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 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(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 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 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 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(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 diff --git a/src/handler/statistics.h b/src/handler/statistics.h new file mode 100644 index 0000000..e1459dd --- /dev/null +++ b/src/handler/statistics.h @@ -0,0 +1,22 @@ +#ifndef STATISTICS_H_INCLUDED +#define STATISTICS_H_INCLUDED + +#include +#include + +#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 diff --git a/src/main.cpp b/src/main.cpp index fcdd840..77353b5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,9 +7,11 @@ #include #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(); diff --git a/src/server/webserver.h b/src/server/webserver.h index d516f75..e1a6b76 100644 --- a/src/server/webserver.h +++ b/src/server/webserver.h @@ -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; diff --git a/src/server/webserver_httplib.cpp b/src/server/webserver_httplib.cpp index 0fff9af..a9be34d 100644 --- a/src/server/webserver_httplib.cpp +++ b/src/server/webserver_httplib.cpp @@ -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)) {