Update On Fri Sep 6 20:35:08 CEST 2024

This commit is contained in:
github-action[bot]
2024-09-06 20:35:09 +02:00
parent d6a419be50
commit 1a9fdafeaa
118 changed files with 7507 additions and 4309 deletions

1
.github/update.log vendored
View File

@@ -755,3 +755,4 @@ Update On Mon Sep 2 20:34:00 CEST 2024
Update On Tue Sep 3 20:32:43 CEST 2024
Update On Wed Sep 4 20:31:01 CEST 2024
Update On Thu Sep 5 20:35:23 CEST 2024
Update On Fri Sep 6 20:34:58 CEST 2024

View File

@@ -1284,6 +1284,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"port_scanner",
"pretty_assertions",
"rand 0.8.5",
"redb",
"regex",
@@ -1963,6 +1964,12 @@ dependencies = [
"syn 2.0.77",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.9.0"
@@ -5125,9 +5132,9 @@ checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f"
[[package]]
name = "oxc_allocator"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b70e00e3c62b26ccefc5c5942b365091ebc398b4493625d34d54fabe9106cb"
checksum = "8f922944b51ca85c0acf47c37726a1e9475e5dd9f36c2ea89d1057f5c68f91ff"
dependencies = [
"allocator-api2",
"bumpalo",
@@ -5135,23 +5142,24 @@ dependencies = [
[[package]]
name = "oxc_ast"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65eda6aacccb2922fe506bff183930df82bf7a7217ef76480f82c35813a37a91"
checksum = "4385ef64890edde1135e5431fbe397cdc8f38bf7341d7e23429b5de09dd03897"
dependencies = [
"bitflags 2.6.0",
"num-bigint",
"oxc_allocator",
"oxc_ast_macros",
"oxc_regular_expression",
"oxc_span",
"oxc_syntax",
]
[[package]]
name = "oxc_ast_macros"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590d563fddfb8f75bcc2e8c34a6fb6d96bcce30e716b82ccb46c0a8600bc5cd2"
checksum = "807868208f9a594a88f6714dae60bc8bed4ccb87a5d3fd33ed946f6fc8a216de"
dependencies = [
"proc-macro2",
"quote",
@@ -5160,9 +5168,9 @@ dependencies = [
[[package]]
name = "oxc_diagnostics"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b23332f6e1781ec5d17c8c76756564331acf45bbed9a80cc07797ab6b29d24"
checksum = "bb283f8d9f7926c5ec4db85a65908ad72a3783110cc14771dbdec5ac81ad5d79"
dependencies = [
"miette",
"owo-colors",
@@ -5172,15 +5180,15 @@ dependencies = [
[[package]]
name = "oxc_index"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "582f984479fb15e680df6f42499e0c5d1aee8f7e38fd3716851a882357c4fce0"
checksum = "b15c56c7fe9c3d99df968c5d0b3129eb373228c09915e22fc91d24e80c262e0d"
[[package]]
name = "oxc_parser"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ecc8a633c442f6f828159e9bb326927ee8b13bbcf871c1bdd45223cea5d901d"
checksum = "e1ac96c09e7d0a33f25bfac70632eb3d8196e52b8e276ba5661c71da36f3d1ee"
dependencies = [
"assert-unchecked",
"bitflags 2.6.0",
@@ -5199,11 +5207,12 @@ dependencies = [
[[package]]
name = "oxc_regular_expression"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d74222e38ec5fa7b4065b899282ece8689fb804ce0daeb0a2e064b3215bebbc4"
checksum = "0d98c72fa996ba40322be6bd6c00e427036d9ffacbafad9347f08401e3553266"
dependencies = [
"oxc_allocator",
"oxc_ast_macros",
"oxc_diagnostics",
"oxc_span",
"phf 0.11.2",
@@ -5213,9 +5222,9 @@ dependencies = [
[[package]]
name = "oxc_span"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d6f98552a864d9c68173842bdffbd30e4ca0746778c308ebd839e70a1571f5"
checksum = "984cf0c05a0da6c557d7c7a600120841910d382008b92ac0ff44af400ba79e29"
dependencies = [
"compact_str",
"miette",
@@ -5225,10 +5234,11 @@ dependencies = [
[[package]]
name = "oxc_syntax"
version = "0.26.0"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ce885718dd2744dd4e6500e765ea7fe4281d17ac0ec23c499a2ad747dbe1699"
checksum = "0343ef487214dbf9f296e155caeaab0d20a5da577ebd54a80f4fa4ce6a462f5a"
dependencies = [
"assert-unchecked",
"bitflags 2.6.0",
"dashmap 6.0.1",
"nonmax",
@@ -5680,6 +5690,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty_assertions"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.22"
@@ -9612,6 +9632,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "yoke"
version = "0.7.4"

View File

@@ -23,6 +23,7 @@ nyanpasu-ipc = { git = "https://github.com/LibNyanpasu/nyanpasu-service.git", fe
] }
nyanpasu-utils = { git = "https://github.com/LibNyanpasu/nyanpasu-utils.git" }
boa_utils = { path = "../boa_utils" } # should be removed when boa support console customize
pretty_assertions = "1.4.0"
which = "6"
anyhow = "1.0"
dirs = "5.0.1"
@@ -123,11 +124,11 @@ os_pipe = "1.2.0"
whoami = "1.5.1"
atomic_enum = "0.3.0"
boa_engine.workspace = true
oxc_parser = "0.26"
oxc_allocator = "0.26"
oxc_span = "0.26"
oxc_ast = "0.26"
oxc_syntax = "0.26"
oxc_parser = "0.27"
oxc_allocator = "0.27"
oxc_span = "0.27"
oxc_ast = "0.27"
oxc_syntax = "0.27"
mlua = { version = "0.9", features = [
"lua54",
"async",

View File

@@ -1,5 +1,6 @@
use super::{runner::ProcessOutput, Logs, LogsExt};
use mlua::LuaSerdeExt;
use serde::de::DeserializeOwned;
use serde_yaml::{Mapping, Value};
use tracing_attributes::instrument;
@@ -58,6 +59,161 @@ fn merge_sequence(target: &mut Value, to_merge: &Value, append: bool) {
}
}
fn run_expr<T: DeserializeOwned>(logs: &mut Logs, item: &Value, expr: &str) -> Option<T> {
let lua_runtime = match super::script::create_lua_context() {
Ok(lua) => lua,
Err(e) => {
logs.error(e.to_string());
return None;
}
};
let item = match lua_runtime.to_value(item) {
Ok(v) => v,
Err(e) => {
logs.error(format!("failed to convert item to lua value: {:#?}", e));
return None;
}
};
if let Err(e) = lua_runtime.globals().set("item", item) {
logs.error(e.to_string());
return None;
}
let res = lua_runtime.load(expr).eval::<mlua::Value>();
match res {
Ok(v) => {
if let Ok(v) = lua_runtime.from_value(v) {
Some(v)
} else {
logs.error("failed to convert lua value to serde value");
None
}
}
Err(e) => {
logs.error(format!("failed to run expr: {:#?}", e));
None
}
}
}
fn do_filter(logs: &mut Logs, config: &mut Value, field_str: &str, filter: &Value) {
let field = match find_field(config, field_str) {
Some(field) if !field.is_sequence() => {
logs.warn(format!("field is not sequence: {:#?}", field_str));
return;
}
Some(field) => field,
None => {
logs.warn(format!("field not found: {:#?}", field_str));
return;
}
};
match filter {
Value::Sequence(filters) => {
for filter in filters {
do_filter(logs, config, field_str, filter);
}
}
Value::String(filter) => {
let list = field.as_sequence_mut().unwrap();
list.retain(|item| run_expr(logs, item, filter).unwrap_or(false));
}
Value::Mapping(filter)
if filter.get("when").is_some_and(|v| v.is_string())
&& filter.get("expr").is_some_and(|v| v.is_string()) =>
{
let when = filter.get("when").unwrap().as_str().unwrap();
let expr = filter.get("expr").unwrap().as_str().unwrap();
let list = field.as_sequence_mut().unwrap();
list.iter_mut().for_each(|item| {
let r#match = run_expr(logs, item, when);
if r#match.unwrap_or(false) {
let res: Option<Value> = run_expr(logs, item, expr);
if let Some(res) = res {
*item = res;
}
}
});
}
Value::Mapping(filter)
if filter.get("when").is_some_and(|v| v.is_string())
&& filter.contains_key("override") =>
{
let when = filter.get("when").unwrap().as_str().unwrap();
let r#override = filter.get("override").unwrap();
let list = field.as_sequence_mut().unwrap();
list.iter_mut().for_each(|item| {
let r#match = run_expr(logs, item, when);
if r#match.unwrap_or(false) {
*item = r#override.clone();
}
});
}
Value::Mapping(filter)
if filter.get("when").is_some_and(|v| v.is_string())
&& filter.get("merge").is_some_and(|v| v.is_mapping()) =>
{
let when = filter.get("when").unwrap().as_str().unwrap();
let merge = filter.get("merge").unwrap().as_mapping().unwrap();
let list = field.as_sequence_mut().unwrap();
list.iter_mut().for_each(|item| {
let r#match = run_expr(logs, item, when);
if r#match.unwrap_or(false) {
for (key, value) in merge.iter() {
override_recursive(item.as_mapping_mut().unwrap(), key, value.clone());
}
}
});
}
Value::Mapping(filter)
if filter.get("when").is_some_and(|v| v.is_string())
&& filter.get("remove").is_some_and(|v| v.is_sequence()) =>
{
let when = filter.get("when").unwrap().as_str().unwrap();
let remove = filter.get("remove").unwrap().as_sequence().unwrap();
let list = field.as_sequence_mut().unwrap();
list.iter_mut().for_each(|item| {
let r#match = run_expr(logs, item, when);
if r#match.unwrap_or(false) {
remove.iter().for_each(|key| {
if key.is_string() && item.is_mapping() {
let key_str = key.as_str().unwrap();
// 对 key_str 做一下处理,跳过最后一个元素
let mut keys = key_str.split('.').collect::<Vec<_>>();
let last_key = if keys.len() > 1 {
keys.pop().unwrap()
} else {
key_str
};
let key_str = keys.join(".");
if let Some(field) = find_field(item, &key_str) {
field.as_mapping_mut().unwrap().remove(last_key);
}
} else {
match item {
Value::Sequence(list) if key.is_i64() => {
let index = key.as_i64().unwrap() as usize;
if index < list.len() {
list.remove(index);
}
}
_ => {
logs.warn(format!("invalid key: {:#?}", key));
}
}
}
});
}
});
}
_ => {
logs.warn(format!("invalid filter: {:#?}", filter));
}
}
}
#[instrument(skip(merge, config))]
pub fn use_merge(merge: Mapping, mut config: Mapping) -> ProcessOutput {
tracing::trace!("original config: {:#?}", config);
@@ -124,41 +280,7 @@ pub fn use_merge(merge: Mapping, mut config: Mapping) -> ProcessOutput {
}
key_str if key_str.starts_with("filter__") => {
let key_str = key_str.replace("filter__", "");
if !value.is_string() {
logs.warn(format!("filter value is not string: {:#?}", key_str));
continue;
}
let field = find_field(&mut map, &key_str);
match field {
Some(field) => {
if !field.is_sequence() {
logs.warn(format!("field is not sequence: {:#?}", key_str));
continue;
}
let filter = value.as_str().unwrap_or_default();
let lua = match super::script::create_lua_context() {
Ok(lua) => lua,
Err(e) => {
logs.error(e.to_string());
continue;
}
};
let list = field.as_sequence_mut().unwrap();
// apply filter to each item
list.retain(|item| {
let item = lua.to_value(item).unwrap();
if let Err(e) = lua.globals().set("item", item) {
logs.error(e.to_string());
return false;
}
lua.load(filter).eval::<bool>().unwrap_or(false)
});
}
None => {
logs.warn(format!("field not found: {:#?}", key_str));
}
}
do_filter(&mut logs, &mut map, &key_str, value);
continue;
}
_ => {
@@ -172,6 +294,7 @@ pub fn use_merge(merge: Mapping, mut config: Mapping) -> ProcessOutput {
}
mod tests {
use pretty_assertions::{assert_eq, assert_ne};
#[test]
fn test_find_field() {
let config = r"
@@ -327,10 +450,10 @@ mod tests {
}
#[test]
fn test_filter() {
fn test_filter_string() {
let merge = r"
filter__proxies: |
item.type == 'ss' or item.type == 'hysteria2'
type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2')
filter__wow: |
item == 'wow'
";
@@ -423,6 +546,156 @@ mod tests {
assert_eq!(result.unwrap(), expected);
}
#[test]
fn test_filter_when_and_expr() {
let merge = r"
filter__proxies:
- when: |
type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2')
expr: |
item
filter__proxy-groups:
- when: |
item.name == 'Spotify'
expr: |
item.icon = 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png'
return item
";
let config = r#"proxy-groups:
- name: Spotify
type: select
proxies:
- Proxies
- DIRECT
- HK
- JP
- SG
- TW
- US
- name: Steam
type: select
proxies:
- Proxies
- DIRECT
- HK
- JP
- SG
- TW
- US
- name: Telegram
type: select
proxies:
- Proxies
- HK
- JP
- SG
- TW
- US"#;
let expected = r#"proxy-groups:
- name: Spotify
icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png
type: select
proxies:
- Proxies
- DIRECT
- HK
- JP
- SG
- TW
- US
- name: Steam
type: select
proxies:
- Proxies
- DIRECT
- HK
- JP
- SG
- TW
- US
- name: Telegram
type: select
proxies:
- Proxies
- HK
- JP
- SG
- TW
- US"#;
let merge = serde_yaml::from_str::<super::Mapping>(merge).unwrap();
let config = serde_yaml::from_str::<super::Mapping>(config).unwrap();
let (result, logs) = super::use_merge(merge, config);
eprintln!("{:#?}\n\n{:#?}", logs, result);
assert_eq!(logs.len(), 1);
let expected = serde_yaml::from_str::<super::Mapping>(expected).unwrap();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn test_filter_when_and_override() {
let merge = r"
filter__proxies:
- when: |
type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2')
override: OVERRIDDEN
";
let config = r#"
proxies:
- 123
- 555
- name: "hysteria2"
type: hysteria2
server: server.com
port: 443
ports: 443-8443
password: yourpassword
up: "30 Mbps"
down: "200 Mbps"
obfs: salamander # 默认为空如果填写则开启obfs目前仅支持salamander
obfs-password: yourpassword
sni: server.com
skip-cert-verify: false
fingerprint: xxxx
alpn:
- h3
ca: "./my.ca"
ca-str: "xyz"
- name: "hysteria2"
type: ss
server: server.com
port: 443
ports: 443-8443
password: yourpassword
up: "30 Mbps"
down: "200 Mbps"
obfs: salamander # 默认为空如果填写则开启obfs目前仅支持salamander
obfs-password: yourpassword
sni: server.com
skip-cert-verify: false
fingerprint: xxxx
alpn:
- h3
ca: "./my.ca"
ca-str: "xyz"
"#;
let expected = r#"
proxies:
- 123
- 555
- OVERRIDDEN
- OVERRIDDEN
"#;
let merge = serde_yaml::from_str::<super::Mapping>(merge).unwrap();
let config = serde_yaml::from_str::<super::Mapping>(config).unwrap();
let (result, logs) = super::use_merge(merge, config);
eprintln!("{:#?}\n\n{:#?}", logs, result);
assert_eq!(logs.len(), 0);
let expected = serde_yaml::from_str::<super::Mapping>(expected).unwrap();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn test_override_recursive() {
let merge = r"

View File

@@ -182,13 +182,14 @@ impl Runner for JSRunner {
let boa_runner = wrap_result!(BoaRunner::try_new(), take_logs(logs));
wrap_result!(boa_runner.setup_console(logger), take_logs(logs));
let config = wrap_result!(
simd_json::serde::to_string_pretty(&mapping)
simd_json::serde::to_string(&mapping)
.map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, e) }),
take_logs(logs)
);
let config = simd_json::to_string(&config).unwrap(); // escape the string
let execute_module = format!(
r#"import process from "./{hash}.mjs";
let config = JSON.parse(`{config}`);
let config = JSON.parse({config});
export let result = JSON.stringify(await process(config));
"#
);

View File

@@ -26,7 +26,7 @@
"allotment": "1.20.2",
"country-code-emoji": "2.3.0",
"dayjs": "1.11.13",
"framer-motion": "12.0.0-alpha.0",
"framer-motion": "12.0.0-alpha.1",
"i18next": "23.14.0",
"jotai": "2.9.3",
"material-react-table": "2.13.3",
@@ -49,12 +49,15 @@
"@csstools/normalize.css": "12.1.1",
"@emotion/babel-plugin": "11.12.0",
"@emotion/react": "11.13.3",
"@iconify/json": "2.2.244",
"@iconify/json": "2.2.245",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.3.1",
"@vitejs/plugin-react-swc": "3.7.0",
"clsx": "2.1.1",
"meta-json-schema": "github:libnyanpasu/meta-json-schema#main",
"monaco-yaml": "5.2.2",
"nanoid": "5.0.7",
"sass": "1.78.0",
"shiki": "1.16.2",
"tailwindcss-textshadow": "2.1.3",

View File

@@ -5,6 +5,7 @@ import { classNames } from "@/utils";
import { useNyanpasu } from "@nyanpasu/interface";
import styles from "./animated-logo.module.scss";
// @ts-expect-error framer-motion types is wrong
const Logo = motion(LogoSvg);
const transition = {
@@ -45,21 +46,21 @@ const motionVariants: { [name: string]: Variants } = {
export default function AnimatedLogo({
className,
style,
disbaleMotion,
disableMotion,
}: {
className?: string;
style?: CSSProperties;
disbaleMotion?: boolean;
disableMotion?: boolean;
}) {
const { nyanpasuConfig } = useNyanpasu();
const disbale = disbaleMotion ?? nyanpasuConfig?.lighten_animation_effects;
const disable = disableMotion ?? nyanpasuConfig?.lighten_animation_effects;
return (
<AnimatePresence initial={false}>
<Logo
className={classNames(styles.LogoSchema, className)}
variants={motionVariants[disbale ? "none" : "default"]}
variants={motionVariants[disable ? "none" : "default"]}
style={style}
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}

View File

@@ -15,7 +15,6 @@ import {
useForm,
} from "react-hook-form-mui";
import { useTranslation } from "react-i18next";
import { classNames } from "@/utils";
import { Divider, InputAdornment } from "@mui/material";
import { Profile, useClash } from "@nyanpasu/interface";
import { BaseDialog } from "@nyanpasu/ui";
@@ -300,6 +299,8 @@ export const ProfileDialog = ({
<ProfileMonacoView
className="w-full"
ref={profileMonacoViewRef}
readonly={isRemote}
schemaType="clash"
open={open}
value={editor.value}
language={editor.language}

View File

@@ -1,6 +1,8 @@
import { useUpdateEffect } from "ahooks";
import { useAtomValue } from "jotai";
import { nanoid } from "nanoid";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { OS } from "@/consts";
import { monaco } from "@/services/monaco";
import { themeMode } from "@/store";
@@ -9,6 +11,8 @@ export interface ProfileMonacoViewProps {
value?: string;
language?: string;
className?: string;
readonly?: boolean;
schemaType?: "clash" | "merge";
}
export interface ProfileMonacoViewRef {
@@ -16,33 +20,56 @@ export interface ProfileMonacoViewRef {
}
export const ProfileMonacoView = forwardRef(function ProfileMonacoView(
{ open, value, language, className }: ProfileMonacoViewProps,
{
open,
value,
language,
readonly = false,
schemaType,
className,
}: ProfileMonacoViewProps,
ref,
) {
const mode = useAtomValue(themeMode);
const monacoRef = useRef<HTMLDivElement>(null);
const monacoeditorRef = useRef<typeof monaco | null>(null);
const monacoEditorRef = useRef<typeof monaco | null>(null);
const instanceRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
useEffect(() => {
const run = async () => {
const { monaco } = await import("@/services/monaco");
monacoeditorRef.current = monaco;
monacoEditorRef.current = monaco;
if (!monacoRef.current) {
return;
}
instanceRef.current = monaco.editor.create(monacoRef.current, {
value,
language,
readOnly: readonly,
renderValidationDecorations: "on",
theme: mode === "light" ? "vs" : "vs-dark",
tabSize: language === "yaml" ? 2 : 4,
minimap: { enabled: false },
automaticLayout: true,
fontLigatures: true,
smoothScrolling: true,
fontFamily: `'Cascadia Code NF', 'Cascadia Code', Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
OS === "windows" ? ", twemoji mozilla" : ""
}`,
quickSuggestions: {
strings: true,
comments: true,
other: true,
},
});
const uri = monaco.Uri.parse(
`${nanoid()}.${!!schemaType ? `${schemaType}.` : ""}.${language}`,
);
const model = monaco.editor.createModel(value || "", language, uri);
instanceRef.current.setModel(model);
};
if (open) {
run().catch(console.error);
@@ -50,7 +77,7 @@ export const ProfileMonacoView = forwardRef(function ProfileMonacoView(
return () => {
instanceRef.current?.dispose();
};
}, [language, mode, open, value]);
}, [language, mode, open, readonly, schemaType, value]);
useImperativeHandle(ref, () => ({
getValue: () => instanceRef.current?.getValue(),
@@ -63,7 +90,7 @@ export const ProfileMonacoView = forwardRef(function ProfileMonacoView(
return;
}
monacoeditorRef.current?.editor.setModelLanguage(model, language);
monacoEditorRef.current?.editor.setModelLanguage(model, language);
}, [language]);
useUpdateEffect(() => {

View File

@@ -222,6 +222,9 @@ export const ScriptDialog = ({
open={openMonaco}
value={editor.value}
language={editor.language}
schemaType={
editor.rawType === Profile.Type.Merge ? "merge" : undefined
}
/>
</div>
</BaseDialog>

View File

@@ -1,27 +1,31 @@
import { useAsyncEffect } from "ahooks";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { useState } from "react";
import useSWR from "swr";
import { OS } from "@/consts";
import { serviceManualPromptDialogAtom } from "@/store/service";
import { getShikiSingleton } from "@/utils/shiki";
import { getServiceInstallPrompt } from "@nyanpasu/interface";
import { BaseDialog, BaseDialogProps } from "@nyanpasu/ui";
export type ServerManualPromptDialogProps = Omit<BaseDialogProps, "title">;
export type ServerManualPromptDialogProps = Omit<BaseDialogProps, "title"> & {
operation: "uninstall" | "install" | "start" | "stop" | null;
};
// TODO: maybe support more commands prompt?
export default function ServerManualPromptDialog({
open,
onClose,
operation,
...props
}: ServerManualPromptDialogProps) {
const { data: serviceInstallPrompt } = useSWR(
"/service_install_prompt",
const { data: serviceInstallPrompt, error } = useSWR(
operation === "install" ? "/service_install_prompt" : null,
getServiceInstallPrompt,
);
const [codes, setCodes] = useState<string | null>(null);
useAsyncEffect(async () => {
if (serviceInstallPrompt) {
if (operation === "install" && serviceInstallPrompt) {
const shiki = await getShikiSingleton();
const code = await shiki.codeToHtml(serviceInstallPrompt, {
lang: "shell",
@@ -31,17 +35,36 @@ export default function ServerManualPromptDialog({
},
});
setCodes(code);
} else if (!!operation) {
const shiki = await getShikiSingleton();
const code = await shiki.codeToHtml(
`${OS !== "windows" ? "sudo " : ""}./nyanpasu-service ${operation}`,
{
lang: "shell",
themes: {
dark: "nord",
light: "min-light",
},
},
);
setCodes(code);
}
}, [serviceInstallPrompt]);
}, [serviceInstallPrompt, operation, setCodes]);
return (
<BaseDialog title="Server Manual" open={open} onClose={onClose} {...props}>
<BaseDialog
title="Service Manual Tips"
open={open}
onClose={onClose}
{...props}
>
<div className="grid gap-3">
<p>
Unable to install service automatically. Please open a PowerShell(as
administrator) in Windows or a terminal emulator in macOS, Linux and
run the following commands:
</p>
{error && <p className="text-red-500">{error.message}</p>}
{!!codes && (
<div
dangerouslySetInnerHTML={{
@@ -55,16 +78,21 @@ export default function ServerManualPromptDialog({
}
export function ServerManualPromptDialogWrapper() {
const [open, setOpen] = useAtom(serviceManualPromptDialogAtom);
const [prompt, setPrompt] = useAtom(serviceManualPromptDialogAtom);
return (
<ServerManualPromptDialog open={open} onClose={() => setOpen(false)} />
<ServerManualPromptDialog
open={!!prompt}
onClose={() => setPrompt(null)}
operation={prompt}
/>
);
}
export function useServerManualPromptDialog() {
const [, setOpen] = useAtom(serviceManualPromptDialogAtom);
const setPrompt = useSetAtom(serviceManualPromptDialogAtom);
return {
show: () => setOpen(true),
close: () => setOpen(false),
show: (prompt: "install" | "uninstall" | "stop" | "start") =>
setPrompt(prompt),
close: () => setPrompt(null),
};
}

View File

@@ -67,19 +67,20 @@ export const SettingSystemService = () => {
}
await restartSidecar();
} catch (e) {
const errorMessage =
const errorMessage = `${
getServiceStatus.data === "not_installed"
? "Install failed"
: "Uninstall failed";
: "Uninstall failed"
}: ${formatError(e)}`;
message(errorMessage, {
type: "error",
title: t("Error"),
});
// If install failed show a prompt to user to install the service manually
if (getServiceStatus.data === "not_installed") {
promptDialog.show();
}
promptDialog.show(
getServiceStatus.data === "not_installed" ? "install" : "uninstall",
);
}
});
});
@@ -111,6 +112,10 @@ export const SettingSystemService = () => {
type: "error",
title: t("Error"),
});
// If start failed show a prompt to user to start the service manually
promptDialog.show(
getServiceStatus.data === "running" ? "stop" : "start",
);
}
});
});

View File

@@ -1,3 +1,6 @@
import nyanpasuMergeSchema from "meta-json-schema/schemas/clash-nyanpasu-merge-json-schema.json";
import clashMetaSchema from "meta-json-schema/schemas/meta-json-schema.json";
import { configureMonacoYaml } from "monaco-yaml";
// features
// langs
import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js";
@@ -14,4 +17,21 @@ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowJs: true,
});
configureMonacoYaml(monaco, {
validate: true,
enableSchemaRequest: true,
schemas: [
{
fileMatch: ["**/*.clash.yaml"],
// @ts-expect-error monaco-yaml parse issue
schema: clashMetaSchema,
},
{
fileMatch: ["**/*.merge.yaml"],
// @ts-expect-error monaco-yaml parse issue
schema: nyanpasuMergeSchema,
},
],
});
export { monaco };

View File

@@ -1,3 +1,5 @@
import { atom } from "jotai";
export const serviceManualPromptDialogAtom = atom<boolean>(false);
export const serviceManualPromptDialogAtom = atom<
"install" | "uninstall" | "start" | "stop" | null
>(null);

View File

@@ -68,7 +68,15 @@ export default defineConfig(({ command }) => {
}),
generouted(),
sassDts({ esmExport: true }),
monaco({ languageWorkers: ["editorWorkerService", "typescript"] }),
monaco({
languageWorkers: ["editorWorkerService", "typescript"],
customWorkers: [
{
label: "yaml",
entry: "monaco-yaml/yaml.worker",
},
],
}),
isDev && devtools(),
],
resolve: {

View File

@@ -28,7 +28,7 @@
"@vitejs/plugin-react": "4.3.1",
"ahooks": "3.8.1",
"d3": "7.9.0",
"framer-motion": "12.0.0-alpha.0",
"framer-motion": "12.0.0-alpha.1",
"react": "18.3.1",
"react-error-boundary": "4.0.13",
"react-i18next": "15.0.1",

View File

@@ -3,7 +3,7 @@
"latest": {
"mihomo": "v1.18.8",
"mihomo_alpha": "alpha-faaa90f",
"clash_rs": "v0.3.0",
"clash_rs": "v0.3.1",
"clash_premium": "2023-09-05-gdcc8d87"
},
"arch_template": {
@@ -36,5 +36,5 @@
"darwin-x64": "clash-darwin-amd64-n{}.gz"
}
},
"updated_at": "2024-09-03T22:20:35.876Z"
"updated_at": "2024-09-05T22:20:27.332Z"
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,8 @@ type Cmgr interface {
Start(ctx context.Context, errCH chan error)
// Metrics related
QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error)
QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error)
QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error)
}
type cmgrImpl struct {
@@ -201,20 +202,30 @@ func (cm *cmgrImpl) Start(ctx context.Context, errCH chan error) {
}
}
func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error) {
num := -1 // default to return all metrics
if req.Latest {
m, err := cm.mr.ReadOnce(ctx)
func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) {
if refresh {
nm, _, err := cm.mr.ReadOnce(ctx)
if err != nil {
return nil, err
}
if err := cm.ms.AddNodeMetric(m); err != nil {
if err := cm.ms.AddNodeMetric(ctx, nm); err != nil {
return nil, err
}
num = 1
}
startTime := time.Unix(req.StartTimestamp, 0)
endTime := time.Unix(req.EndTimestamp, 0)
return cm.ms.QueryNodeMetric(startTime, endTime, num)
return cm.ms.QueryNodeMetric(ctx, req)
}
func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) {
if refresh {
_, rm, err := cm.mr.ReadOnce(ctx)
if err != nil {
return nil, err
}
for _, m := range rm {
if err := cm.ms.AddRuleMetric(ctx, m); err != nil {
return nil, err
}
}
}
return cm.ms.QueryRuleMetric(ctx, req)
}

View File

@@ -0,0 +1,163 @@
package ms
import (
"context"
"github.com/Ehco1996/ehco/pkg/metric_reader"
)
type NodeMetrics struct {
Timestamp int64 `json:"timestamp"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
NetworkIn float64 `json:"network_in"` // bytes per second
NetworkOut float64 `json:"network_out"` // bytes per second
}
type QueryNodeMetricsReq struct {
StartTimestamp int64
EndTimestamp int64
Num int64
}
type QueryNodeMetricsResp struct {
TOTAL int `json:"total"`
Data []NodeMetrics `json:"data"`
}
type RuleMetricsData struct {
Timestamp int64 `json:"timestamp"`
Label string `json:"label"`
Remote string `json:"remote"`
PingLatency int64 `json:"ping_latency"`
TCPConnectionCount int64 `json:"tcp_connection_count"`
TCPHandshakeDuration int64 `json:"tcp_handshake_duration"`
TCPNetworkTransmitBytes int64 `json:"tcp_network_transmit_bytes"`
UDPConnectionCount int64 `json:"udp_connection_count"`
UDPHandshakeDuration int64 `json:"udp_handshake_duration"`
UDPNetworkTransmitBytes int64 `json:"udp_network_transmit_bytes"`
}
type QueryRuleMetricsReq struct {
RuleLabel string
Remote string
StartTimestamp int64
EndTimestamp int64
Num int64
}
type QueryRuleMetricsResp struct {
TOTAL int `json:"total"`
Data []RuleMetricsData `json:"data"`
}
func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *metric_reader.NodeMetrics) error {
_, err := ms.db.ExecContext(ctx, `
INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out)
VALUES (?, ?, ?, ?, ?, ?)
`, m.SyncTime.Unix(), m.CpuUsagePercent, m.MemoryUsagePercent, m.DiskUsagePercent, m.NetworkReceiveBytesRate, m.NetworkTransmitBytesRate)
return err
}
func (ms *MetricsStore) AddRuleMetric(ctx context.Context, rm *metric_reader.RuleMetrics) error {
tx, err := ms.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
stmt, err := tx.PrepareContext(ctx, `
INSERT OR REPLACE INTO rule_metrics
(timestamp, label, remote, ping_latency,
tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes,
udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
}
defer stmt.Close() //nolint:errcheck
for remote, pingMetric := range rm.PingMetrics {
_, err := stmt.ExecContext(ctx, rm.SyncTime.Unix(), rm.Label, remote, pingMetric.Latency,
rm.TCPConnectionCount[remote], rm.TCPHandShakeDuration[remote], rm.TCPNetworkTransmitBytes[remote],
rm.UDPConnectionCount[remote], rm.UDPHandShakeDuration[remote], rm.UDPNetworkTransmitBytes[remote])
if err != nil {
return err
}
}
return tx.Commit()
}
func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetricsReq) (*QueryNodeMetricsResp, error) {
rows, err := ms.db.QueryContext(ctx, `
SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out
FROM node_metrics
WHERE timestamp >= ? AND timestamp <= ?
ORDER BY timestamp DESC
LIMIT ?
`, req.StartTimestamp, req.EndTimestamp, req.Num)
if err != nil {
return nil, err
}
defer rows.Close() //nolint:errcheck
var resp QueryNodeMetricsResp
for rows.Next() {
var m NodeMetrics
if err := rows.Scan(&m.Timestamp, &m.CPUUsage, &m.MemoryUsage, &m.DiskUsage, &m.NetworkIn, &m.NetworkOut); err != nil {
return nil, err
}
resp.Data = append(resp.Data, m)
}
resp.TOTAL = len(resp.Data)
return &resp, nil
}
func (ms *MetricsStore) QueryRuleMetric(ctx context.Context, req *QueryRuleMetricsReq) (*QueryRuleMetricsResp, error) {
query := `
SELECT timestamp, label, remote, ping_latency,
tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes,
udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes
FROM rule_metrics
WHERE timestamp >= ? AND timestamp <= ?
`
args := []interface{}{req.StartTimestamp, req.EndTimestamp}
if req.RuleLabel != "" {
query += " AND label = ?"
args = append(args, req.RuleLabel)
}
if req.Remote != "" {
query += " AND remote = ?"
args = append(args, req.Remote)
}
query += `
ORDER BY timestamp DESC
LIMIT ?
`
args = append(args, req.Num)
rows, err := ms.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close() //nolint:errcheck
var resp QueryRuleMetricsResp
for rows.Next() {
var m RuleMetricsData
if err := rows.Scan(&m.Timestamp, &m.Label, &m.Remote, &m.PingLatency,
&m.TCPConnectionCount, &m.TCPHandshakeDuration, &m.TCPNetworkTransmitBytes,
&m.UDPConnectionCount, &m.UDPHandshakeDuration, &m.UDPNetworkTransmitBytes); err != nil {
return nil, err
}
resp.Data = append(resp.Data, m)
}
resp.TOTAL = len(resp.Data)
return &resp, nil
}

View File

@@ -8,31 +8,8 @@ import (
"go.uber.org/zap"
_ "modernc.org/sqlite"
"github.com/Ehco1996/ehco/pkg/metric_reader"
)
type NodeMetrics struct {
Timestamp int64 `json:"timestamp"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
NetworkIn float64 `json:"network_in"`
NetworkOut float64 `json:"network_out"`
}
type QueryNodeMetricsReq struct {
StartTimestamp int64 `json:"start_ts"`
EndTimestamp int64 `json:"end_ts"`
Latest bool `json:"latest"` // whether to refresh the cache and get the latest data
}
type QueryNodeMetricsResp struct {
TOTAL int `json:"total"`
Data []NodeMetrics `json:"data"`
}
type MetricsStore struct {
db *sql.DB
dbPath string
@@ -65,12 +42,34 @@ func NewMetricsStore(dbPath string) (*MetricsStore, error) {
if err := ms.initDB(); err != nil {
return nil, err
}
if err := ms.cleanOldData(); err != nil {
return nil, err
}
return ms, nil
}
func (ms *MetricsStore) cleanOldData() error {
thirtyDaysAgo := time.Now().AddDate(0, 0, -30).Unix()
// 清理 node_metrics 表
_, err := ms.db.Exec("DELETE FROM node_metrics WHERE timestamp < ?", thirtyDaysAgo)
if err != nil {
return err
}
// 清理 rule_metrics 表
_, err = ms.db.Exec("DELETE FROM rule_metrics WHERE timestamp < ?", thirtyDaysAgo)
if err != nil {
return err
}
ms.l.Infof("Cleaned data older than 30 days")
return nil
}
func (ms *MetricsStore) initDB() error {
// init NodeMetrics table
_, err := ms.db.Exec(`
if _, err := ms.db.Exec(`
CREATE TABLE IF NOT EXISTS node_metrics (
timestamp INTEGER,
cpu_usage REAL,
@@ -80,39 +79,27 @@ func (ms *MetricsStore) initDB() error {
network_out REAL,
PRIMARY KEY (timestamp)
)
`)
return err
}
func (ms *MetricsStore) AddNodeMetric(m *metric_reader.NodeMetrics) error {
_, err := ms.db.Exec(`
INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out)
VALUES (?, ?, ?, ?, ?, ?)
`, m.SyncTime.Unix(), m.CpuUsagePercent, m.MemoryUsagePercent, m.DiskUsagePercent, m.NetworkReceiveBytesRate, m.NetworkTransmitBytesRate)
return err
}
func (ms *MetricsStore) QueryNodeMetric(startTime, endTime time.Time, num int) (*QueryNodeMetricsResp, error) {
rows, err := ms.db.Query(`
SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out
FROM node_metrics
WHERE timestamp >= ? AND timestamp <= ?
ORDER BY timestamp DESC
LIMIT ?
`, startTime.Unix(), endTime.Unix(), num)
if err != nil {
return nil, err
`); err != nil {
return err
}
defer rows.Close() //nolint:errcheck
var resp QueryNodeMetricsResp
for rows.Next() {
var m NodeMetrics
if err := rows.Scan(&m.Timestamp, &m.CPUUsage, &m.MemoryUsage, &m.DiskUsage, &m.NetworkIn, &m.NetworkOut); err != nil {
return nil, err
}
resp.Data = append(resp.Data, m)
// init rule_metrics
if _, err := ms.db.Exec(`
CREATE TABLE IF NOT EXISTS rule_metrics (
timestamp INTEGER,
label TEXT,
remote TEXT,
ping_latency INTEGER,
tcp_connection_count INTEGER,
tcp_handshake_duration INTEGER,
tcp_network_transmit_bytes INTEGER,
udp_connection_count INTEGER,
udp_handshake_duration INTEGER,
udp_network_transmit_bytes INTEGER,
PRIMARY KEY (timestamp, label, remote)
)
`); err != nil {
return err
}
resp.TOTAL = len(resp.Data)
return &resp, nil
return nil
}

View File

@@ -45,14 +45,19 @@ func (cm *cmgrImpl) syncOnce(ctx context.Context) error {
}
if cm.cfg.NeedMetrics() {
metrics, err := cm.mr.ReadOnce(ctx)
nm, rmm, err := cm.mr.ReadOnce(ctx)
if err != nil {
cm.l.Errorf("read metrics failed: %v", err)
} else {
req.Node = *metrics
if err := cm.ms.AddNodeMetric(metrics); err != nil {
req.Node = *nm
if err := cm.ms.AddNodeMetric(ctx, nm); err != nil {
cm.l.Errorf("add metrics to store failed: %v", err)
}
for _, rm := range rmm {
if err := cm.ms.AddRuleMetric(ctx, rm); err != nil {
cm.l.Errorf("add rule metrics to store failed: %v", err)
}
}
}
}

View File

@@ -209,14 +209,12 @@ func (c *innerConn) recordStats(n int, isRead bool) {
return
}
if isRead {
metrics.NetWorkTransmitBytes.WithLabelValues(
c.rc.remote.Label, metrics.METRIC_CONN_TYPE_TCP, metrics.METRIC_CONN_FLOW_READ,
).Add(float64(n))
labels := []string{c.rc.RelayLabel, c.rc.ConnType, metrics.METRIC_FLOW_READ, c.rc.remote.Address}
metrics.NetWorkTransmitBytes.WithLabelValues(labels...).Add(float64(n))
c.rc.Stats.Record(0, int64(n))
} else {
metrics.NetWorkTransmitBytes.WithLabelValues(
c.rc.remote.Label, metrics.METRIC_CONN_TYPE_TCP, metrics.METRIC_CONN_FLOW_WRITE,
).Add(float64(n))
labels := []string{c.rc.RelayLabel, c.rc.ConnType, metrics.METRIC_FLOW_WRITE, c.rc.remote.Address}
metrics.NetWorkTransmitBytes.WithLabelValues(labels...).Add(float64(n))
c.rc.Stats.Record(int64(n), 0)
}
}
@@ -236,7 +234,7 @@ func (c *innerConn) Read(p []byte) (n int, err error) {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
since := time.Since(c.lastActive)
if since > c.rc.Options.IdleTimeout {
c.l.Debugf("Read idle, close remote: %s", c.rc.remote.Label)
c.l.Debugf("Read idle, close remote: %s", c.rc.remote.Address)
return 0, ErrIdleTimeout
}
continue

View File

@@ -24,7 +24,7 @@ func TestInnerConn_ReadWrite(t *testing.T) {
serverConn.SetDeadline(time.Now().Add(1 * time.Second))
defer clientConn.Close()
defer serverConn.Close()
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
innerC := newInnerConn(clientConn, &rc)
errChan := make(chan error, 1)
go func() {
@@ -100,7 +100,7 @@ func TestCopyTCPConn(t *testing.T) {
assert.NoError(t, err)
defer remoteConn.Close()
testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second}
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
c1 := newInnerConn(clientConn, &rc)
c2 := newInnerConn(remoteConn, &rc)
@@ -161,7 +161,7 @@ func TestCopyUDPConn(t *testing.T) {
defer remoteConn.Close()
testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second}
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
c1 := newInnerConn(clientConn, &rc)
c2 := newInnerConn(remoteConn, &rc)

View File

@@ -1,6 +1,8 @@
package lb
import (
"net/url"
"strings"
"time"
"go.uber.org/atomic"
@@ -8,21 +10,38 @@ import (
type Node struct {
Address string
Label string
HandShakeDuration time.Duration
}
func (n *Node) Clone() *Node {
return &Node{
Address: n.Address,
Label: n.Label,
HandShakeDuration: n.HandShakeDuration,
}
}
func extractHost(input string) (string, error) {
// Check if the input string has a scheme, if not, add "http://"
if !strings.Contains(input, "://") {
input = "http://" + input
}
// Parse the URL
u, err := url.Parse(input)
if err != nil {
return "", err
}
return u.Hostname(), nil
}
// NOTE for (https/ws/wss)://xxx.com -> xxx.com
func (n *Node) GetAddrHost() (string, error) {
return extractHost(n.Address)
}
// RoundRobin is an interface for representing round-robin balancing.
type RoundRobin interface {
Next() *Node
GetAll() []*Node
}
type roundrobin struct {
@@ -42,3 +61,7 @@ func (r *roundrobin) Next() *Node {
next := r.nodeList[(int(n)-1)%r.len]
return next
}
func (r *roundrobin) GetAll() []*Node {
return r.nodeList
}

View File

@@ -13,53 +13,43 @@ const (
METRIC_SUBSYSTEM_TRAFFIC = "traffic"
METRIC_SUBSYSTEM_PING = "ping"
METRIC_LABEL_REMOTE = "remote"
METRIC_LABEL_CONN_FLOW = "flow"
METRIC_CONN_FLOW_WRITE = "write"
METRIC_CONN_FLOW_READ = "read"
METRIC_LABEL_CONN_TYPE = "type"
METRIC_CONN_TYPE_TCP = "tcp"
METRIC_CONN_TYPE_UDP = "udp"
METRIC_CONN_TYPE_TCP = "tcp"
METRIC_CONN_TYPE_UDP = "udp"
METRIC_FLOW_READ = "read"
METRIC_FLOW_WRITE = "write"
EhcoAliveStateInit = 0
EhcoAliveStateRunning = 1
)
var (
Hostname, _ = os.Hostname()
ConstLabels = map[string]string{
"ehco_runner_hostname": Hostname,
}
// 1ms ~ 5s (1ms 到 437ms )
msBuckets = prometheus.ExponentialBuckets(1, 1.5, 16)
)
// ping metrics
var (
pingLabelNames = []string{"ip", "host", "label"}
pingBuckets = prometheus.ExponentialBuckets(0.001, 2, 12) // 1ms ~ 4s
pingInterval = time.Second * 30
PingResponseDurationSeconds = prometheus.NewHistogramVec(
pingInterval = time.Second * 30
PingResponseDurationMilliseconds = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: METRIC_NS,
Subsystem: METRIC_SUBSYSTEM_PING,
Name: "response_duration_seconds",
Name: "response_duration_milliseconds",
Help: "A histogram of latencies for ping responses.",
Buckets: pingBuckets,
Buckets: msBuckets,
ConstLabels: ConstLabels,
},
pingLabelNames,
)
PingRequestTotal = prometheus.NewDesc(
prometheus.BuildFQName(METRIC_NS, METRIC_SUBSYSTEM_PING, "requests_total"),
"Number of ping requests sent",
pingLabelNames,
ConstLabels,
[]string{"label", "remote", "ip"},
)
)
// traffic metrics
var (
Hostname, _ = os.Hostname()
ConstLabels = map[string]string{
"ehco_runner_hostname": Hostname,
}
EhcoAlive = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: METRIC_NS,
Subsystem: "",
@@ -74,7 +64,15 @@ var (
Name: "current_connection_count",
Help: "当前链接数",
ConstLabels: ConstLabels,
}, []string{METRIC_LABEL_REMOTE, METRIC_LABEL_CONN_TYPE})
}, []string{"label", "conn_type", "remote"})
HandShakeDurationMilliseconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Subsystem: METRIC_SUBSYSTEM_TRAFFIC,
Namespace: METRIC_NS,
Name: "handshake_duration_milliseconds",
Help: "握手时间ms",
ConstLabels: ConstLabels,
}, []string{"label", "conn_type", "remote"})
NetWorkTransmitBytes = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: METRIC_NS,
@@ -82,15 +80,7 @@ var (
Name: "network_transmit_bytes",
Help: "传输流量总量bytes",
ConstLabels: ConstLabels,
}, []string{METRIC_LABEL_REMOTE, METRIC_LABEL_CONN_TYPE, METRIC_LABEL_CONN_FLOW})
HandShakeDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Subsystem: METRIC_SUBSYSTEM_TRAFFIC,
Namespace: METRIC_NS,
Name: "handshake_duration",
Help: "握手时间ms",
ConstLabels: ConstLabels,
}, []string{METRIC_LABEL_REMOTE})
}, []string{"label", "conn_type", "flow", "remote"})
)
func RegisterEhcoMetrics(cfg *config.Config) error {
@@ -98,15 +88,14 @@ func RegisterEhcoMetrics(cfg *config.Config) error {
prometheus.MustRegister(EhcoAlive)
prometheus.MustRegister(CurConnectionCount)
prometheus.MustRegister(NetWorkTransmitBytes)
prometheus.MustRegister(HandShakeDuration)
prometheus.MustRegister(HandShakeDurationMilliseconds)
EhcoAlive.Set(EhcoAliveStateInit)
// ping
if cfg.EnablePing {
pg := NewPingGroup(cfg)
prometheus.MustRegister(PingResponseDurationSeconds)
prometheus.MustRegister(pg)
prometheus.MustRegister(PingResponseDurationMilliseconds)
go pg.Run()
}
return nil

View File

@@ -1,20 +1,16 @@
package metrics
import (
"fmt"
"math"
"net/url"
"runtime"
"strings"
"time"
"github.com/Ehco1996/ehco/internal/config"
"github.com/go-ping/ping"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
)
func (pg *PingGroup) newPinger(addr string) (*ping.Pinger, error) {
func (pg *PingGroup) newPinger(ruleLabel string, remote string, addr string) (*ping.Pinger, error) {
pinger := ping.New(addr)
if err := pinger.Resolve(); err != nil {
pg.logger.Error("failed to resolve pinger", zap.String("addr", addr), zap.Error(err))
@@ -26,6 +22,13 @@ func (pg *PingGroup) newPinger(addr string) (*ping.Pinger, error) {
if runtime.GOOS != "darwin" {
pinger.SetPrivileged(true)
}
pinger.OnRecv = func(pkt *ping.Packet) {
ip := pkt.IPAddr.String()
PingResponseDurationMilliseconds.WithLabelValues(
ruleLabel, remote, ip).Observe(float64(pkt.Rtt.Milliseconds()))
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v",
pkt.Nbytes, pkt.Addr, pkt.Seq, pkt.Rtt, pkt.Ttl)
}
return pinger, nil
}
@@ -34,89 +37,29 @@ type PingGroup struct {
// k: addr
Pingers map[string]*ping.Pinger
// k: addr v:relay rule label joined by ","
PingerLabels map[string]string
}
func extractHost(input string) (string, error) {
// Check if the input string has a scheme, if not, add "http://"
if !strings.Contains(input, "://") {
input = "http://" + input
}
// Parse the URL
u, err := url.Parse(input)
if err != nil {
return "", err
}
return u.Hostname(), nil
}
func NewPingGroup(cfg *config.Config) *PingGroup {
logger := zap.L().Named("pinger")
pg := &PingGroup{
logger: logger,
Pingers: make(map[string]*ping.Pinger),
PingerLabels: map[string]string{},
logger: zap.L().Named("pinger"),
Pingers: make(map[string]*ping.Pinger),
}
// parse addr from rule
for _, relayCfg := range cfg.RelayConfigs {
// NOTE for (https/ws/wss)://xxx.com -> xxx.com
for _, remote := range relayCfg.Remotes {
addr, err := extractHost(remote)
for _, remote := range relayCfg.GetAllRemotes() {
addr, err := remote.GetAddrHost()
if err != nil {
pg.logger.Error("try parse host error", zap.Error(err))
}
if _, ok := pg.Pingers[addr]; ok {
// append rule label when remote host is same
pg.PingerLabels[addr] += fmt.Sprintf(",%s", relayCfg.Label)
continue
}
if pinger, err := pg.newPinger(addr); err != nil {
if pinger, err := pg.newPinger(relayCfg.Label, remote.Address, addr); err != nil {
pg.logger.Error("new pinger meet error", zap.Error(err))
} else {
pg.Pingers[pinger.Addr()] = pinger
pg.PingerLabels[addr] = relayCfg.Label
pg.Pingers[addr] = pinger
}
}
}
// update metrics
for addr, pinger := range pg.Pingers {
pinger.OnRecv = func(pkt *ping.Packet) {
PingResponseDurationSeconds.WithLabelValues(
pkt.IPAddr.String(), pkt.Addr, pg.PingerLabels[addr]).Observe(pkt.Rtt.Seconds())
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v",
pkt.Nbytes, pkt.Addr, pkt.Seq, pkt.Rtt, pkt.Ttl)
}
pinger.OnDuplicateRecv = func(pkt *ping.Packet) {
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v (DUP!)",
pkt.Nbytes, pkt.IPAddr, pkt.Seq, pkt.Rtt, pkt.Ttl)
}
}
return pg
}
func (pg *PingGroup) Describe(ch chan<- *prometheus.Desc) {
ch <- PingRequestTotal
}
func (pg *PingGroup) Collect(ch chan<- prometheus.Metric) {
for addr, pinger := range pg.Pingers {
stats := pinger.Statistics()
ch <- prometheus.MustNewConstMetric(
PingRequestTotal,
prometheus.CounterValue,
float64(stats.PacketsSent),
stats.IPAddr.String(),
stats.Addr,
pg.PingerLabels[addr],
)
}
}
func (pg *PingGroup) Run() {
if len(pg.Pingers) <= 0 {
return

View File

@@ -179,14 +179,16 @@ func (r *Config) DefaultLabel() string {
func (r *Config) ToRemotesLB() lb.RoundRobin {
tcpNodeList := make([]*lb.Node, len(r.Remotes))
for idx, addr := range r.Remotes {
tcpNodeList[idx] = &lb.Node{
Address: addr,
Label: fmt.Sprintf("%s-%s", r.Label, addr),
}
tcpNodeList[idx] = &lb.Node{Address: addr}
}
return lb.NewRoundRobin(tcpNodeList)
}
func (r *Config) GetAllRemotes() []*lb.Node {
lb := r.ToRemotesLB()
return lb.GetAll()
}
func (r *Config) GetLoggerName() string {
return fmt.Sprintf("%s(%s<->%s)", r.Label, r.ListenType, r.TransportType)
}

View File

@@ -44,8 +44,9 @@ func newBaseRelayServer(cfg *conf.Config, cmgr cmgr.Cmgr) (*BaseRelayServer, err
}
func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Inc()
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Dec()
labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_TCP, remote.Address}
metrics.CurConnectionCount.WithLabelValues(labels...).Inc()
defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec()
if err := b.checkConnectionLimit(); err != nil {
return err
@@ -68,8 +69,9 @@ func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *
}
func (b *BaseRelayServer) RelayUDPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Inc()
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Dec()
labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_UDP, remote.Address}
metrics.CurConnectionCount.WithLabelValues(labels...).Inc()
defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec()
rc, err := b.relayer.HandShake(ctx, remote, false)
if err != nil {

View File

@@ -47,7 +47,12 @@ func (raw *RawClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool
return nil, err
}
latency := time.Since(t1)
metrics.HandShakeDuration.WithLabelValues(remote.Label).Observe(float64(latency.Milliseconds()))
connType := metrics.METRIC_CONN_TYPE_TCP
if !isTCP {
connType = metrics.METRIC_CONN_TYPE_UDP
}
labels := []string{raw.cfg.Label, connType, remote.Address}
metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds()))
remote.HandShakeDuration = latency
return rc, nil
}

View File

@@ -67,7 +67,12 @@ func (s *WsClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool) (
return nil, err
}
latency := time.Since(t1)
metrics.HandShakeDuration.WithLabelValues(remote.Label).Observe(float64(latency.Milliseconds()))
connType := metrics.METRIC_CONN_TYPE_TCP
if !isTCP {
connType = metrics.METRIC_CONN_TYPE_UDP
}
labels := []string{s.cfg.Label, connType, remote.Address}
metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds()))
remote.HandShakeDuration = latency
c := conn.NewWSConn(wsc, false)
return c, nil
@@ -97,7 +102,7 @@ func (s *WsServer) handleRequest(w http.ResponseWriter, req *http.Request) {
var remote *lb.Node
if addr := req.URL.Query().Get(conf.WS_QUERY_REMOTE_ADDR); addr != "" {
remote = &lb.Node{Address: addr, Label: addr}
remote = &lb.Node{Address: addr}
} else {
remote = s.remotes.Next()
}

View File

@@ -0,0 +1,134 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/Ehco1996/ehco/internal/cmgr/ms"
"github.com/labstack/echo/v4"
)
const (
defaultTimeRange = 60 // seconds
errInvalidParam = "invalid parameter: %s"
)
type queryParams struct {
startTS int64
endTS int64
refresh bool
}
func parseQueryParams(c echo.Context) (*queryParams, error) {
now := time.Now().Unix()
params := &queryParams{
startTS: now - defaultTimeRange,
endTS: now,
refresh: false,
}
if start, err := parseTimestamp(c.QueryParam("start_ts")); err == nil {
params.startTS = start
}
if end, err := parseTimestamp(c.QueryParam("end_ts")); err == nil {
params.endTS = end
}
if refresh, err := strconv.ParseBool(c.QueryParam("latest")); err == nil {
params.refresh = refresh
}
if params.startTS >= params.endTS {
return nil, fmt.Errorf(errInvalidParam, "time range")
}
return params, nil
}
func parseTimestamp(s string) (int64, error) {
if s == "" {
return 0, fmt.Errorf("empty timestamp")
}
return strconv.ParseInt(s, 10, 64)
}
func (s *Server) GetNodeMetrics(c echo.Context) error {
params, err := parseQueryParams(c)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1}
if params.refresh {
req.Num = 1
}
metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req, params.refresh)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, metrics)
}
func (s *Server) GetRuleMetrics(c echo.Context) error {
params, err := parseQueryParams(c)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
req := &ms.QueryRuleMetricsReq{
StartTimestamp: params.startTS,
EndTimestamp: params.endTS,
Num: -1,
RuleLabel: c.QueryParam("label"),
Remote: c.QueryParam("remote"),
}
if params.refresh {
req.Num = 1
}
metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req, params.refresh)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, metrics)
}
func (s *Server) CurrentConfig(c echo.Context) error {
ret, err := json.Marshal(s.cfg)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSONBlob(http.StatusOK, ret)
}
func (s *Server) HandleReload(c echo.Context) error {
if s.Reloader == nil {
return echo.NewHTTPError(http.StatusBadRequest, "reload not support")
}
err := s.Reloader.Reload(true)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if _, err := c.Response().Write([]byte("reload success")); err != nil {
s.l.Errorf("write response meet err=%v", err)
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return nil
}
func (s *Server) HandleHealthCheck(c echo.Context) error {
relayLabel := c.QueryParam("relay_label")
if relayLabel == "" {
return echo.NewHTTPError(http.StatusBadRequest, "relay_label is required")
}
latency, err := s.HealthCheck(c.Request().Context(), relayLabel)
if err != nil {
res := HealthCheckResp{Message: err.Error(), ErrorCode: -1}
return c.JSON(http.StatusBadRequest, res)
}
return c.JSON(http.StatusOK, HealthCheckResp{Message: "connect success", Latency: latency})
}

View File

@@ -1,13 +1,10 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/Ehco1996/ehco/internal/cmgr/ms"
"github.com/Ehco1996/ehco/internal/config"
"github.com/Ehco1996/ehco/internal/constant"
"github.com/labstack/echo/v4"
@@ -42,44 +39,6 @@ func (s *Server) index(c echo.Context) error {
return c.Render(http.StatusOK, "index.html", data)
}
func (s *Server) HandleReload(c echo.Context) error {
if s.Reloader == nil {
return echo.NewHTTPError(http.StatusBadRequest, "reload not support")
}
err := s.Reloader.Reload(true)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if _, err := c.Response().Write([]byte("reload success")); err != nil {
s.l.Errorf("write response meet err=%v", err)
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return nil
}
func (s *Server) HandleHealthCheck(c echo.Context) error {
relayLabel := c.QueryParam("relay_label")
if relayLabel == "" {
return echo.NewHTTPError(http.StatusBadRequest, "relay_label is required")
}
latency, err := s.HealthCheck(c.Request().Context(), relayLabel)
if err != nil {
res := HealthCheckResp{Message: err.Error(), ErrorCode: -1}
return c.JSON(http.StatusBadRequest, res)
}
return c.JSON(http.StatusOK, HealthCheckResp{Message: "connect success", Latency: latency})
}
func (s *Server) CurrentConfig(c echo.Context) error {
ret, err := json.Marshal(s.cfg)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSONBlob(http.StatusOK, ret)
}
func (s *Server) ListConnections(c echo.Context) error {
pageStr := c.QueryParam("page")
page, err := strconv.Atoi(pageStr)
@@ -126,36 +85,12 @@ func (s *Server) ListRules(c echo.Context) error {
})
}
func (s *Server) GetNodeMetrics(c echo.Context) error {
startTS := time.Now().Unix() - 60
if c.QueryParam("start_ts") != "" {
star, err := strconv.ParseInt(c.QueryParam("start_ts"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
startTS = star
}
endTS := time.Now().Unix()
if c.QueryParam("end_ts") != "" {
end, err := strconv.ParseInt(c.QueryParam("end_ts"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
endTS = end
}
req := &ms.QueryNodeMetricsReq{StartTimestamp: startTS, EndTimestamp: endTS}
latest := c.QueryParam("latest")
if latest != "" {
r, err := strconv.ParseBool(latest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
req.Latest = r
}
metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, metrics)
func (s *Server) RuleMetrics(c echo.Context) error {
return c.Render(http.StatusOK, "rule_metrics.html", map[string]interface{}{
"Configs": s.cfg.RelayConfigs,
})
}
func (s *Server) LogsPage(c echo.Context) error {
return c.Render(http.StatusOK, "logs.html", nil)
}

View File

@@ -0,0 +1,33 @@
package web
import (
"net"
"github.com/Ehco1996/ehco/pkg/log"
"github.com/gobwas/ws"
"github.com/labstack/echo/v4"
)
func (s *Server) handleWebSocketLogs(c echo.Context) error {
conn, _, _, err := ws.UpgradeHTTP(c.Request(), c.Response())
if err != nil {
return err
}
defer conn.Close()
log.SetWebSocketConn(conn)
// 保持连接打开并处理可能的入站消息
for {
_, err := ws.ReadFrame(conn)
if err != nil {
if _, ok := err.(net.Error); ok {
// 处理网络错误
s.l.Errorf("WebSocket read error: %v", err)
}
break
}
}
log.SetWebSocketConn(nil)
return nil
}

View File

@@ -1,393 +0,0 @@
const MetricsModule = (function () {
// Constants
const API_BASE_URL = '/api/v1';
const NODE_METRICS_PATH = '/node_metrics/';
const BYTE_TO_MB = 1024 * 1024;
const handleError = (error) => {
console.error('Error:', error);
};
// API functions
const fetchData = async (path, params = {}) => {
const url = new URL(API_BASE_URL + path, window.location.origin);
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
try {
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
handleError(error);
return null;
}
};
const fetchLatestMetric = () => fetchData(NODE_METRICS_PATH, { latest: true }).then((data) => data?.data[0]);
const fetchMetrics = (startTs, endTs) => fetchData(NODE_METRICS_PATH, { start_ts: startTs, end_ts: endTs }).then((data) => data?.data);
// Chart functions
const initChart = (canvasId, type, datasets, legendPosition = '', yDisplayText = '', title = '', unit = '') => {
const ctx = $(`#${canvasId}`)[0].getContext('2d');
const colors = {
cpu: 'rgba(255, 99, 132, 1)',
memory: 'rgba(54, 162, 235, 1)',
disk: 'rgba(255, 206, 86, 1)',
receive: 'rgba(0, 150, 255, 1)',
transmit: 'rgba(255, 140, 0, 1)',
};
const getDatasetConfig = (label) => {
const color = colors[label.toLowerCase()] || 'rgba(0, 0, 0, 1)';
return {
label,
borderColor: color,
backgroundColor: color.replace('1)', '0.2)'),
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 2,
fill: true,
data: [],
};
};
const data = {
labels: [],
datasets: $.isArray(datasets) ? datasets.map((dataset) => getDatasetConfig(dataset.label)) : [getDatasetConfig(datasets.label)],
};
return new Chart(ctx, {
type,
data,
options: {
line: {
spanGaps: false, // 设置为 false不连接空值
},
responsive: true,
plugins: {
legend: { position: legendPosition },
title: {
display: !!title,
text: title,
position: 'bottom',
font: { size: 14, weight: 'bold' },
},
tooltip: {
callbacks: {
title: function (tooltipItems) {
return new Date(tooltipItems[0].label).toLocaleString();
},
label: function (context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(2) + ' ' + unit;
}
return label;
},
},
},
},
scales: {
x: {
type: 'time',
time: {
unit: 'minute',
displayFormats: {
minute: 'HH:mm',
},
},
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10,
},
adapters: {
date: {
locale: 'en',
},
},
},
y: {
beginAtZero: true,
title: { display: true, text: yDisplayText, font: { weight: 'bold' } },
},
},
elements: { line: { tension: 0.4 } },
downsample: {
enabled: true,
threshold: 200,
},
},
});
};
const updateChart = (chart, newData, labels) => {
if (!newData || !labels) {
console.error('Invalid data or labels provided');
return;
}
if ($.isArray(newData) && $.isArray(newData[0])) {
$.each(chart.data.datasets, (index, dataset) => {
if (newData[index]) {
dataset.data = newData[index].map((value, i) => ({ x: moment(labels[i]), y: value }));
}
});
} else {
chart.data.datasets[0].data = newData.map((value, i) => ({ x: moment(labels[i]), y: value }));
}
chart.options.scales.x.min = moment(labels[0]);
chart.options.scales.x.max = moment(labels[labels.length - 1]);
chart.update();
};
const updateCharts = (charts, metrics, startTs, endTs) => {
console.log('Raw metrics data:', metrics);
const generateTimestamps = (start, end) => {
const timestamps = [];
let current = moment.unix(start);
const endMoment = moment.unix(end);
while (current.isSameOrBefore(endMoment)) {
timestamps.push(current.toISOString());
current.add(1, 'minute');
}
return timestamps;
};
const timestamps = generateTimestamps(startTs, endTs);
const processData = (dataKey) => {
const data = new Array(timestamps.length).fill(null);
metrics.forEach((metric) => {
const index = Math.floor((metric.timestamp - startTs) / 60);
if (index >= 0 && index < data.length) {
data[index] = metric[dataKey];
}
});
return data;
};
updateChart(charts.cpu, processData('cpu_usage'), timestamps);
updateChart(charts.memory, processData('memory_usage'), timestamps);
updateChart(charts.disk, processData('disk_usage'), timestamps);
updateChart(
charts.network,
[
processData('network_in').map((v) => (v === null ? null : v / BYTE_TO_MB)),
processData('network_out').map((v) => (v === null ? null : v / BYTE_TO_MB)),
],
timestamps
);
};
const addLatestDataToCharts = (charts, latestMetric) => {
console.log('Raw latestMetric data:', latestMetric);
const timestamp = moment.unix(latestMetric.timestamp);
$.each(charts, (key, chart) => {
// 检查是否已经有这个时间戳的数据
const existingDataIndex = chart.data.labels.findIndex((label) => label.isSame(timestamp));
if (existingDataIndex === -1) {
// 如果是新数据,添加到末尾
chart.data.labels.push(timestamp);
if (key === 'network') {
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric.network_in / BYTE_TO_MB });
chart.data.datasets[1].data.push({ x: timestamp, y: latestMetric.network_out / BYTE_TO_MB });
} else {
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric[`${key}_usage`] });
}
// 更新x轴范围但保持一定的时间窗口
const timeWindow = moment.duration(30, 'minutes'); // 设置显示的时间窗口例如30分钟
const oldestAllowedTime = moment(timestamp).subtract(timeWindow);
chart.options.scales.x.min = oldestAllowedTime;
chart.options.scales.x.max = timestamp;
// 开启图表的平移和缩放功能
chart.options.plugins.zoom = {
pan: {
enabled: true,
mode: 'x',
},
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true,
},
mode: 'x',
},
};
chart.update();
}
// 如果数据已存在,我们不做任何操作,保持现有数据
});
};
// Chart initialization
const initializeCharts = async () => {
const metric = await fetchLatestMetric();
if (!metric) return null;
return {
cpu: initChart('cpuChart', 'line', { label: 'CPU' }, 'top', 'Usage (%)', `CPU`, '%'),
memory: initChart('memoryChart', 'line', { label: 'Memory' }, 'top', 'Usage (%)', `Memory`, '%'),
disk: initChart('diskChart', 'line', { label: 'Disk' }, 'top', 'Usage (%)', `Disk`, '%'),
network: initChart(
'networkChart',
'line',
[{ label: 'Receive' }, { label: 'Transmit' }],
'top',
'Rate (MB/s)',
'Network Rate',
'MB/s'
),
};
};
// Date range functions
const setupDateRangeDropdown = (charts) => {
const $dateRangeDropdown = $('#dateRangeDropdown');
const $dateRangeButton = $('#dateRangeButton');
const $dateRangeText = $('#dateRangeText');
const $dateRangeInput = $('#dateRangeInput');
$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', function (e) {
e.preventDefault();
const range = $(this).data('range');
const now = new Date();
let start, end;
switch (range) {
case '30m':
start = new Date(now - 30 * 60 * 1000);
break;
case '1h':
start = new Date(now - 60 * 60 * 1000);
break;
case '3h':
start = new Date(now - 3 * 60 * 60 * 1000);
break;
case '6h':
start = new Date(now - 6 * 60 * 60 * 1000);
break;
case '12h':
start = new Date(now - 12 * 60 * 60 * 1000);
break;
case '24h':
start = new Date(now - 24 * 60 * 60 * 1000);
break;
case '7d':
start = new Date(now - 7 * 24 * 60 * 60 * 1000);
break;
}
end = now;
const startTs = Math.floor(start.getTime() / 1000);
const endTs = Math.floor(end.getTime() / 1000);
fetchDataForRange(charts, startTs, endTs);
$dateRangeText.text($(this).text());
$dateRangeDropdown.removeClass('is-active');
});
$dateRangeButton.on('click', (event) => {
event.stopPropagation();
$dateRangeDropdown.toggleClass('is-active');
});
$(document).on('click', (event) => {
if (!$dateRangeDropdown.has(event.target).length) {
$dateRangeDropdown.removeClass('is-active');
}
});
const picker = flatpickr($dateRangeInput[0], {
mode: 'range',
enableTime: true,
dateFormat: 'Y-m-d H:i',
onChange: function (selectedDates) {
if (selectedDates.length === 2) {
const startTs = Math.floor(selectedDates[0].getTime() / 1000);
const endTs = Math.floor(selectedDates[1].getTime() / 1000);
fetchDataForRange(charts, startTs, endTs);
const formattedStart = selectedDates[0].toLocaleString();
const formattedEnd = selectedDates[1].toLocaleString();
$dateRangeText.text(`${formattedStart} - ${formattedEnd}`);
// 关闭下拉菜单
$dateRangeDropdown.removeClass('is-active');
}
},
onClose: function () {
// 确保在日期选择器关闭时也关闭下拉菜单
$dateRangeDropdown.removeClass('is-active');
},
});
// 防止点击日期选择器时关闭下拉菜单
$dateRangeInput.on('click', (event) => {
event.stopPropagation();
});
};
const fetchDataForRange = async (charts, startTs, endTs) => {
const metrics = await fetchMetrics(startTs, endTs);
if (metrics) {
console.log('Raw metrics data:', metrics);
updateCharts(charts, metrics, startTs, endTs);
}
};
// Auto refresh functions
const setupAutoRefresh = (charts) => {
let autoRefreshInterval;
let isAutoRefreshing = false;
$('#refreshButton').click(function () {
if (isAutoRefreshing) {
clearInterval(autoRefreshInterval);
$(this).removeClass('is-info');
$(this).find('span:last').text('Auto Refresh');
isAutoRefreshing = false;
} else {
$(this).addClass('is-info');
$(this).find('span:last').text('Stop Refresh');
isAutoRefreshing = true;
refreshData(charts);
autoRefreshInterval = setInterval(() => refreshData(charts), 5000);
}
});
};
const refreshData = async (charts) => {
const latestMetric = await fetchLatestMetric();
if (latestMetric) {
addLatestDataToCharts(charts, latestMetric);
}
};
// Main initialization function
const init = async () => {
const charts = await initializeCharts();
if (charts) {
setupDateRangeDropdown(charts);
setupAutoRefresh(charts);
}
};
// Public API
return {
init: init,
};
})();
// Initialize when the DOM is ready
document.addEventListener('DOMContentLoaded', MetricsModule.init);

View File

@@ -0,0 +1,404 @@
const Config = {
API_BASE_URL: '/api/v1',
NODE_METRICS_PATH: '/node_metrics/',
BYTE_TO_MB: 1024 * 1024,
CHART_COLORS: {
cpu: 'rgba(255, 99, 132, 1)',
memory: 'rgba(54, 162, 235, 1)',
disk: 'rgba(255, 206, 86, 1)',
receive: 'rgba(0, 150, 255, 1)',
transmit: 'rgba(255, 140, 0, 1)',
},
TIME_WINDOW: 30, // minutes
AUTO_REFRESH_INTERVAL: 5000, // milliseconds
};
class ApiService {
static async fetchData(path, params = {}) {
const url = new URL(Config.API_BASE_URL + path, window.location.origin);
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
try {
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error:', error);
return null;
}
}
static async fetchLatestMetric() {
const data = await this.fetchData(Config.NODE_METRICS_PATH, { latest: true });
return data?.data[0];
}
static async fetchMetrics(startTs, endTs) {
const data = await this.fetchData(Config.NODE_METRICS_PATH, { start_ts: startTs, end_ts: endTs });
return data?.data;
}
}
class ChartManager {
constructor() {
this.charts = {};
}
initializeCharts() {
this.charts = {
cpu: this.initChart('cpuChart', 'line', { label: 'CPU' }, 'top', 'Usage (%)', 'CPU', '%'),
memory: this.initChart('memoryChart', 'line', { label: 'Memory' }, 'top', 'Usage (%)', 'Memory', '%'),
disk: this.initChart('diskChart', 'line', { label: 'Disk' }, 'top', 'Usage (%)', 'Disk', '%'),
network: this.initChart(
'networkChart',
'line',
[{ label: 'Receive' }, { label: 'Transmit' }],
'top',
'Rate (MB/s)',
'Network Rate',
'MB/s'
),
};
}
initChart(canvasId, type, datasets, legendPosition, yDisplayText, title, unit) {
const ctx = $(`#${canvasId}`)[0].getContext('2d');
const data = {
labels: [],
datasets: Array.isArray(datasets)
? datasets.map((dataset) => this.getDatasetConfig(dataset.label))
: [this.getDatasetConfig(datasets.label)],
};
return new Chart(ctx, {
type,
data,
options: this.getChartOptions(legendPosition, yDisplayText, title, unit),
});
}
getDatasetConfig(label) {
const color = Config.CHART_COLORS[label.toLowerCase()] || 'rgba(0, 0, 0, 1)';
return {
label,
borderColor: color,
backgroundColor: color.replace('1)', '0.2)'),
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 2,
fill: true,
data: [],
};
}
getChartOptions(legendPosition, yDisplayText, title, unit) {
return {
line: { spanGaps: false },
responsive: true,
plugins: {
legend: { position: legendPosition },
title: {
display: !!title,
text: title,
position: 'bottom',
font: { size: 14, weight: 'bold' },
},
tooltip: {
callbacks: {
title: (tooltipItems) => new Date(tooltipItems[0].label).toLocaleString(),
label: (context) => {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(2) + ' ' + unit;
}
return label;
},
},
},
zoom: {
pan: { enabled: true, mode: 'x' },
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
mode: 'x',
},
},
},
scales: {
x: {
type: 'time',
time: {
unit: 'minute',
displayFormats: { minute: 'HH:mm' },
},
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10,
},
adapters: {
date: { locale: 'en' },
},
},
y: {
beginAtZero: true,
title: { display: true, text: yDisplayText, font: { weight: 'bold' } },
},
},
elements: { line: { tension: 0.4 } },
downsample: {
enabled: true,
threshold: 200,
},
};
}
updateCharts(metrics, startTs, endTs) {
const timestamps = this.generateTimestamps(startTs, endTs);
const processData = (dataKey) => {
const data = new Array(timestamps.length).fill(null);
metrics.forEach((metric) => {
const index = Math.floor((metric.timestamp - startTs) / 60);
if (index >= 0 && index < data.length) {
data[index] = metric[dataKey];
}
});
return data;
};
this.updateChart(this.charts.cpu, processData('cpu_usage'), timestamps);
this.updateChart(this.charts.memory, processData('memory_usage'), timestamps);
this.updateChart(this.charts.disk, processData('disk_usage'), timestamps);
this.updateChart(
this.charts.network,
[
processData('network_in').map((v) => (v === null ? null : v / Config.BYTE_TO_MB)),
processData('network_out').map((v) => (v === null ? null : v / Config.BYTE_TO_MB)),
],
timestamps
);
}
updateChart(chart, newData, labels) {
if (!newData || !labels) {
console.error('Invalid data or labels provided');
return;
}
if (Array.isArray(newData) && Array.isArray(newData[0])) {
chart.data.datasets.forEach((dataset, index) => {
if (newData[index]) {
dataset.data = newData[index].map((value, i) => ({ x: moment(labels[i]), y: value }));
}
});
} else {
chart.data.datasets[0].data = newData.map((value, i) => ({ x: moment(labels[i]), y: value }));
}
chart.options.scales.x.min = moment(labels[0]);
chart.options.scales.x.max = moment(labels[labels.length - 1]);
chart.update();
}
addLatestDataToCharts(latestMetric) {
const timestamp = moment.unix(latestMetric.timestamp);
Object.entries(this.charts).forEach(([key, chart]) => {
const existingDataIndex = chart.data.labels.findIndex((label) => label.isSame(timestamp));
if (existingDataIndex === -1) {
chart.data.labels.push(timestamp);
if (key === 'network') {
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric.network_in / Config.BYTE_TO_MB });
chart.data.datasets[1].data.push({ x: timestamp, y: latestMetric.network_out / Config.BYTE_TO_MB });
} else {
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric[`${key}_usage`] });
}
const timeWindow = moment.duration(Config.TIME_WINDOW, 'minutes');
const oldestAllowedTime = moment(timestamp).subtract(timeWindow);
chart.options.scales.x.min = oldestAllowedTime;
chart.options.scales.x.max = timestamp;
chart.update();
}
});
}
generateTimestamps(start, end) {
const timestamps = [];
let current = moment.unix(start);
const endMoment = moment.unix(end);
while (current.isSameOrBefore(endMoment)) {
timestamps.push(current.toISOString());
current.add(1, 'minute');
}
return timestamps;
}
}
class DateRangeManager {
constructor(chartManager) {
this.chartManager = chartManager;
this.$dateRangeDropdown = $('#dateRangeDropdown');
this.$dateRangeButton = $('#dateRangeButton');
this.$dateRangeText = $('#dateRangeText');
this.$dateRangeInput = $('#dateRangeInput');
this.setupEventListeners();
}
setupEventListeners() {
this.$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', (e) => this.handlePresetDateRange(e));
this.$dateRangeButton.on('click', (event) => this.toggleDropdown(event));
$(document).on('click', (event) => this.closeDropdownOnOutsideClick(event));
this.initializeDatePicker();
}
handlePresetDateRange(e) {
e.preventDefault();
const range = $(e.currentTarget).data('range');
const [start, end] = this.calculateDateRange(range);
this.fetchAndUpdateCharts(start, end);
this.$dateRangeText.text($(e.currentTarget).text());
this.$dateRangeDropdown.removeClass('is-active');
}
calculateDateRange(range) {
const now = new Date();
let start;
switch (range) {
case '30m':
start = new Date(now - 30 * 60 * 1000);
break;
case '1h':
start = new Date(now - 60 * 60 * 1000);
break;
case '3h':
start = new Date(now - 3 * 60 * 60 * 1000);
break;
case '6h':
start = new Date(now - 6 * 60 * 60 * 1000);
break;
case '12h':
start = new Date(now - 12 * 60 * 60 * 1000);
break;
case '24h':
start = new Date(now - 24 * 60 * 60 * 1000);
break;
case '7d':
start = new Date(now - 7 * 24 * 60 * 60 * 1000);
break;
}
return [start, now];
}
toggleDropdown(event) {
event.stopPropagation();
this.$dateRangeDropdown.toggleClass('is-active');
}
closeDropdownOnOutsideClick(event) {
if (!this.$dateRangeDropdown.has(event.target).length) {
this.$dateRangeDropdown.removeClass('is-active');
}
}
initializeDatePicker() {
flatpickr(this.$dateRangeInput[0], {
mode: 'range',
enableTime: true,
dateFormat: 'Y-m-d H:i',
onChange: (selectedDates) => this.handleDatePickerChange(selectedDates),
onClose: () => this.$dateRangeDropdown.removeClass('is-active'),
});
this.$dateRangeInput.on('click', (event) => event.stopPropagation());
}
handleDatePickerChange(selectedDates) {
if (selectedDates.length === 2) {
const [start, end] = selectedDates;
this.fetchAndUpdateCharts(start, end);
const formattedStart = start.toLocaleString();
const formattedEnd = end.toLocaleString();
this.$dateRangeText.text(`${formattedStart} - ${formattedEnd}`);
this.$dateRangeDropdown.removeClass('is-active');
}
}
async fetchAndUpdateCharts(start, end) {
const startTs = Math.floor(start.getTime() / 1000);
const endTs = Math.floor(end.getTime() / 1000);
const metrics = await ApiService.fetchMetrics(startTs, endTs);
if (metrics) {
this.chartManager.updateCharts(metrics, startTs, endTs);
}
}
}
class AutoRefreshManager {
constructor(chartManager) {
this.chartManager = chartManager;
this.autoRefreshInterval = null;
this.isAutoRefreshing = false;
this.$refreshButton = $('#refreshButton');
this.setupEventListeners();
}
setupEventListeners() {
this.$refreshButton.click(() => this.toggleAutoRefresh());
}
toggleAutoRefresh() {
if (this.isAutoRefreshing) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh();
}
}
startAutoRefresh() {
this.isAutoRefreshing = true;
this.$refreshButton.addClass('is-info');
this.$refreshButton.find('span:last').text('Stop Refresh');
this.refreshData();
this.autoRefreshInterval = setInterval(() => this.refreshData(), Config.AUTO_REFRESH_INTERVAL);
}
stopAutoRefresh() {
this.isAutoRefreshing = false;
clearInterval(this.autoRefreshInterval);
this.$refreshButton.removeClass('is-info');
this.$refreshButton.find('span:last').text('Auto Refresh');
}
async refreshData() {
const latestMetric = await ApiService.fetchLatestMetric();
if (latestMetric) {
this.chartManager.addLatestDataToCharts(latestMetric);
}
}
}
class MetricsModule {
constructor() {
this.chartManager = new ChartManager();
this.dateRangeManager = new DateRangeManager(this.chartManager);
this.autoRefreshManager = new AutoRefreshManager(this.chartManager);
}
async init() {
this.chartManager.initializeCharts();
}
}
// Initialize when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const metricsModule = new MetricsModule();
metricsModule.init();
});

View File

@@ -0,0 +1,402 @@
const Config = {
API_BASE_URL: '/api/v1',
RULE_METRICS_PATH: '/rule_metrics/',
BYTE_TO_MB: 1024 * 1024,
CHART_COLORS: {
connectionCount: 'rgba(255, 99, 132, 1)',
handshakeDuration: 'rgba(54, 162, 235, 1)',
pingLatency: 'rgba(255, 206, 86, 1)',
networkTransmitBytes: 'rgba(75, 192, 192, 1)',
},
TIME_WINDOW: 30, // minutes
AUTO_REFRESH_INTERVAL: 5000, // milliseconds
};
class ApiService {
static async fetchData(path, params = {}) {
const url = new URL(Config.API_BASE_URL + path, window.location.origin);
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
try {
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error:', error);
return null;
}
}
static async fetchRuleMetrics(startTs, endTs, label = '', remote = '') {
const params = { start_ts: startTs, end_ts: endTs };
if (label) params.label = label;
if (remote) params.remote = remote;
return await this.fetchData(Config.RULE_METRICS_PATH, params);
}
static async fetchConfig() {
return await this.fetchData('/config/');
}
static async fetchLabelsAndRemotes() {
const config = await this.fetchConfig();
if (!config || !config.relay_configs) {
return { labels: [], remotes: [] };
}
const labels = new Set();
const remotes = new Set();
config.relay_configs.forEach((relayConfig) => {
if (relayConfig.label) labels.add(relayConfig.label);
if (relayConfig.remotes) {
relayConfig.remotes.forEach((remote) => remotes.add(remote));
}
});
return {
labels: Array.from(labels),
remotes: Array.from(remotes),
};
}
}
class ChartManager {
constructor() {
this.charts = {};
}
initializeCharts() {
this.charts = {
connectionCount: this.initChart('connectionCountChart', 'line', 'Connection Count', 'Count'),
handshakeDuration: this.initChart('handshakeDurationChart', 'line', 'Handshake Duration', 'ms'),
pingLatency: this.initChart('pingLatencyChart', 'line', 'Ping Latency', 'ms'),
networkTransmitBytes: this.initChart('networkTransmitBytesChart', 'line', 'Network Transmit', 'MB'),
};
}
initChart(canvasId, type, title, unit) {
const ctx = $(`#${canvasId}`)[0].getContext('2d');
const color = Config.CHART_COLORS[canvasId.replace('Chart', '')];
return new Chart(ctx, {
type: type,
data: {
labels: [],
datasets: [
{
label: title,
borderColor: color,
backgroundColor: color.replace('1)', '0.2)'),
borderWidth: 2,
data: [],
},
],
},
options: this.getChartOptions(title, unit),
});
}
getChartOptions(title, unit) {
return {
responsive: true,
plugins: {
title: {
display: true,
text: title,
font: { size: 16, weight: 'bold' },
},
tooltip: {
callbacks: {
label: (context) => `${context.dataset.label}: ${context.parsed.y.toFixed(2)} ${unit}`,
},
},
},
scales: {
x: {
type: 'time',
time: { unit: 'minute', displayFormats: { minute: 'HH:mm' } },
title: { display: true, text: 'Time' },
},
y: {
beginAtZero: true,
title: { display: true, text: unit },
},
},
};
}
fillMissingDataPoints(data, startTime, endTime) {
const filledData = [];
let currentTime = new Date(startTime);
const endTimeDate = new Date(endTime);
while (currentTime <= endTimeDate) {
const existingPoint = data.find((point) => Math.abs(point.x.getTime() - currentTime.getTime()) < 60000);
if (existingPoint) {
filledData.push(existingPoint);
} else {
filledData.push({ x: new Date(currentTime), y: null });
}
currentTime.setMinutes(currentTime.getMinutes() + 1);
}
return filledData;
}
updateCharts(metrics, startTime, endTime) {
// 检查metrics是否为null或undefined
if (!metrics) {
// 如果为null则更新所有图表为空
Object.values(this.charts).forEach((chart) => {
chart.data.datasets = [
{
label: 'No Data',
data: [],
},
];
chart.update();
});
return;
}
// 首先按时间正序排列数据
metrics.sort((a, b) => a.timestamp - b.timestamp);
// 按 label-remote 分组
const groupedMetrics = this.groupMetricsByLabelRemote(metrics);
console.log('groupedMetrics', groupedMetrics);
// 预处理所有指标的数据
const processedData = {};
Object.keys(this.charts).forEach((key) => {
processedData[key] = groupedMetrics.map((group, index) => {
const data = group.metrics.map((m) => ({
x: new Date(m.timestamp * 1000),
y: this.getMetricValue(key, m),
}));
const filledData = this.fillMissingDataPoints(data, startTime, endTime);
return {
label: `${group.label} - ${group.remote}`,
borderColor: this.getColor(index),
backgroundColor: this.getColor(index, 0.2),
borderWidth: 2,
data: filledData,
};
});
});
// 更新每个图表
Object.entries(this.charts).forEach(([key, chart]) => {
chart.data.datasets = processedData[key];
chart.update();
});
}
groupMetricsByLabelRemote(metrics) {
const groups = {};
metrics.forEach((metric) => {
const key = `${metric.label}-${metric.remote}`;
if (!groups[key]) {
groups[key] = { label: metric.label, remote: metric.remote, metrics: [] };
}
groups[key].metrics.push(metric);
});
return Object.values(groups);
}
getMetricValue(metricType, metric) {
switch (metricType) {
case 'connectionCount':
return metric.tcp_connection_count + metric.udp_connection_count;
case 'handshakeDuration':
return Math.max(metric.tcp_handshake_duration, metric.udp_handshake_duration);
case 'pingLatency':
return metric.ping_latency;
case 'networkTransmitBytes':
return (metric.tcp_network_transmit_bytes + metric.udp_network_transmit_bytes) / Config.BYTE_TO_MB;
default:
return 0;
}
}
getColor(index, alpha = 1) {
const colors = [
`rgba(255, 99, 132, ${alpha})`,
`rgba(54, 162, 235, ${alpha})`,
`rgba(255, 206, 86, ${alpha})`,
`rgba(75, 192, 192, ${alpha})`,
`rgba(153, 102, 255, ${alpha})`,
`rgba(255, 159, 64, ${alpha})`,
];
return colors[index % colors.length];
}
}
class FilterManager {
constructor(chartManager, dateRangeManager) {
this.chartManager = chartManager;
this.dateRangeManager = dateRangeManager;
this.$labelFilter = $('#labelFilter');
this.$remoteFilter = $('#remoteFilter');
this.relayConfigs = [];
this.currentStartDate = null;
this.currentEndDate = null;
this.setupEventListeners();
this.loadFilters();
}
setupEventListeners() {
this.$labelFilter.on('change', () => this.onLabelChange());
this.$remoteFilter.on('change', () => this.applyFilters());
}
async loadFilters() {
const config = await ApiService.fetchConfig();
if (config && config.relay_configs) {
this.relayConfigs = config.relay_configs;
this.populateLabelFilter();
this.onLabelChange(); // Initialize remotes for the first label
}
}
populateLabelFilter() {
const labels = [...new Set(this.relayConfigs.map((config) => config.label))];
this.populateFilter(this.$labelFilter, labels);
}
onLabelChange() {
const selectedLabel = this.$labelFilter.val();
const remotes = this.getRemotesForLabel(selectedLabel);
this.populateFilter(this.$remoteFilter, remotes);
this.applyFilters();
}
getRemotesForLabel(label) {
const config = this.relayConfigs.find((c) => c.label === label);
return config ? config.remotes : [];
}
populateFilter($select, options) {
$select.empty().append($('<option>', { value: '', text: 'All' }));
options.forEach((option) => {
$select.append($('<option>', { value: option, text: option }));
});
}
async applyFilters() {
const label = this.$labelFilter.val();
const remote = this.$remoteFilter.val();
// 使用当前保存的日期范围如果没有则使用默认的30分钟
const endDate = this.currentEndDate || new Date();
const startDate = this.currentStartDate || new Date(endDate - Config.TIME_WINDOW * 60 * 1000);
const metrics = await ApiService.fetchRuleMetrics(
Math.floor(startDate.getTime() / 1000),
Math.floor(endDate.getTime() / 1000),
label,
remote
);
this.chartManager.updateCharts(metrics.data, startDate, endDate);
}
setDateRange(start, end) {
this.currentStartDate = start;
this.currentEndDate = end;
}
}
class DateRangeManager {
constructor(chartManager, filterManager) {
this.chartManager = chartManager;
this.filterManager = filterManager;
this.$dateRangeDropdown = $('#dateRangeDropdown');
this.$dateRangeButton = $('#dateRangeButton');
this.$dateRangeText = $('#dateRangeText');
this.$dateRangeInput = $('#dateRangeInput');
this.setupEventListeners();
}
setupEventListeners() {
this.$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', (e) => this.handlePresetDateRange(e));
this.$dateRangeButton.on('click', () => this.$dateRangeDropdown.toggleClass('is-active'));
$(document).on('click', (e) => {
if (!this.$dateRangeDropdown.has(e.target).length) {
this.$dateRangeDropdown.removeClass('is-active');
}
});
this.initializeDatePicker();
}
handlePresetDateRange(e) {
e.preventDefault();
const range = $(e.currentTarget).data('range');
const [start, end] = this.calculateDateRange(range);
this.fetchAndUpdateCharts(start, end);
this.$dateRangeText.text($(e.currentTarget).text());
this.$dateRangeDropdown.removeClass('is-active');
}
calculateDateRange(range) {
const now = new Date();
const start = new Date(now - this.getMillisecondsFromRange(range));
return [start, now];
}
getMillisecondsFromRange(range) {
const rangeMap = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'3h': 3 * 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'12h': 12 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
};
return rangeMap[range] || 30 * 60 * 1000; // Default to 30 minutes
}
initializeDatePicker() {
flatpickr(this.$dateRangeInput[0], {
mode: 'range',
enableTime: true,
dateFormat: 'Y-m-d H:i',
onChange: (selectedDates) => this.handleDatePickerChange(selectedDates),
});
}
handleDatePickerChange(selectedDates) {
if (selectedDates.length === 2) {
const [start, end] = selectedDates;
this.fetchAndUpdateCharts(start, end);
this.$dateRangeText.text(`${start.toLocaleString()} - ${end.toLocaleString()}`);
this.$dateRangeDropdown.removeClass('is-active');
}
}
async fetchAndUpdateCharts(start, end) {
this.filterManager.setDateRange(start, end);
await this.filterManager.applyFilters();
}
}
class RuleMetricsModule {
constructor() {
this.chartManager = new ChartManager();
this.filterManager = new FilterManager(this.chartManager);
this.dateRangeManager = new DateRangeManager(this.chartManager, this.filterManager);
this.filterManager.dateRangeManager = this.dateRangeManager;
}
init() {
this.chartManager.initializeCharts();
this.filterManager.applyFilters();
}
}
// Initialize when the DOM is ready
$(document).ready(() => {
const ruleMetricsModule = new RuleMetricsModule();
ruleMetricsModule.init();
});

View File

@@ -165,15 +165,22 @@ func setupRoutes(s *Server) {
e.GET(metricsPath, echo.WrapHandler(promhttp.Handler()))
e.GET("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux))
// web pages
e.GET(indexPath, s.index)
e.GET(connectionsPath, s.ListConnections)
e.GET(rulesPath, s.ListRules)
e.GET("/rule_metrics/", s.RuleMetrics)
e.GET("/logs/", s.LogsPage)
api := e.Group(apiPrefix)
api.GET("/config/", s.CurrentConfig)
api.POST("/config/reload/", s.HandleReload)
api.GET("/health_check/", s.HandleHealthCheck)
api.GET("/node_metrics/", s.GetNodeMetrics)
api.GET("/rule_metrics/", s.GetRuleMetrics)
// ws
e.GET("/ws/logs", s.handleWebSocketLogs)
}
func (s *Server) Start() error {

View File

@@ -16,18 +16,20 @@
<div class="navbar-start">
<a href="/rules/" class="navbar-item">
<span class="icon"><i class="fas fa-list"></i></span>
<span>Rule List</span>
<span>Rules</span>
</a>
<a href="/rule_metrics/" class="navbar-item">
<span class="icon"><i class="fas fa-chart-line"></i></span>
<span>Metrics</span>
</a>
<a href="/logs/" class="navbar-item">
<span class="icon"><i class="fas fa-file-alt"></i></span>
<span>Logs</span>
</a>
<a href="/connections/?conn_type=active/" class="navbar-item">
<span class="icon"><i class="fas fa-link"></i></span>
<span>Connections</span>
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<span class="icon"><i class="fas fa-link"></i></span>
<span>Connections</span>
</a>
<div class="navbar-dropdown">
<a href="/connections/?conn_type=active" class="navbar-item">Active</a>
<a href="/connections/?conn_type=closed" class="navbar-item">Closed</a>
</div>
</div>
</div>
<div class="navbar-end">

View File

@@ -52,6 +52,7 @@
</div>
</div>
</div>
<!-- </div> -->
<script src="js/metrics.js"></script>
</div>
<script src="/js/node_metrics.js"></script>
</script>

View File

@@ -0,0 +1,82 @@
<div class="card" id="rule-metrics-card">
<header class="card-header is-flex is-flex-wrap-wrap">
<p class="card-header-title has-text-centered">Rule Metrics</p>
<div class="card-header-icon is-flex-grow-1 is-flex is-justify-content-space-between">
<div class="field is-horizontal mr-2">
<div class="field-label is-small mr-2">
<label class="label" for="labelFilter">Label:</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-small">
<select id="labelFilter" aria-label="Filter by label">
<option value="">All Labels</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal mr-2">
<div class="field-label is-small mr-2">
<label class="label" for="remoteFilter">Remote:</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-small">
<select id="remoteFilter" aria-label="Filter by remote">
<option value="">All Remotes</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="dropdown" id="dateRangeDropdown">
<div class="dropdown-trigger">
<button class="button is-small" aria-haspopup="true" aria-controls="dropdown-menu" id="dateRangeButton">
<span id="dateRangeText">Select date range</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a href="#" class="dropdown-item" data-range="30m">Last 30 minutes</a>
<a href="#" class="dropdown-item" data-range="1h">Last 1 hour</a>
<a href="#" class="dropdown-item" data-range="3h">Last 3 hours</a>
<a href="#" class="dropdown-item" data-range="6h">Last 6 hours</a>
<a href="#" class="dropdown-item" data-range="12h">Last 12 hours</a>
<a href="#" class="dropdown-item" data-range="24h">Last 24 hours</a>
<a href="#" class="dropdown-item" data-range="7d">Last 7 days</a>
<hr class="dropdown-divider" />
<a href="#" class="dropdown-item" id="dateRangeInput">Custom range</a>
</div>
</div>
</div>
</div>
</header>
<div class="card-content">
<div class="content">
<div class="columns is-multiline">
<div class="column is-6">
<canvas id="connectionCountChart"></canvas>
</div>
<div class="column is-6">
<canvas id="handshakeDurationChart"></canvas>
</div>
<div class="column is-12">
<canvas id="pingLatencyChart"></canvas>
</div>
<div class="column is-12">
<canvas id="networkTransmitBytesChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<script src="/js/rule_metrics.js"></script>

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
{{template "_head.html" .}}
<body>
@@ -32,7 +32,7 @@
</div>
</div>
<!-- metrics -->
{{template "_metrics.html" .}}
{{template "_node_metrics_dash.html" .}}
</div>
<!-- Footer -->

View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
{{template "_head.html" .}}
<style>
.logs-container {
height: 700px;
overflow-y: auto;
font-family: 'Fira Code', monospace;
border-radius: 6px;
background-color: #f5f5f5;
padding: 1rem;
transition: background-color 0.3s ease;
}
.log-entry {
margin-bottom: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.3s ease;
}
</style>
<body>
{{ template "_navbar.html" . }}
<section class="section">
<div class="container-fluid">
<h1 class="title is-2 mb-6">Real-time Logs</h1>
<div class="card">
<div class="card-content">
<div class="field has-addons mb-4">
<div class="control is-expanded">
<input class="input is-medium" type="text" id="filterInput" placeholder="Filter logs..." />
</div>
<div class="control">
<button class="button is-info is-medium" id="filterButton">
<span class="icon">
<i class="fas fa-filter"></i>
</span>
</button>
</div>
</div>
<div class="buttons mb-4">
<button class="button is-warning is-medium" id="pauseButton">
<span class="icon">
<i class="fas fa-pause"></i>
</span>
<span>Pause</span>
</button>
<button class="button is-danger is-medium" id="clearButton">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
<span>Clear</span>
</button>
</div>
<div id="logsContainer" class="logs-container"></div>
</div>
</div>
</div>
</section>
<script>
const logsContainer = document.getElementById('logsContainer');
const filterInput = document.getElementById('filterInput');
const filterButton = document.getElementById('filterButton');
const pauseButton = document.getElementById('pauseButton');
const clearButton = document.getElementById('clearButton');
let isPaused = false;
let ws;
let filterTerm = '';
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '/ws/logs');
ws.onopen = function (event) {
console.log('WebSocket connection established');
};
ws.onerror = function (event) {
console.error('WebSocket error observed:', event);
};
ws.onmessage = function (event) {
if (!isPaused) {
const logEntry = document.createElement('div');
logEntry.innerHTML = formatLogMessage(event.data);
if (shouldDisplayLog(logEntry.textContent)) {
logsContainer.appendChild(logEntry);
logsContainer.scrollTop = logsContainer.scrollHeight;
}
}
};
ws.onclose = function (event) {
console.log('WebSocket connection closed. Reconnecting...');
setTimeout(connectWebSocket, 1000);
};
}
function formatLogMessage(message) {
try {
const logEntry = JSON.parse(message);
const timestamp = logEntry.ts;
const level = logEntry.level;
const msg = logEntry.msg;
const logger = logEntry.logger;
console.log('Log entry:', logEntry);
const levelClass = getLevelClass(level);
return `
<div class="columns is-mobile">
<div class="column is-3"><span class="has-text-grey-light is-small">${timestamp}</span></div>
<div class="column is-1"><span class="tag ${levelClass} is-small">${level.toUpperCase()}</span></div>
<div class="column is-2"><span class="is-info is-light is-small">${logger}</span></div>
<div class="column"><span>${msg}</span></div>
</div>
`;
} catch (e) {
console.error('Error parsing log message:', e);
return `<div class="has-text-danger">${message}</div>`;
}
}
function getLevelClass(level) {
switch (level) {
case 'debug':
return 'is-light';
case 'info':
return 'is-info';
case 'warn':
return 'is-warning';
case 'error':
return 'is-danger';
case 'fatal':
return 'is-dark';
default:
return 'is-light';
}
}
function shouldDisplayLog(logText) {
return filterTerm === '' || logText.toLowerCase().includes(filterTerm.toLowerCase());
}
function applyFilter() {
filterTerm = filterInput.value;
const logEntries = logsContainer.getElementsByClassName('log-entry');
for (let entry of logEntries) {
if (shouldDisplayLog(entry.textContent)) {
entry.style.display = '';
} else {
entry.style.display = 'none';
}
}
}
connectWebSocket();
filterButton.addEventListener('click', applyFilter);
filterInput.addEventListener('keyup', function (event) {
if (event.key === 'Enter') {
applyFilter();
}
});
pauseButton.addEventListener('click', function () {
isPaused = !isPaused;
pauseButton.textContent = isPaused ? 'Resume' : 'Pause';
pauseButton.classList.toggle('is-warning');
pauseButton.classList.toggle('is-success');
});
clearButton.addEventListener('click', function () {
logsContainer.innerHTML = '';
});
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
{{template "_head.html" .}}
<body>
@@ -53,7 +53,7 @@
response.msg + // Use 'msg' as per Go struct
' (Latency: ' +
response.latency + // Ensure this matches the Go struct field name
'ms)',
'ms)'
);
} else {
// If error code is not 0, show error message

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
{{template "_head.html" .}}
<body>
{{ template "_navbar.html" . }}
<section class="section">
<div class="container">
<h1 class="title">Rule Metrics</h1>
{{template "_rule_metrics_dash.html" .}}
</div>
</section>
</body>
</html>

View File

@@ -11,6 +11,8 @@ import (
var (
doOnce sync.Once
globalInitd bool
globalWebSocketSyncher *WebSocketLogSyncher
)
func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
@@ -18,8 +20,8 @@ func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
if err := level.UnmarshalText([]byte(logLevel)); err != nil {
return nil, err
}
writers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
encoder := zapcore.EncoderConfig{
consoleEncoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
MessageKey: "msg",
@@ -27,12 +29,29 @@ func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
EncodeLevel: zapcore.LowercaseColorLevelEncoder,
EncodeTime: zapcore.RFC3339TimeEncoder,
EncodeName: zapcore.FullNameEncoder,
}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoder),
zapcore.NewMultiWriteSyncer(writers...),
level,
)
})
stdoutCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
jsonEncoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
})
globalWebSocketSyncher = NewWebSocketLogSyncher()
wsCore := zapcore.NewCore(jsonEncoder, globalWebSocketSyncher, level)
// 合并两个 core
core := zapcore.NewTee(stdoutCore, wsCore)
l := zap.New(core)
if replaceGlobal {
zap.ReplaceGlobals(l)

52
echo/pkg/log/ws.go Normal file
View File

@@ -0,0 +1,52 @@
package log
import (
"encoding/json"
"net"
"sync"
"github.com/gobwas/ws"
)
type WebSocketLogSyncher struct {
conn net.Conn
mu sync.Mutex
}
func NewWebSocketLogSyncher() *WebSocketLogSyncher {
return &WebSocketLogSyncher{}
}
func (wsSync *WebSocketLogSyncher) Write(p []byte) (n int, err error) {
wsSync.mu.Lock()
defer wsSync.mu.Unlock()
if wsSync.conn != nil {
var logEntry map[string]interface{}
if err := json.Unmarshal(p, &logEntry); err == nil {
jsonData, _ := json.Marshal(logEntry)
_ = ws.WriteFrame(wsSync.conn, ws.NewTextFrame(jsonData))
}
if err != nil {
return 0, err
}
}
return len(p), nil
}
func (wsSync *WebSocketLogSyncher) Sync() error {
return nil
}
func (wsSync *WebSocketLogSyncher) SetWSConn(conn net.Conn) {
wsSync.mu.Lock()
defer wsSync.mu.Unlock()
wsSync.conn = conn
}
func SetWebSocketConn(conn net.Conn) {
if globalWebSocketSyncher != nil {
globalWebSocketSyncher.SetWSConn(conn)
}
}

View File

@@ -0,0 +1,165 @@
package metric_reader
import (
"fmt"
"math"
"strings"
"time"
dto "github.com/prometheus/client_model/go"
)
const (
metricCPUSecondsTotal = "node_cpu_seconds_total"
metricLoad1 = "node_load1"
metricLoad5 = "node_load5"
metricLoad15 = "node_load15"
metricMemoryTotalBytes = "node_memory_total_bytes"
metricMemoryActiveBytes = "node_memory_active_bytes"
metricMemoryWiredBytes = "node_memory_wired_bytes"
metricMemoryMemTotalBytes = "node_memory_MemTotal_bytes"
metricMemoryMemAvailableBytes = "node_memory_MemAvailable_bytes"
metricFilesystemSizeBytes = "node_filesystem_size_bytes"
metricFilesystemAvailBytes = "node_filesystem_avail_bytes"
metricNetworkReceiveBytesTotal = "node_network_receive_bytes_total"
metricNetworkTransmitBytesTotal = "node_network_transmit_bytes_total"
)
type NodeMetrics struct {
// cpu
CpuCoreCount int `json:"cpu_core_count"`
CpuLoadInfo string `json:"cpu_load_info"`
CpuUsagePercent float64 `json:"cpu_usage_percent"`
// memory
MemoryTotalBytes int64 `json:"memory_total_bytes"`
MemoryUsageBytes int64 `json:"memory_usage_bytes"`
MemoryUsagePercent float64 `json:"memory_usage_percent"`
// disk
DiskTotalBytes int64 `json:"disk_total_bytes"`
DiskUsageBytes int64 `json:"disk_usage_bytes"`
DiskUsagePercent float64 `json:"disk_usage_percent"`
// network
NetworkReceiveBytesTotal int64 `json:"network_receive_bytes_total"`
NetworkTransmitBytesTotal int64 `json:"network_transmit_bytes_total"`
NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"`
NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"`
SyncTime time.Time
}
type cpuStats struct {
totalTime float64
idleTime float64
cores int
}
func (b *readerImpl) ParseNodeMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
isMac := metricMap[metricMemoryTotalBytes] != nil
cpu := &cpuStats{}
b.processCPUMetrics(metricMap, cpu)
b.processMemoryMetrics(metricMap, nm, isMac)
b.processDiskMetrics(metricMap, nm)
b.processNetworkMetrics(metricMap, nm)
b.processLoadMetrics(metricMap, nm)
b.calculateFinalMetrics(nm, cpu)
return nil
}
func (b *readerImpl) processCPUMetrics(metricMap map[string]*dto.MetricFamily, cpu *cpuStats) {
if cpuMetric, ok := metricMap[metricCPUSecondsTotal]; ok {
for _, metric := range cpuMetric.Metric {
value := getMetricValue(metric, cpuMetric.GetType())
cpu.totalTime += value
if getLabel(metric, "mode") == "idle" {
cpu.idleTime += value
cpu.cores++
}
}
}
}
func (b *readerImpl) processMemoryMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics, isMac bool) {
if isMac {
nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryTotalBytes)
nm.MemoryUsageBytes = sumInt64Metric(metricMap, metricMemoryActiveBytes) + sumInt64Metric(metricMap, metricMemoryWiredBytes)
} else {
nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryMemTotalBytes)
availableMemory := sumInt64Metric(metricMap, metricMemoryMemAvailableBytes)
nm.MemoryUsageBytes = nm.MemoryTotalBytes - availableMemory
}
}
func (b *readerImpl) processDiskMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
nm.DiskTotalBytes = sumInt64Metric(metricMap, metricFilesystemSizeBytes)
availableDisk := sumInt64Metric(metricMap, metricFilesystemAvailBytes)
nm.DiskUsageBytes = nm.DiskTotalBytes - availableDisk
}
func (b *readerImpl) processNetworkMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
nm.NetworkReceiveBytesTotal = sumInt64Metric(metricMap, metricNetworkReceiveBytesTotal)
nm.NetworkTransmitBytesTotal = sumInt64Metric(metricMap, metricNetworkTransmitBytesTotal)
}
func (b *readerImpl) processLoadMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
loads := []string{metricLoad1, metricLoad5, metricLoad15}
for _, load := range loads {
value := sumFloat64Metric(metricMap, load)
nm.CpuLoadInfo += fmt.Sprintf("%.2f|", value)
}
nm.CpuLoadInfo = strings.TrimRight(nm.CpuLoadInfo, "|")
}
func (b *readerImpl) calculateFinalMetrics(nm *NodeMetrics, cpu *cpuStats) {
nm.CpuCoreCount = cpu.cores
nm.CpuUsagePercent = 100 * (cpu.totalTime - cpu.idleTime) / cpu.totalTime
nm.MemoryUsagePercent = 100 * float64(nm.MemoryUsageBytes) / float64(nm.MemoryTotalBytes)
nm.DiskUsagePercent = 100 * float64(nm.DiskUsageBytes) / float64(nm.DiskTotalBytes)
nm.CpuUsagePercent = math.Round(nm.CpuUsagePercent*100) / 100
nm.MemoryUsagePercent = math.Round(nm.MemoryUsagePercent*100) / 100
nm.DiskUsagePercent = math.Round(nm.DiskUsagePercent*100) / 100
if b.lastMetrics != nil {
duration := time.Since(b.lastMetrics.SyncTime).Seconds()
if duration > 0.1 {
nm.NetworkReceiveBytesRate = math.Max(0, float64(nm.NetworkReceiveBytesTotal-b.lastMetrics.NetworkReceiveBytesTotal)/duration)
nm.NetworkTransmitBytesRate = math.Max(0, float64(nm.NetworkTransmitBytesTotal-b.lastMetrics.NetworkTransmitBytesTotal)/duration)
nm.NetworkReceiveBytesRate = math.Round(nm.NetworkReceiveBytesRate)
nm.NetworkTransmitBytesRate = math.Round(nm.NetworkTransmitBytesRate)
}
}
}
func sumInt64Metric(metricMap map[string]*dto.MetricFamily, metricName string) int64 {
ret := int64(0)
if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 {
for _, m := range metric.Metric {
ret += int64(getMetricValue(m, metric.GetType()))
}
}
return ret
}
func sumFloat64Metric(metricMap map[string]*dto.MetricFamily, metricName string) float64 {
ret := float64(0)
if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 {
for _, m := range metric.Metric {
ret += getMetricValue(m, metric.GetType())
}
}
return 0
}
func getLabel(metric *dto.Metric, name string) string {
for _, label := range metric.Label {
if label.GetName() == name {
return label.GetValue()
}
}
return ""
}

View File

@@ -2,25 +2,28 @@ package metric_reader
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"go.uber.org/zap"
)
type Reader interface {
ReadOnce(ctx context.Context) (*NodeMetrics, error)
ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error)
}
type readerImpl struct {
metricsURL string
httpClient *http.Client
lastMetrics *NodeMetrics
metricsURL string
httpClient *http.Client
lastMetrics *NodeMetrics
lastRuleMetrics map[string]*RuleMetrics // key: label value: RuleMetrics
l *zap.SugaredLogger
}
func NewReader(metricsURL string) *readerImpl {
@@ -28,267 +31,47 @@ func NewReader(metricsURL string) *readerImpl {
return &readerImpl{
httpClient: c,
metricsURL: metricsURL,
l: zap.S().Named("metric_reader"),
}
}
func (b *readerImpl) parsePingInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
metric, ok := metricMap["ehco_ping_response_duration_seconds"]
if !ok {
// this metric is optional when enable_ping = false
zap.S().Debug("ping metric not found")
return nil
}
for _, m := range metric.Metric {
g := m.GetHistogram()
ip := ""
val := float64(g.GetSampleSum()) / float64(g.GetSampleCount()) * 1000 // to ms
for _, label := range m.GetLabel() {
if label.GetName() == "ip" {
ip = label.GetValue()
}
}
nm.PingMetrics = append(nm.PingMetrics, PingMetric{Latency: val, Target: ip})
}
return nil
}
func (b *readerImpl) parseCpuInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
handleMetric := func(metricName string, handleValue func(float64, string)) error {
metric, ok := metricMap[metricName]
if !ok {
return fmt.Errorf("%s not found", metricName)
}
for _, m := range metric.Metric {
g := m.GetCounter()
mode := ""
for _, label := range m.GetLabel() {
if label.GetName() == "mode" {
mode = label.GetValue()
}
}
handleValue(g.GetValue(), mode)
}
return nil
}
var (
totalIdleTime float64
totalCpuTime float64
cpuCores int
)
err := handleMetric("node_cpu_seconds_total", func(val float64, mode string) {
totalCpuTime += val
if mode == "idle" {
totalIdleTime += val
cpuCores++
}
})
func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) {
metricMap, err := b.fetchMetrics(ctx)
if err != nil {
return err
return nil, nil, errors.Wrap(err, "failed to fetch metrics")
}
nm := &NodeMetrics{SyncTime: time.Now()}
if err := b.ParseNodeMetrics(metricMap, nm); err != nil {
return nil, nil, err
}
nm.CpuCoreCount = cpuCores
nm.CpuUsagePercent = 100 * (totalCpuTime - totalIdleTime) / totalCpuTime
for _, load := range []string{"1", "5", "15"} {
loadMetricName := fmt.Sprintf("node_load%s", load)
loadMetric, ok := metricMap[loadMetricName]
if !ok {
return fmt.Errorf("%s not found", loadMetricName)
}
for _, m := range loadMetric.Metric {
g := m.GetGauge()
nm.CpuLoadInfo += fmt.Sprintf("%.2f|", g.GetValue())
}
}
nm.CpuLoadInfo = strings.TrimRight(nm.CpuLoadInfo, "|")
return nil
}
func (b *readerImpl) parseMemoryInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
handleMetric := func(metricName string, handleValue func(float64)) error {
metric, ok := metricMap[metricName]
if !ok {
return fmt.Errorf("%s not found", metricName)
}
for _, m := range metric.Metric {
g := m.GetGauge()
handleValue(g.GetValue())
}
return nil
}
isMac := false
if _, ok := metricMap["node_memory_total_bytes"]; ok {
isMac = true
}
if isMac {
err := handleMetric("node_memory_total_bytes", func(val float64) {
nm.MemoryTotalBytes = val
})
if err != nil {
return err
}
err = handleMetric("node_memory_active_bytes", func(val float64) {
nm.MemoryUsageBytes += val
})
if err != nil {
return err
}
err = handleMetric("node_memory_wired_bytes", func(val float64) {
nm.MemoryUsageBytes += val
})
if err != nil {
return err
}
} else {
err := handleMetric("node_memory_MemTotal_bytes", func(val float64) {
nm.MemoryTotalBytes = val
})
if err != nil {
return err
}
err = handleMetric("node_memory_MemAvailable_bytes", func(val float64) {
nm.MemoryUsageBytes = nm.MemoryTotalBytes - val
})
if err != nil {
return err
}
}
if nm.MemoryTotalBytes != 0 {
nm.MemoryUsagePercent = 100 * nm.MemoryUsageBytes / nm.MemoryTotalBytes
}
return nil
}
func (b *readerImpl) parseDiskInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
handleMetric := func(metricName string, handleValue func(float64)) error {
forMac := false
diskMap := make(map[string]float64)
metric, ok := metricMap[metricName]
if !ok {
return fmt.Errorf("%s not found", metricName)
}
for _, m := range metric.Metric {
g := m.GetGauge()
disk := ""
for _, label := range m.GetLabel() {
if label.GetName() == "device" {
disk = getDiskName(label.GetValue())
}
if label.GetName() == "fstype" && label.GetValue() == "apfs" {
forMac = true
}
}
diskMap[disk] = g.GetValue()
}
// 对于 macos 的 apfs 文件系统,可能会有多个相同大小的磁盘,这是因为 apfs 磁盘(卷)会共享物理磁盘
seenVal := map[float64]bool{}
for _, val := range diskMap {
if seenVal[val] && forMac {
continue
}
handleValue(val)
seenVal[val] = true
}
return nil
}
err := handleMetric("node_filesystem_size_bytes", func(val float64) {
nm.DiskTotalBytes += val
})
if err != nil {
return err
}
var availBytes float64
err = handleMetric("node_filesystem_avail_bytes", func(val float64) {
availBytes += val
})
if err != nil {
return err
}
nm.DiskUsageBytes = nm.DiskTotalBytes - availBytes
if nm.DiskTotalBytes != 0 {
nm.DiskUsagePercent = 100 * nm.DiskUsageBytes / nm.DiskTotalBytes
}
return nil
}
func (b *readerImpl) parseNetworkInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
now := time.Now()
handleMetric := func(metricName string, handleValue func(float64)) error {
metric, ok := metricMap[metricName]
if !ok {
return fmt.Errorf("%s not found", metricName)
}
for _, m := range metric.Metric {
g := m.GetCounter()
handleValue(g.GetValue())
}
return nil
}
err := handleMetric("node_network_receive_bytes_total", func(val float64) {
nm.NetworkReceiveBytesTotal += val
})
if err != nil {
return err
}
err = handleMetric("node_network_transmit_bytes_total", func(val float64) {
nm.NetworkTransmitBytesTotal += val
})
if err != nil {
return err
}
if b.lastMetrics != nil {
passedTime := now.Sub(b.lastMetrics.SyncTime).Seconds()
nm.NetworkReceiveBytesRate = (nm.NetworkReceiveBytesTotal - b.lastMetrics.NetworkReceiveBytesTotal) / passedTime
nm.NetworkTransmitBytesRate = (nm.NetworkTransmitBytesTotal - b.lastMetrics.NetworkTransmitBytesTotal) / passedTime
}
return nil
}
func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, error) {
response, err := b.httpClient.Get(b.metricsURL)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
var parser expfmt.TextParser
parsed, err := parser.TextToMetricFamilies(strings.NewReader(string(body)))
if err != nil {
return nil, err
}
nm := &NodeMetrics{SyncTime: time.Now(), PingMetrics: []PingMetric{}}
if err := b.parseCpuInfo(parsed, nm); err != nil {
return nil, err
}
if err := b.parseMemoryInfo(parsed, nm); err != nil {
return nil, err
}
if err := b.parseDiskInfo(parsed, nm); err != nil {
return nil, err
}
if err := b.parseNetworkInfo(parsed, nm); err != nil {
return nil, err
}
if err := b.parsePingInfo(parsed, nm); err != nil {
return nil, err
rm := make(map[string]*RuleMetrics)
if err := b.ParseRuleMetrics(metricMap, rm); err != nil {
return nil, nil, err
}
b.lastMetrics = nm
return nm, nil
b.lastRuleMetrics = rm
return nm, rm, nil
}
func (r *readerImpl) fetchMetrics(ctx context.Context) (map[string]*dto.MetricFamily, error) {
req, err := http.NewRequestWithContext(ctx, "GET", r.metricsURL, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to create request")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "failed to send request")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read response body")
}
var parser expfmt.TextParser
return parser.TextToMetricFamilies(strings.NewReader(string(body)))
}

View File

@@ -0,0 +1,146 @@
package metric_reader
import (
"time"
dto "github.com/prometheus/client_model/go"
)
const (
metricConnectionCount = "ehco_traffic_current_connection_count"
metricNetworkTransmit = "ehco_traffic_network_transmit_bytes"
metricPingResponse = "ehco_ping_response_duration_milliseconds"
metricHandshakeDuration = "ehco_traffic_handshake_duration_milliseconds"
labelKey = "label"
remoteKey = "remote"
connTypeKey = "conn_type"
flowKey = "flow"
ipKey = "ip"
)
type PingMetric struct {
Latency int64 `json:"latency"` // in ms
Target string `json:"target"`
}
type RuleMetrics struct {
Label string // rule label
PingMetrics map[string]*PingMetric // key: remote
TCPConnectionCount map[string]int64 // key: remote
TCPHandShakeDuration map[string]int64 // key: remote in ms
TCPNetworkTransmitBytes map[string]int64 // key: remote
UDPConnectionCount map[string]int64 // key: remote
UDPHandShakeDuration map[string]int64 // key: remote in ms
UDPNetworkTransmitBytes map[string]int64 // key: remote
SyncTime time.Time
}
func (b *readerImpl) ParseRuleMetrics(metricMap map[string]*dto.MetricFamily, rm map[string]*RuleMetrics) error {
requiredMetrics := []string{
metricConnectionCount,
metricNetworkTransmit,
metricPingResponse,
metricHandshakeDuration,
}
for _, metricName := range requiredMetrics {
metricFamily, ok := metricMap[metricName]
if !ok {
continue
}
for _, metric := range metricFamily.Metric {
labels := getLabelMap(metric)
value := int64(getMetricValue(metric, metricFamily.GetType()))
label, ok := labels[labelKey]
if !ok || label == "" {
continue
}
ruleMetric := b.ensureRuleMetric(rm, label)
switch metricName {
case metricConnectionCount:
b.updateConnectionCount(ruleMetric, labels, value)
case metricNetworkTransmit:
b.updateNetworkTransmit(ruleMetric, labels, value)
case metricPingResponse:
b.updatePingMetrics(ruleMetric, labels, value)
case metricHandshakeDuration:
b.updateHandshakeDuration(ruleMetric, labels, value)
}
}
}
return nil
}
func (b *readerImpl) ensureRuleMetric(rm map[string]*RuleMetrics, label string) *RuleMetrics {
if _, ok := rm[label]; !ok {
rm[label] = &RuleMetrics{
Label: label,
PingMetrics: make(map[string]*PingMetric),
TCPConnectionCount: make(map[string]int64),
TCPHandShakeDuration: make(map[string]int64),
TCPNetworkTransmitBytes: make(map[string]int64),
UDPConnectionCount: make(map[string]int64),
UDPHandShakeDuration: make(map[string]int64),
UDPNetworkTransmitBytes: make(map[string]int64),
SyncTime: time.Now(),
}
}
return rm[label]
}
func (b *readerImpl) updateConnectionCount(rm *RuleMetrics, labels map[string]string, value int64) {
key := labels[remoteKey]
switch labels[connTypeKey] {
case "tcp":
rm.TCPConnectionCount[key] = value
default:
rm.UDPConnectionCount[key] = value
}
}
func (b *readerImpl) updateNetworkTransmit(rm *RuleMetrics, labels map[string]string, value int64) {
if labels[flowKey] == "read" {
key := labels[remoteKey]
switch labels[connTypeKey] {
case "tcp":
rm.TCPNetworkTransmitBytes[key] += value
default:
rm.UDPNetworkTransmitBytes[key] += value
}
}
}
func (b *readerImpl) updatePingMetrics(rm *RuleMetrics, labels map[string]string, value int64) {
remote := labels[remoteKey]
rm.PingMetrics[remote] = &PingMetric{
Latency: value,
Target: labels[ipKey],
}
}
func (b *readerImpl) updateHandshakeDuration(rm *RuleMetrics, labels map[string]string, value int64) {
key := labels[remoteKey]
switch labels[connTypeKey] {
case "tcp":
rm.TCPHandShakeDuration[key] = value
default:
rm.UDPHandShakeDuration[key] = value
}
}
func getLabelMap(metric *dto.Metric) map[string]string {
labels := make(map[string]string)
for _, label := range metric.Label {
labels[label.GetName()] = label.GetValue()
}
return labels
}

View File

@@ -1,38 +0,0 @@
package metric_reader
import (
"time"
)
type NodeMetrics struct {
// cpu
CpuCoreCount int `json:"cpu_core_count"`
CpuLoadInfo string `json:"cpu_load_info"`
CpuUsagePercent float64 `json:"cpu_usage_percent"`
// memory
MemoryTotalBytes float64 `json:"memory_total_bytes"`
MemoryUsageBytes float64 `json:"memory_usage_bytes"`
MemoryUsagePercent float64 `json:"memory_usage_percent"`
// disk
DiskTotalBytes float64 `json:"disk_total_bytes"`
DiskUsageBytes float64 `json:"disk_usage_bytes"`
DiskUsagePercent float64 `json:"disk_usage_percent"`
// network
NetworkReceiveBytesTotal float64 `json:"network_receive_bytes_total"`
NetworkTransmitBytesTotal float64 `json:"network_transmit_bytes_total"`
NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"`
NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"`
// ping
PingMetrics []PingMetric `json:"ping_metrics"`
SyncTime time.Time
}
type PingMetric struct {
Latency float64 `json:"latency"` // in ms
Target string `json:"target"`
}

View File

@@ -1,22 +1,46 @@
package metric_reader
import "regexp"
import (
"math"
// parse disk name from device path,such as:
// e.g. /dev/disk1s1 -> disk1
// e.g. /dev/disk1s2 -> disk1
// e.g. ntfs://disk1s1 -> disk1
// e.g. ntfs://disk1s2 -> disk1
// e.g. /dev/sda1 -> sda
// e.g. /dev/sda2 -> sda
var diskNameRegex = regexp.MustCompile(`/dev/disk(\d+)|ntfs://disk(\d+)|/dev/sd[a-zA-Z]`)
dto "github.com/prometheus/client_model/go"
)
func getDiskName(devicePath string) string {
matches := diskNameRegex.FindStringSubmatch(devicePath)
for _, match := range matches {
if match != "" {
return match
func calculatePercentile(histogram *dto.Histogram, percentile float64) float64 {
if histogram == nil {
return 0
}
totalSamples := histogram.GetSampleCount()
targetSample := percentile * float64(totalSamples)
cumulativeCount := uint64(0)
var lastBucketBound float64
for _, bucket := range histogram.Bucket {
cumulativeCount += bucket.GetCumulativeCount()
if float64(cumulativeCount) >= targetSample {
// Linear interpolation between bucket boundaries
if bucket.GetCumulativeCount() > 0 && lastBucketBound != bucket.GetUpperBound() {
return lastBucketBound + (float64(targetSample-float64(cumulativeCount-bucket.GetCumulativeCount()))/float64(bucket.GetCumulativeCount()))*(bucket.GetUpperBound()-lastBucketBound)
} else {
return bucket.GetUpperBound()
}
}
lastBucketBound = bucket.GetUpperBound()
}
return math.NaN()
}
func getMetricValue(metric *dto.Metric, metricType dto.MetricType) float64 {
switch metricType {
case dto.MetricType_COUNTER:
return metric.Counter.GetValue()
case dto.MetricType_GAUGE:
return metric.Gauge.GetValue()
case dto.MetricType_HISTOGRAM:
histogram := metric.Histogram
if histogram != nil {
return calculatePercentile(histogram, 0.9)
}
}
return ""
return 0
}

View File

@@ -2,6 +2,7 @@
package echo
import (
"errors"
"fmt"
"io"
"log"
@@ -118,6 +119,10 @@ func (s *EchoServer) handleTCPConn(conn net.Conn) {
}
}
func isClosedConnError(err error) bool {
return errors.Is(err, net.ErrClosed)
}
func (s *EchoServer) serveUDP() {
defer s.wg.Done()
buf := make([]byte, 1024)
@@ -128,6 +133,9 @@ func (s *EchoServer) serveUDP() {
default:
n, remoteAddr, err := s.udpConn.ReadFromUDP(buf)
if err != nil {
if isClosedConnError(err) {
break
}
s.logger.Errorf("Error reading UDP: %v", err)
continue
}

View File

@@ -20,25 +20,27 @@ For an explanation of the mieru protocol, see [mieru Proxy Protocol](./docs/prot
## Features
1. mieru uses a high-strength XChaCha20-Poly1305 encryption algorithm that generates encryption keys based on username, password and system time.
2. mieru implements complete encryption of all transmitted content between the client and the proxy server, without transmitting any plaintext information.
3. When mieru sends a packet, it is padded with random bytes at the end. Even when the same content is transmitted, the packet size varies.
4. When using the UDP transport protocol, mieru does not require a handshake between client and server.
5. When the server can not decrypt the data sent by the client, no content is returned. it is difficult for GFW to discover the mieru service through active probing.
6. mieru supports multiple users sharing a single proxy server.
7. mieru supports IPv4 and IPv6.
8. mieru provides socks5, HTTP and HTTPS proxy.
9. The client software supports Windows, Mac OS, Linux and Android. For Android users, please use [NekoBox](https://github.com/MatsuriDayo/NekoBoxForAndroid) version 1.3.1 or above, and install [mieru plugin](https://github.com/enfein/NekoBoxPlugins).
10. The server software supports socks5 outbound (proxy chain).
11. If you need advanced features like global proxy or customized routing rules, you can use mieru as the backend of a proxy platform such as [Xray](https://github.com/XTLS/Xray-core) and [sing-box](https://github.com/SagerNet/sing-box).
1. mieru implements complete encryption of all transmitted content between the client and the proxy server, without transmitting any plaintext information.
1. When mieru sends a packet, it is padded with random bytes at the end. Even when the same content is transmitted, the packet size varies.
1. When using the UDP transport protocol, mieru does not require a handshake between client and server.
1. When the server can not decrypt the data sent by the client, no content is returned. it is difficult for GFW to discover the mieru service through active probing.
1. mieru supports multiple users sharing a single proxy server.
1. mieru supports IPv4 and IPv6.
1. mieru provides socks5, HTTP and HTTPS proxy.
1. The server software supports socks5 outbound (proxy chain).
1. The client software supports Windows, Mac OS, Linux and Android. Android clients include
- [NekoBox](https://github.com/MatsuriDayo/NekoBoxForAndroid) version 1.3.1 or above, with [mieru plugin](https://github.com/enfein/NekoBoxPlugins).
- [Exclave](https://github.com/dyhkwong/Exclave), with mieru plugin.
1. If you need advanced features like global proxy or customized routing rules, you can use mieru as the backend of a proxy platform such as [Xray](https://github.com/XTLS/Xray-core) and [sing-box](https://github.com/SagerNet/sing-box).
## User Guide
1. [Server Installation & Configuration](./docs/server-install.md)
2. [Client Installation & Configuration](./docs/client-install.md)
3. [Client Installation & Configuration - OpenWrt](./docs/client-install-openwrt.md)
4. [Maintenance & Troubleshooting](./docs/operation.md)
5. [Security Guide](./docs/security.md)
6. [Compilation](./docs/compile.md)
1. [Client Installation & Configuration](./docs/client-install.md)
1. [Client Installation & Configuration - OpenWrt](./docs/client-install-openwrt.md)
1. [Maintenance & Troubleshooting](./docs/operation.md)
1. [Security Guide](./docs/security.md)
1. [Compilation](./docs/compile.md)
## Share

View File

@@ -18,25 +18,27 @@ mieru 的翻墙原理与 shadowsocks / v2ray 等软件类似,在客户端和
## 特性
1. 使用高强度的 XChaCha20-Poly1305 加密算法,基于用户名、密码和系统时间生成密钥。
2. mieru 实现了客户端和代理服务器之间所有传输内容的完整加密,不传输任何明文信息。
3. 当 mieru 发送数据包时,会在尾部填充随机信息。即便是传输相同的内容,数据包大小也不相同。
4. 在使用 UDP 传输协议时mieru 不需要客户端和服务器进行握手,即可直接发送数据。
5. 当服务器无法解密客户端发送的数据时不会返回任何内容。GFW 很难通过主动探测发现 mieru 服务。
6. mieru 支持多个用户共享代理服务器。
7. mieru 支持 IPv4 和 IPv6。
8. mieru 提供 socks5, HTTP 和 HTTPS 代理。
9. 客户端软件支持 Windows, Mac OS, Linux 和 Android 系统。Android 用户请使用 [NekoBox](https://github.com/MatsuriDayo/NekoBoxForAndroid) 1.3.1 及以上版本,并安装 [mieru 插件](https://github.com/enfein/NekoBoxPlugins)
10. 服务器软件支持 socks5 出站(链式代理)。
11. 如果需要全局代理或自定义路由规则等高级功能,可以将 mieru 作为 [Xray](https://github.com/XTLS/Xray-core) 和 [sing-box](https://github.com/SagerNet/sing-box) 等代理平台的后端
1. mieru 实现了客户端和代理服务器之间所有传输内容的完整加密,不传输任何明文信息。
1. 当 mieru 发送数据包时,会在尾部填充随机信息。即便是传输相同的内容,数据包大小也不相同。
1. 在使用 UDP 传输协议时mieru 不需要客户端和服务器进行握手,即可直接发送数据。
1. 当服务器无法解密客户端发送的数据时不会返回任何内容。GFW 很难通过主动探测发现 mieru 服务。
1. mieru 支持多个用户共享代理服务器。
1. mieru 支持 IPv4 和 IPv6。
1. mieru 提供 socks5, HTTP 和 HTTPS 代理。
1. 服务器软件支持 socks5 出站(链式代理)
1. 客户端软件支持 Windows, Mac OS, Linux 和 Android 系统。Android 客户端包括
- [NekoBox](https://github.com/MatsuriDayo/NekoBoxForAndroid) 1.3.1 及以上版本,并安装 [mieru 插件](https://github.com/enfein/NekoBoxPlugins)
- [Exclave](https://github.com/dyhkwong/Exclave) 并安装 mieru 插件。
1. 如果需要全局代理或自定义路由规则等高级功能,可以将 mieru 作为 [Xray](https://github.com/XTLS/Xray-core) 和 [sing-box](https://github.com/SagerNet/sing-box) 等代理平台的后端。
## 使用教程
1. [服务器安装与配置](./docs/server-install.zh_CN.md)
2. [客户端安装与配置](./docs/client-install.zh_CN.md)
3. [客户端安装与配置 - OpenWrt](./docs/client-install-openwrt.zh_CN.md)
4. [运营维护与故障排查](./docs/operation.zh_CN.md)
5. [翻墙安全指南](./docs/security.zh_CN.md)
6. [编译](./docs/compile.zh_CN.md)
1. [客户端安装与配置](./docs/client-install.zh_CN.md)
1. [客户端安装与配置 - OpenWrt](./docs/client-install-openwrt.zh_CN.md)
1. [运营维护与故障排查](./docs/operation.zh_CN.md)
1. [翻墙安全指南](./docs/security.zh_CN.md)
1. [编译](./docs/compile.zh_CN.md)
## 分享

View File

@@ -1,9 +1,32 @@
config alist
option 'enabled' '0'
option 'port' '5244'
option 'temp_dir' '/tmp/alist'
option 'ssl' '0'
option 'token_expires_in' '48'
option 'max_connections' '0'
option 'site_url' ''
option 'delayed_start' '0'
option enabled '0'
option port '5244'
option delayed_start '0'
option allow_wan '0'
option force '1'
option token_expires_in '48'
option max_connections '0'
option tls_insecure_skip_verify '1'
option data_dir '/etc/alist'
option temp_dir '/tmp/alist'
option log '1'
option log_max_size '10'
option log_max_backups '5'
option log_max_age '28'
option log_compress '0'
option database_type 'sqlite3'
option ssl '0'
option download_workers '5'
option download_max_retry '1'
option transfer_workers '5'
option transfer_max_retry '2'
option upload_workers '5'
option upload_max_retry '0'
option copy_workers '5'
option copy_max_retry '2'
option cors_allow_origins '*'
option cors_allow_methods '*'
option cors_allow_headers '*'
option s3 '0'

View File

@@ -10,21 +10,34 @@ LOG_FILE=/var/log/alist.log
get_config() {
config_get_bool enabled $1 enabled 1
config_get port $1 port 5244
config_get log $1 log 1
config_get site_url $1 site_url ""
config_get data_dir $1 data_dir "/etc/alist"
config_get temp_dir $1 temp_dir "/tmp/alist"
config_get ssl $1 ssl 0
config_get ssl_cert $1 ssl_cert ""
config_get ssl_key $1 ssl_key ""
config_get token_expires_in $1 token_expires_in 48
config_get allow_wan $1 allow_wan 0
config_get max_connections $1 max_connections 0
config_get delayed_start $1 delayed_start 0
# mysql
config_get mysql $1 mysql 0
config_get mysql_type $1 mysql_type "mysql"
config_get force $1 force 1
config_get site_url $1 site_url ""
config_get cdn $1 cdn ""
config_get jwt_secret $1 jwt_secret ""
config_get data_dir $1 data_dir "/etc/alist"
config_get temp_dir $1 temp_dir "/tmp/alist"
config_get token_expires_in $1 token_expires_in 48
config_get max_connections $1 max_connections 0
config_get tls_insecure_skip_verify $1 tls_insecure_skip_verify 1
# log
config_get log $1 log 1
config_get log_max_size $1 log_max_size 10
config_get log_max_backups $1 log_max_backups 5
config_get log_max_age $1 log_max_age 28
config_get log_compress $1 log_compress 0
# scheme
config_get ssl $1 ssl 0
config_get force_https $1 force_https 0
config_get ssl_cert $1 ssl_cert ""
config_get ssl_key $1 ssl_key ""
# database
config_get database_type $1 database_type "sqlite3"
config_get mysql_host $1 mysql_host ""
config_get mysql_port $1 mysql_port "3306"
config_get mysql_username $1 mysql_username ""
@@ -34,6 +47,26 @@ get_config() {
config_get mysql_ssl_mode $1 mysql_ssl_mode ""
config_get mysql_dsn $1 mysql_dsn ""
# tasks
config_get download_workers $1 download_workers 5
config_get download_max_retry $1 download_max_retry 1
config_get transfer_workers $1 transfer_workers 5
config_get transfer_max_retry $1 transfer_max_retry 2
config_get upload_workers $1 upload_workers 5
config_get upload_max_retry $1 upload_max_retry 0
config_get copy_workers $1 copy_workers 5
config_get copy_max_retry $1 copy_max_retry 2
# cors
config_get cors_allow_origins $1 cors_allow_origins '*'
config_get cors_allow_methods $1 cors_allow_methods '*'
config_get cors_allow_headers $1 cors_allow_headers '*'
# s3
config_get s3 $1 s3 0
config_get s3_port $1 s3_port 5246
config_get s3_ssl $1 s3_ssl 0
config_load network
config_get lan_addr lan ipaddr "0.0.0.0"
if echo "${lan_addr}" | grep -Fq ' '; then
@@ -41,6 +74,11 @@ get_config() {
else
lan_addr=${lan_addr%%/*}
fi
# init jwt_secret
[ -z "$jwt_secret" ] && jwt_secret=$(tr -cd "a-zA-Z0-9" < "/dev/urandom" | head -c16)
uci -q set alist.@alist[0].jwt_secret="$jwt_secret"
uci commit alist
}
set_firewall() {
@@ -81,23 +119,20 @@ start_service() {
external_access="deny"
fi
# mysql
[ "$mysql" -eq 1 ] && database=$mysql_type || database=sqlite3
set_firewall
true > $LOG_FILE
# init config
json_init
json_add_boolean "force" "1"
json_add_boolean "force" "$force"
json_add_string "site_url" "$site_url"
json_add_string "cdn" ""
json_add_string "jwt_secret" ""
json_add_string "cdn" "$cdn"
json_add_string "jwt_secret" "$jwt_secret"
json_add_int "token_expires_in" "$token_expires_in"
# database
json_add_object 'database'
json_add_string "type" "$database"
json_add_string "type" "$database_type"
json_add_string "host" "$mysql_host"
json_add_int "port" "$mysql_port"
json_add_string "user" "$mysql_username"
@@ -121,7 +156,7 @@ start_service() {
json_add_string "address" "$listen_addr"
json_add_int "http_port" "$http_port"
json_add_int "https_port" "$https_port"
json_add_boolean "force_https" "0"
json_add_boolean "force_https" "$force_https"
json_add_string "cert_file" "$ssl_cert"
json_add_string "key_file" "$ssl_key"
json_add_string "unix_file" ""
@@ -136,61 +171,62 @@ start_service() {
json_add_object "log"
json_add_boolean "enable" "$log"
json_add_string "name" "$LOG_FILE"
json_add_int "max_size" "10"
json_add_int "max_backups" "5"
json_add_int "max_age" "28"
json_add_boolean "compress" "0"
json_add_int "max_size" "$log_max_size"
json_add_int "max_backups" "$log_max_backups"
json_add_int "max_age" "$log_max_age"
json_add_boolean "compress" "$log_compress"
json_close_object
json_add_int "delayed_start" "$delayed_start"
json_add_int "max_connections" "$max_connections"
json_add_boolean "tls_insecure_skip_verify" "1"
json_add_boolean "tls_insecure_skip_verify" "$tls_insecure_skip_verify"
# tasks
json_add_object "tasks"
json_add_object "download"
json_add_int "workers" "5"
json_add_int "max_retry" "1"
json_add_int "workers" "$download_workers"
json_add_int "max_retry" "$download_max_retry"
json_close_object
json_add_object "transfer"
json_add_int "workers" "5"
json_add_int "max_retry" "2"
json_add_int "workers" "$transfer_workers"
json_add_int "max_retry" "$transfer_max_retry"
json_close_object
json_add_object "upload"
json_add_int "workers" "5"
json_add_int "max_retry" "0"
json_add_int "workers" "$upload_workers"
json_add_int "max_retry" "$upload_max_retry"
json_close_object
json_add_object "copy"
json_add_int "workers" "5"
json_add_int "max_retry" "2"
json_add_int "workers" "$copy_workers"
json_add_int "max_retry" "$copy_max_retry"
json_close_object
json_close_object
# cors
json_add_object "cors"
json_add_array "allow_origins"
json_add_string "" "*"
json_add_string "" "$cors_allow_origins"
json_close_array
json_add_array "allow_methods"
json_add_string "" "*"
json_add_string "" "$cors_allow_methods"
json_close_array
json_add_array "allow_headers"
json_add_string "" "*"
json_add_string "" "$cors_allow_headers"
json_close_array
json_close_object
# s3
json_add_object "s3"
json_add_boolean "enable" "0"
json_add_int "port" "5246"
json_add_boolean "ssl" "0"
json_add_boolean "enable" "$s3"
json_add_int "port" "$s3_port"
json_add_boolean "ssl" "$s3_ssl"
json_close_object
json_dump > $data_dir/config.json
procd_open_instance alist
procd_set_param command $PROG
procd_append_param command server --data $data_dir
procd_append_param command server
procd_append_param command --data $data_dir
procd_set_param stdout 0
procd_set_param stderr 0
procd_set_param respawn
@@ -201,7 +237,7 @@ start_service() {
reload_service() {
stop
sleep 3
sleep 2
start
}

View File

@@ -658,8 +658,11 @@ run_chinadns_ng() {
([ -z "${_default_tag}" ] || [ "${_default_tag}" = "smart" ] || [ "${_default_tag}" = "none_noip" ]) && _default_tag="none"
echo "default-tag ${_default_tag}" >> ${_CONF_FILE}
echo "cache 4096" >> ${_CONF_FILE}
echo "cache-stale 3600" >> ${_CONF_FILE}
[ "${_flag}" = "default" ] && [ "${_default_tag}" = "none" ] && {
echo "verdict-cache 4096" >> ${_CONF_FILE}
echo "verdict-cache 5000" >> ${_CONF_FILE}
}
ln_run "$(first_type chinadns-ng)" chinadns-ng "${_LOG_FILE}" -C ${_CONF_FILE}
@@ -1379,7 +1382,6 @@ start_dns() {
LOCAL_DNS=$(config_t_get global direct_dns_udp 223.5.5.5 | sed 's/:/#/g')
china_ng_local_dns=${LOCAL_DNS}
sing_box_local_dns="direct_dns_udp_server=${LOCAL_DNS}"
IPT_APPEND_DNS=${LOCAL_DNS}
;;
tcp)
LOCAL_DNS="127.0.0.1#${dns_listen_port}"
@@ -1387,7 +1389,6 @@ start_dns() {
local DIRECT_DNS=$(config_t_get global direct_dns_tcp 223.5.5.5 | sed 's/:/#/g')
china_ng_local_dns="tcp://${DIRECT_DNS}"
sing_box_local_dns="direct_dns_tcp_server=${DIRECT_DNS}"
IPT_APPEND_DNS="${LOCAL_DNS},${DIRECT_DNS}"
ln_run "$(first_type dns2tcp)" dns2tcp "/dev/null" -L "${LOCAL_DNS}" -R "$(get_first_dns DIRECT_DNS 53)" -v
echolog " - dns2tcp(${LOCAL_DNS}) -> tcp://$(get_first_dns DIRECT_DNS 53 | sed 's/#/:/g')"
echolog " * 请确保上游直连 DNS 支持 TCP 查询。"
@@ -1405,8 +1406,8 @@ start_dns() {
local tmp_dot_ip=$(echo "$DIRECT_DNS" | sed -n 's/.*:\/\/\([^@#]*@\)*\([^@#]*\).*/\2/p')
local tmp_dot_port=$(echo "$DIRECT_DNS" | sed -n 's/.*#\([0-9]\+\).*/\1/p')
sing_box_local_dns="direct_dns_dot_server=$tmp_dot_ip#${tmp_dot_port:-853}"
IPT_APPEND_DNS="${LOCAL_DNS},$tmp_dot_ip#${tmp_dot_port:-853}"
DIRECT_DNS=$tmp_dot_ip#${tmp_dot_port:-853}
sing_box_local_dns="direct_dns_dot_server=${DIRECT_DNS}"
else
echolog " - 你的ChinaDNS-NG版本不支持DoT直连DNS将使用默认地址。"
fi
@@ -1417,6 +1418,21 @@ start_dns() {
;;
esac
# 追加直连DNS到iptables/nftables
[ "$(config_t_get global_haproxy balancing_enable 0)" != "1" ] && IPT_APPEND_DNS=
add_default_port() {
[ -z "$1" ] && echo "" || echo "$1" | awk -F',' '{for(i=1;i<=NF;i++){if($i !~ /#/) $i=$i"#53";} print $0;}' OFS=','
}
LOCAL_DNS=$(add_default_port "$LOCAL_DNS")
IPT_APPEND_DNS=$(add_default_port "${IPT_APPEND_DNS:-$LOCAL_DNS}")
echo "$IPT_APPEND_DNS" | grep -q -E "(^|,)$LOCAL_DNS(,|$)" || IPT_APPEND_DNS="${IPT_APPEND_DNS:+$IPT_APPEND_DNS,}$LOCAL_DNS"
[ -n "$DIRECT_DNS" ] && {
DIRECT_DNS=$(add_default_port "$DIRECT_DNS")
echo "$IPT_APPEND_DNS" | grep -q -E "(^|,)$DIRECT_DNS(,|$)" || IPT_APPEND_DNS="${IPT_APPEND_DNS:+$IPT_APPEND_DNS,}$DIRECT_DNS"
}
# 排除127.0.0.1的条目
IPT_APPEND_DNS=$(echo "$IPT_APPEND_DNS" | awk -F',' '{for(i=1;i<=NF;i++) if($i !~ /^127\.0\.0\.1/) printf (i>1?",":"") $i; print ""}' | sed 's/^,\|,$//g')
TUN_DNS="127.0.0.1#${dns_listen_port}"
[ "${resolve_dns}" == "1" ] && TUN_DNS="127.0.0.1#${resolve_dns_port}"

View File

@@ -0,0 +1,50 @@
#!/bin/bash
PKG_NAME="$1"
CURDIR="$2"
PKG_BUILD_DIR="$3"
PKG_BUILD_BIN="$PKG_BUILD_DIR/bin"
export PATH="$PATH:$PKG_BUILD_BIN"
OS=linux
ARCH=amd64
JQVERSION=1.7.1
DOCNAME=Ruleset-URI-Scheme
mkdir -p "$PKG_BUILD_BIN"
curl -L "https://github.com/jqlang/jq/releases/download/jq-${JQVERSION}/jq-${OS}-${ARCH}" -o "$PKG_BUILD_BIN"/jq
chmod +x "$PKG_BUILD_BIN"/jq
latest="$(curl -L https://api.github.com/repos/kpym/gm/releases/latest | jq -rc '.tag_name' 2>/dev/null)"
curl -L "https://github.com/kpym/gm/releases/download/${latest}/gm_${latest#v}_Linux_64bit.tar.gz" -o- | tar -xz -C "$PKG_BUILD_BIN"
latest="$(curl -L https://api.github.com/repos/tdewolff/minify/releases/latest | jq -rc '.tag_name' 2>/dev/null)"
curl -L "https://github.com/tdewolff/minify/releases/download/${latest}/minify_${OS}_${ARCH}.tar.gz" -o- | tar -xz -C "$PKG_BUILD_BIN"
chmod -R +x "$PKG_BUILD_BIN"
cp "$CURDIR"/docs/$DOCNAME.md "$PKG_BUILD_DIR"
pushd "$PKG_BUILD_DIR"
gm $DOCNAME.md
p=$(sed -n '/github.min.css/=' $DOCNAME.html)
{
head -n$(( $p -1 )) $DOCNAME.html
echo '<style>'
cat "$CURDIR"/docs/css/ClearnessDark.css
echo '</style>'
tail -n +$(( $p +1 )) $DOCNAME.html
} > buildin.html
popd
minify "$PKG_BUILD_DIR"/buildin.html | base64 | tr -d '\n' > "$PKG_BUILD_DIR"/base64
sed -i "s|'cmxzdHBsYWNlaG9sZGVy'|'$(cat "$PKG_BUILD_DIR"/base64)'|" "$PKG_BUILD_DIR"/htdocs/luci-static/resources/view/homeproxy/ruleset.js
if [ -d "$CURDIR/.git" ]; then
config="$CURDIR/.git/config"
else
config="$(sed "s|^gitdir:\s*|$CURDIR/|;s|$|/config|" "$CURDIR/.git")"
fi
[ -n "$(sed -En '/^\[remote /{h;:top;n;/^\[/b;s,(https?://gitcode\.(com|net)),\1,;T top;H;x;s|\n\s*|: |;p;}' "$config")" ] && {
for d in luasrc ucode htdocs root src; do
rm -rf "$PKG_BUILD_DIR"/$d
done
mkdir -p "$PKG_BUILD_DIR"/htdocs/luci-static/resources/view
touch "$PKG_BUILD_DIR"/htdocs/luci-static/resources/view/$PKG_NAME.js
mkdir -p "$PKG_BUILD_DIR"/root/usr/share/luci/menu.d
touch "$PKG_BUILD_DIR"/root/usr/share/luci/menu.d/$PKG_NAME.json
}
exit 0

View File

@@ -10,7 +10,8 @@ LUCI_DEPENDS:= \
+sing-box \
+chinadns-ng \
+firewall4 \
+kmod-nft-tproxy
+kmod-nft-tproxy \
+unzip
PKG_NAME:=luci-app-homeproxy
@@ -20,8 +21,13 @@ define Package/luci-app-homeproxy/conffiles
/etc/homeproxy/ruleset/
/etc/homeproxy/resources/direct_list.txt
/etc/homeproxy/resources/proxy_list.txt
/etc/homeproxy/resources/clash_dashboard.ver
/etc/homeproxy/resources/*.zip
/etc/homeproxy/cache.db
endef
PKG_UNPACK=$(CURDIR)/.prepare.sh $(PKG_NAME) $(CURDIR) $(PKG_BUILD_DIR)
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature

View File

@@ -1,5 +1,14 @@
Recently, sagernet blocked my account unilaterally and without any reason.
So this fork will not continue to provide support for new SB features in the future.
Goodbye. everyone.
For developers:
The code of the dev/* branch has supported new SB features up to 1.10.0-beta.3.
You can merge it yourself if necessary.
TODO:
- Subscription page slow response with a large number of nodes
- Refactor nft rules
- Support Clash selector, urltest etc.
- Move ACL settings to a dedicated page
- Any other improvements

View File

@@ -0,0 +1,54 @@
# Import rule-set links format
## Structure
**remote:** `http[s]://[auth@]<host><path>?file=<rulefmt>[&key=value][#label]`
**local:** `file://[host]<path>?file=<rulefmt>[&key=value][#label]`
**inline:** `inline://<base64edJsonStr>[#label]`
## Components
### Scheme
Can be `http` or `https` or `file` or `inline`.
### Auth
Add it only if required by the target host.
### Host
The format is `hostname[:port]`.
`hostname` can be **Domain** or **IP Address**.
`:port` is optional, add it only if required by the target host.
### Path
The shortest format is `/`.
### Base64edJsonStr
Generation steps:
1. Base64 encode **Headless Rule** `.rules`.
2. Replace all `+` with `-` and all `/` with `_` in base64 string.
3. Remove all `=` from the EOF the base64 string.
### QueryParameters
+ `file`: Required. Available values refer to **Rulefmt**.
+ `rawquery`: Optional. Available values refer to **rawQuery**.
#### Rulefmt
Can be `json` or `srs`. Rule file format.
#### rawQuery
This parameter is required if the original link contains a url query.
Encrypt the part `key1=value1&key2=value2` after `?` in the original link with `encodeURIComponent` and use it as the payload of this parameter.
### URIFragment
Ruleset label. Empty strings are not recommended.
Need encoded by `encodeURIComponent`.

View File

@@ -0,0 +1,209 @@
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote {
margin: 0;
padding: 0;
}
body {
font-family: "Helvetica Neue", Helvetica, "Hiragino Sans GB", Arial, sans-serif;
font-size: 13px;
line-height: 18px;
color: #fff;
background-color: #282a36;
margin: 10px 13px 10px 13px;
}
a {
color: #59acf3;
}
a:hover {
color: #a7d8ff;
text-decoration: none;
}
a img {
border: none;
}
p {
margin-bottom: 9px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #fff;
line-height: 36px;
}
h1 {
margin-bottom: 18px;
font-size: 30px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
}
h6 {
font-size: 13px;
}
hr {
margin: 0 0 19px;
border: 0;
border-bottom: 1px solid #ccc;
}
blockquote {
padding: 13px 13px 21px 15px;
margin-bottom: 18px;
font-family:georgia,serif;
font-style: italic;
}
blockquote:before {
content:"\201C";
font-size:40px;
margin-left:-10px;
font-family:georgia,serif;
color:#eee;
}
blockquote p {
font-size: 14px;
font-weight: 300;
line-height: 18px;
margin-bottom: 0;
font-style: italic;
}
code, pre {
font-family: Monaco, Andale Mono, Courier New, monospace;
}
code {
color: #ff4a14;
padding: 1px 3px;
font-size: 12px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
pre {
display: block;
padding: 14px;
margin: 0 0 18px;
line-height: 16px;
font-size: 11px;
border: 1px solid #bf370f;
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
}
pre code {
background-color: #282a36;
color: #ff4a14;
font-size: 11px;
padding: 0;
}
@media screen and (min-width: 768px) {
body {
width: 748px;
margin:10px auto;
}
}
/**
* obsidian.css
* Obsidian style
* ported by Alexander Marenin (http://github.com/ioncreature)
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: #282b2e;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-selector-id {
color: #93c763;
}
.hljs-number {
color: #ffcd22;
}
.hljs {
color: #e0e2e4;
}
.hljs-attribute {
color: #668bb0;
}
.hljs-code,
.hljs-class .hljs-title,
.hljs-section {
color: white;
}
.hljs-regexp,
.hljs-link {
color: #d39745;
}
.hljs-meta {
color: #557182;
}
.hljs-tag,
.hljs-name,
.hljs-bullet,
.hljs-subst,
.hljs-emphasis,
.hljs-type,
.hljs-built_in,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-addition,
.hljs-variable,
.hljs-template-tag,
.hljs-template-variable {
color: #8cbbad;
}
.hljs-string,
.hljs-symbol {
color: #ec7600;
}
.hljs-comment,
.hljs-quote,
.hljs-deletion {
color: #818e96;
}
.hljs-selector-class {
color: #A082BD
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-doctag,
.hljs-title,
.hljs-section,
.hljs-type,
.hljs-name,
.hljs-strong {
font-weight: bold;
}

View File

@@ -11,6 +11,7 @@
'require rpc';
'require uci';
'require ui';
'require validation';
return baseclass.extend({
dns_strategy: {
@@ -183,7 +184,8 @@ return baseclass.extend({
).join('');
case 'uuid':
/* Thanks to https://stackoverflow.com/a/2117523 */
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
return (location.protocol === 'https:') ? crypto.randomUUID() :
([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
default:
@@ -206,19 +208,55 @@ return baseclass.extend({
return label ? title + ' » ' + label : addtitle;
},
renderSectionAdd: function(section, extra_class) {
loadSubscriptionInfo: function(uciconfig) {
var subs = {};
for (var suburl of (uci.get(uciconfig, 'subscription', 'subscription_url') || [])) {
const url = new URL(suburl);
const urlhash = this.calcStringMD5(suburl.replace(/#.*$/, ''));
subs[urlhash] = {
"url": suburl.replace(/#.*$/, ''),
"name": url.hash ? decodeURIComponent(url.hash.slice(1)) : url.hostname
};
}
return subs;
},
loadNodesList: function(uciconfig, subinfo) {
var nodelist = {};
uci.sections(uciconfig, 'node', (res) => {
var nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
nodeport = ((res.type === 'direct') ? res.override_port : res.port) || '';
nodelist[res['.name']] =
String.format('%s [%s] %s', res.grouphash ?
String.format('[%s]', subinfo[res.grouphash]?.name || res.grouphash) : '',
res.type, res.label || ((validation.parseIPv6(nodeaddr) ?
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
});
return nodelist;
},
renderSectionAdd: function(section, prefmt, LC, extra_class) {
var el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
var button = el.querySelector('.cbi-section-create > .cbi-button-add');
var uciconfig = section.uciconfig || section.map.config;
var prefix = prefmt?.prefix ? prefmt.prefix : '',
suffix = prefmt?.suffix ? prefmt.suffix : '';
if (!v) {
button.disabled = true;
return true;
} else if (LC && (v !== v.toLowerCase())) {
button.disabled = true;
return _('Expecting: %s').format(_('Lowercase only'));
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else if (uci.get(uciconfig, prefix + v + suffix)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique label'));
} else {
button.disabled = null;
return true;
@@ -228,6 +266,13 @@ return baseclass.extend({
return el;
},
handleAdd: function(section, prefmt, ev, name) {
var prefix = prefmt?.prefix ? prefmt.prefix : '',
suffix = prefmt?.suffix ? prefmt.suffix : '';
return form.GridSection.prototype.handleAdd.apply(section, [ ev, prefix + name + suffix ]);
},
uploadCertificate: function(option, type, filename, ev) {
var callWriteCertificate = rpc.declare({
object: 'luci.homeproxy',

View File

@@ -6,10 +6,12 @@
'use strict';
'require form';
'require fs';
'require network';
'require poll';
'require rpc';
'require uci';
'require ui';
'require validation';
'require view';
@@ -38,6 +40,20 @@ var callWriteDomainList = rpc.declare({
expect: { '': {} }
});
var callGetAPISecret = rpc.declare({
object: 'luci.homeproxy',
method: 'clash_api_get_secret',
params: [],
expect: { '': {} }
});
var callResVersion = rpc.declare({
object: 'luci.homeproxy',
method: 'resources_get_version',
params: ['type', 'repo'],
expect: { '': {} }
});
function getServiceStatus() {
return L.resolveDefault(callServiceList('homeproxy'), {}).then((res) => {
var isRunning = false;
@@ -48,12 +64,35 @@ function getServiceStatus() {
});
}
function renderStatus(isRunning) {
function renderStatus(isRunning, args) {
let nginx = args.features.hp_has_nginx && args.nginx_support === '1';
var spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
var urlParams;
var renderHTML;
if (isRunning)
renderHTML = spanTemp.format('green', _('HomeProxy'), _('RUNNING'));
else
if (isRunning) {
if (args.set_dash_backend) {
switch (args.dashboard_repo) {
case 'metacubex/metacubexd':
urlParams = String.format('#/setup?hostname=%s&port=%s&secret=%s', window.location.hostname, args.api_port, args.api_secret);
break;
case 'metacubex/yacd-meta':
urlParams = String.format('?hostname=%s&port=%s&secret=%s', window.location.hostname, args.api_port, args.api_secret);
break;
case 'metacubex/razord-meta':
urlParams = String.format('?host=%s&port=%s&secret=%s', window.location.hostname, args.api_port, args.api_secret);
break;
default:
break;
}
}
if (args.dashboard_repo) {
var button = String.format('&#160;<a class="btn cbi-button-apply" href="%s" target="_blank" rel="noreferrer noopener">%s</a>',
(nginx ? 'https:' : 'http:') + '//' + window.location.hostname +
(nginx ? '/homeproxy' : ':' + args.api_port) + '/ui/' + (urlParams || ''),
_('Open Clash Dashboard'));
}
renderHTML = spanTemp.format('green', _('HomeProxy'), _('RUNNING')) + (button || '');
} else
renderHTML = spanTemp.format('red', _('HomeProxy'), _('NOT RUNNING'));
return renderHTML;
@@ -96,7 +135,8 @@ return view.extend({
return Promise.all([
uci.load('homeproxy'),
hp.getBuiltinFeatures(),
network.getHostHints()
network.getHostHints(),
L.resolveDefault(callGetAPISecret(), {})
]);
},
@@ -104,18 +144,12 @@ return view.extend({
var m, s, o, ss, so;
var features = data[1],
hosts = data[2]?.hosts;
/* Cache all configured proxy nodes, they will be called multiple times */
var proxy_nodes = {};
uci.sections(data[0], 'node', (res) => {
var nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
nodeport = ((res.type === 'direct') ? res.override_port : res.port) || '';
proxy_nodes[res['.name']] =
String.format('[%s] %s', res.type, res.label || ((stubValidator.apply('ip6addr', nodeaddr) ?
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
});
hosts = data[2]?.hosts,
api_port = uci.get(data[0], 'experimental', 'clash_api_port'),
api_secret = data[3]?.secret || '',
nginx_support = uci.get(data[0], 'experimental', 'nginx_support') || '0',
dashboard_repo = uci.get(data[0], 'experimental', 'dashboard_repo'),
set_dash_backend = uci.get(data[0], 'experimental', 'set_dash_backend');
m = new form.Map('homeproxy', _('HomeProxy'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
@@ -125,7 +159,7 @@ return view.extend({
poll.add(function () {
return L.resolveDefault(getServiceStatus()).then((res) => {
var view = document.getElementById('service_status');
view.innerHTML = renderStatus(res);
view.innerHTML = renderStatus(res, {features, nginx_support, dashboard_repo, set_dash_backend, api_port, api_secret});
});
});
@@ -134,14 +168,20 @@ return view.extend({
]);
}
/* Cache all subscription info, they will be called multiple times */
var subs_info = hp.loadSubscriptionInfo(data[0]);
/* Cache all configured proxy nodes, they will be called multiple times */
var proxy_nodes = hp.loadNodesList(data[0], subs_info);
s = m.section(form.NamedSection, 'config', 'homeproxy');
s.tab('routing', _('Routing Settings'));
o = s.taboption('routing', form.ListValue, 'main_node', _('Main node'));
o.value('nil', _('Disable'));
for (var i in proxy_nodes)
o.value(i, proxy_nodes[i]);
for (var k in proxy_nodes)
o.value(k, proxy_nodes[k]);
o.default = 'nil';
o.depends({'routing_mode': 'custom', '!reverse': true});
o.rmempty = false;
@@ -149,8 +189,8 @@ return view.extend({
o = s.taboption('routing', form.ListValue, 'main_udp_node', _('Main UDP node'));
o.value('nil', _('Disable'));
o.value('same', _('Same as main node'));
for (var i in proxy_nodes)
o.value(i, proxy_nodes[i]);
for (var k in proxy_nodes)
o.value(k, proxy_nodes[k]);
o.default = 'nil';
o.depends({'routing_mode': /^((?!custom).)+$/, 'proxy_mode': /^((?!redirect$).)+$/});
o.rmempty = false;
@@ -164,7 +204,7 @@ return view.extend({
o.value('', '---');
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
o.value('114.114.114.114', _('Xinfeng Public DNS (114.114.114.114)'));
o.default = '8.8.8.8';
o.rmempty = false;
o.depends({'routing_mode': 'custom', '!reverse': true});
@@ -187,7 +227,7 @@ return view.extend({
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)'));
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
o.value('114.114.114.114', _('Xinfeng Public DNS (114.114.114.114)'));
o.depends('routing_mode', 'bypass_mainland_china');
o.validate = function(section_id) {
if (section_id) {
@@ -234,11 +274,14 @@ return view.extend({
o = s.taboption('routing', form.Value, 'routing_port', _('Routing ports'),
_('Specify target ports to be proxied. Multiple ports must be separated by commas.'));
o.value('', _('All ports'));
o.value('all', _('All ports'));
o.value('common', _('Common ports only (bypass P2P traffic)'));
o.default = 'common';
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id && value && value !== 'common') {
if (section_id && value !== 'all' && value !== 'common') {
if (!value)
return _('Expecting: %s').format(_('valid port value'));
var ports = [];
for (var i of value.split(',')) {
@@ -324,11 +367,6 @@ return view.extend({
so.default = so.disabled;
so.rmempty = false;
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
_('If set, the requested domain name will be resolved to IP before routing.'));
for (var i in hp.dns_strategy)
so.value(i, hp.dns_strategy[i])
so = ss.option(form.Flag, 'sniff_override', _('Override destination'),
_('Override the connection destination address with the sniffed domain.'));
so.default = so.enabled;
@@ -351,6 +389,17 @@ return view.extend({
}
so.default = 'nil';
so.rmempty = false;
so = ss.option(form.Button, '_reload_client', _('Quick Reload'));
so.inputtitle = _('Reload');
so.inputstyle = 'apply';
so.onclick = function() {
return fs.exec('/etc/init.d/homeproxy', ['reload', 'client'])
.then((res) => { return window.location = window.location.href.split('#')[0] })
.catch((e) => {
ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/homeproxy %s %s" reason: %s').format('reload', 'client', e)));
});
};
/* Routing settings end */
/* Routing nodes start */
@@ -365,7 +414,8 @@ return view.extend({
ss.nodescriptions = true;
ss.modaltitle = L.bind(hp.loadModalTitle, this, _('Routing node'), _('Add a routing node'), data[0]);
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss, {}, true);
ss.handleAdd = L.bind(hp.handleAdd, this, ss, {});
so = ss.option(form.Value, 'label', _('Label'));
so.load = L.bind(hp.loadDefaultLabel, this, data[0]);
@@ -379,13 +429,13 @@ return view.extend({
so = ss.option(form.ListValue, 'node', _('Node'),
_('Outbound node'));
for (var i in proxy_nodes)
so.value(i, proxy_nodes[i]);
for (var k in proxy_nodes)
so.value(k, proxy_nodes[k]);
so.validate = L.bind(hp.validateUniqueValue, this, data[0], 'routing_node', 'node');
so.editable = true;
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
_('If set, the server domain name will be resolved to IP before connecting.<br/>'));
_('If set, the server domain name will be resolved to IP before connecting.<br/>dns.strategy will be used if empty.'));
for (var i in hp.dns_strategy)
so.value(i, hp.dns_strategy[i]);
so.modalonly = true;
@@ -435,13 +485,15 @@ return view.extend({
o.depends('routing_mode', 'custom');
ss = o.subsection;
var prefmt = { 'prefix': '', 'suffix': '_host' };
ss.addremove = true;
ss.rowcolors = true;
ss.sortable = true;
ss.nodescriptions = true;
ss.modaltitle = L.bind(hp.loadModalTitle, this, _('Routing rule'), _('Add a routing rule'), data[0]);
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss, prefmt, false);
ss.handleAdd = L.bind(hp.handleAdd, this, ss, prefmt);
ss.tab('field_other', _('Other fields'));
ss.tab('field_host', _('Host fields'));
@@ -516,7 +568,6 @@ return view.extend({
so = ss.taboption('field_source_ip', form.Flag, 'source_ip_is_private', _('Private source IP'),
_('Match private source IP.'));
so.default = so.disabled;
so.rmempty = false;
so.modalonly = true;
so = ss.taboption('field_host', form.DynamicList, 'ip_cidr', _('IP CIDR'),
@@ -527,7 +578,6 @@ return view.extend({
so = ss.taboption('field_host', form.Flag, 'ip_is_private', _('Private IP'),
_('Match private IP.'));
so.default = so.disabled;
so.rmempty = false;
so.modalonly = true;
so = ss.taboption('field_source_port', form.DynamicList, 'source_port', _('Source port'),
@@ -562,6 +612,14 @@ return view.extend({
_('Match user name.'));
so.modalonly = true;
so = ss.taboption('field_other', form.ListValue, 'clash_mode', _('Clash mode'),
_('Match clash mode.'));
so.value('', _('None'));
so.value('global', _('Global'));
so.value('rule', _('Rule'));
so.value('direct', _('Direct'));
so.modalonly = true;
so = ss.taboption('field_other', form.MultiValue, 'rule_set', _('Rule set'),
_('Match rule set.'));
so.load = function(section_id) {
@@ -581,7 +639,6 @@ return view.extend({
so = ss.taboption('field_other', form.Flag, 'rule_set_ipcidr_match_source', _('Match source IP via rule set'),
_('Make IP CIDR in rule set used to match the source IP.'));
so.default = so.disabled;
so.rmempty = false;
so.modalonly = true;
so = ss.taboption('field_other', form.Flag, 'invert', _('Invert'),
@@ -672,13 +729,15 @@ return view.extend({
o.depends('routing_mode', 'custom');
ss = o.subsection;
var prefmt = { 'prefix': 'dns_', 'suffix': '' };
ss.addremove = true;
ss.rowcolors = true;
ss.sortable = true;
ss.nodescriptions = true;
ss.modaltitle = L.bind(hp.loadModalTitle, this, _('DNS server'), _('Add a DNS server'), data[0]);
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss, prefmt, true);
ss.handleAdd = L.bind(hp.handleAdd, this, ss, prefmt);
so = ss.option(form.Value, 'label', _('Label'));
so.load = L.bind(hp.loadDefaultLabel, this, data[0]);
@@ -727,7 +786,7 @@ return view.extend({
so.modalonly = true;
so = ss.option(form.ListValue, 'address_strategy', _('Address strategy'),
_('The domain strategy for resolving the domain name in the address.'));
_('The domain strategy for resolving the domain name in the address. dns.strategy will be used if empty.'));
for (var i in hp.dns_strategy)
so.value(i, hp.dns_strategy[i]);
so.modalonly = true;
@@ -767,13 +826,15 @@ return view.extend({
o.depends('routing_mode', 'custom');
ss = o.subsection;
var prefmt = { 'prefix': '', 'suffix': '_domain' };
ss.addremove = true;
ss.rowcolors = true;
ss.sortable = true;
ss.nodescriptions = true;
ss.modaltitle = L.bind(hp.loadModalTitle, this, _('DNS rule'), _('Add a DNS rule'), data[0]);
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss, prefmt, false);
ss.handleAdd = L.bind(hp.handleAdd, this, ss, prefmt);
ss.tab('field_other', _('Other fields'));
ss.tab('field_host', _('Host fields'));
@@ -896,6 +957,14 @@ return view.extend({
_('Match user name.'));
so.modalonly = true;
so = ss.taboption('field_other', form.ListValue, 'clash_mode', _('Clash mode'),
_('Match clash mode.'));
so.value('', _('None'));
so.value('global', _('Global'));
so.value('rule', _('Rule'));
so.value('direct', _('Direct'));
so.modalonly = true;
so = ss.taboption('field_other', form.MultiValue, 'rule_set', _('Rule set'),
_('Match rule set.'));
so.load = function(section_id) {
@@ -938,6 +1007,13 @@ return view.extend({
return this.super('load', section_id);
}
so.validate = function(section_id, value) {
let arr = value.trim().split(' ');
if (arr.length > 1 && arr.includes('any-out'))
return _('Expecting: %s').format(_('If Any is selected, uncheck others'));
return true;
}
so.modalonly = true;
so = ss.taboption('field_other', form.ListValue, 'server', _('Server'),
@@ -976,93 +1052,82 @@ return view.extend({
/* DNS rules end */
/* Custom routing settings end */
/* Rule set settings start */
s.tab('ruleset', _('Rule set'));
o = s.taboption('ruleset', form.SectionValue, '_ruleset', form.GridSection, 'ruleset');
/* Clash API settings start */
s.tab('clash', _('Clash API settings'));
o = s.taboption('clash', form.SectionValue, '_clash', form.NamedSection, 'experimental');
o.depends('routing_mode', 'custom');
ss = o.subsection;
ss.addremove = true;
ss.rowcolors = true;
ss.sortable = true;
ss.nodescriptions = true;
ss.modaltitle = L.bind(hp.loadModalTitle, this, _('Rule set'), _('Add a rule set'), data[0]);
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss);
so = ss.option(form.Flag, 'clash_api_enabled', _('Enable Clash API'));
so.default = so.disabled;
so = ss.option(form.Value, 'label', _('Label'));
so.load = L.bind(hp.loadDefaultLabel, this, data[0]);
so.validate = L.bind(hp.validateUniqueValue, this, data[0], 'ruleset', 'label');
so.modalonly = true;
so = ss.option(form.Flag, 'enabled', _('Enable'));
so.default = so.enabled;
so.rmempty = false;
so.editable = true;
so = ss.option(form.ListValue, 'type', _('Type'));
so.value('local', _('Local'));
so.value('remote', _('Remote'));
so.default = 'remote';
so.rmempty = false;
so = ss.option(form.ListValue, 'format', _('Format'));
so.value('source', _('Source file'));
so.value('binary', _('Binary file'));
so.default = 'source';
so.rmempty = false;
so = ss.option(form.Value, 'path', _('Path'));
so.datatype = 'file';
so.placeholder = '/etc/homeproxy/ruleset/example.json';
so.rmempty = false;
so.depends('type', 'local');
so.modalonly = true;
so = ss.option(form.Value, 'url', _('Rule set URL'));
so.validate = function(section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
try {
var url = new URL(value);
if (!url.hostname)
return _('Expecting: %s').format(_('valid URL'));
}
catch(e) {
return _('Expecting: %s').format(_('valid URL'));
}
}
return true;
so = ss.option(form.Flag, 'nginx_support', _('Nginx Support'));
so.rmempty = true;
if (! features.hp_has_nginx) {
so.description = _('To enable this feature you need install <b>luci-nginx</b> and <b>luci-ssl-nginx</b><br/> first');
so.readonly = true;
}
so.write = function(section_id, value) {
return uci.set(data[0], section_id, 'nginx_support', features.hp_has_nginx ? value : null);
}
so.rmempty = false;
so.depends('type', 'remote');
so.modalonly = true;
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
_('Tag of the outbound to download rule set.'));
so = ss.option(form.ListValue, 'clash_api_log_level', _('Log level'));
so.value('trace', 'Trace');
so.value('debug', 'Debug');
so.value('info', 'Info');
so.value('warn', 'Warning');
so.value('error', 'Error');
so.value('fatal', 'Fatal');
so.value('panic', 'Panic');
so.default = 'warn';
so = ss.option(form.ListValue, 'dashboard_repo', _('Select Clash Dashboard'),
_('If the selected dashboard is <code>') + _('Not Installed') + _('</code>.<br/> you will need to check update via <code>') +
_('Service Status') + _('</code> » <code>') + _('Clash dashboard version') + _('</code>.'));
so.load = function(section_id) {
delete this.keylist;
delete this.vallist;
this.value('direct-out', _('Direct'));
uci.sections(data[0], 'routing_node', (res) => {
if (res.enabled === '1')
this.value(res['.name'], res.label);
let repos = [
['metacubex/metacubexd', _('metacubexd')],
['metacubex/yacd-meta', _('yacd-meta')],
['metacubex/razord-meta', _('razord-meta')]
];
this.value('', _('Use Online Dashboard'));
repos.forEach((repo) => {
callResVersion('clash_dashboard', repo[0]).then((res) => {
this.value(repo[0], repo[1] + ' - ' + (res.error ? _('Not Installed') : _('Installed')));
});
});
return this.super('load', section_id);
}
so.default = 'direct-out';
so.rmempty = false;
so.depends('type', 'remote');
so.default = '';
if (api_secret) {
if (features.hp_has_nginx && nginx_support === '1') {
so.description = _('The current API URL is <code>%s</code>')
.format('https://' + window.location.hostname + '/homeproxy/');
} else {
so.description = _('The current API URL is <code>%s</code>')
.format('http://' + window.location.hostname + ':' + api_port);
}
}
so = ss.option(form.Value, 'update_interval', _('Update interval'),
_('Update interval of rule set.<br/><code>1d</code> will be used if empty.'));
so.depends('type', 'remote');
/* Rule set settings end */
so = ss.option(form.Flag, 'set_dash_backend', _('Auto set backend'),
_('Auto set backend address for dashboard.'));
so.default = so.disabled;
so = ss.option(form.Value, 'clash_api_port', _('Port'));
so.datatype = "and(port, min(1))";
so.default = '9090';
so.rmempty = false;
so = ss.option(form.Value, 'clash_api_secret', _('Secret'), _('Automatically generated if empty'));
so.password = true;
if (api_secret)
so.description = _('The current Secret is <code>' + api_secret + '</code>');
/* Clash API settings end */
/* ACL settings start */
s.tab('control', _('Access Control'));

View File

@@ -9,6 +9,7 @@
'require fs';
'require uci';
'require ui';
'require dom';
'require view';
'require homeproxy as hp';
@@ -355,7 +356,7 @@ function parseShareLink(uri, features) {
return config;
}
function renderNodeSettings(section, data, features, main_node, routing_mode) {
function renderNodeSettings(section, data, features, main_node, routing_mode, subs_info, proxy_nodes) {
var s = section, o;
s.rowcolors = true;
s.sortable = true;
@@ -408,16 +409,18 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.value('wireguard', _('WireGuard'));
o.value('vless', _('VLESS'));
o.value('vmess', _('VMess'));
o.value('selector', _('Selector'));
o.value('urltest', _('URLTest'));
o.rmempty = false;
o = s.option(form.Value, 'address', _('Address'));
o.datatype = 'host';
o.depends({'type': 'direct', '!reverse': true});
o.depends({'type': /^(direct|selector|urltest)$/, '!reverse': true});
o.rmempty = false;
o = s.option(form.Value, 'port', _('Port'));
o.datatype = 'port';
o.depends({'type': 'direct', '!reverse': true});
o.depends({'type': /^(direct|selector|urltest)$/, '!reverse': true});
o.rmempty = false;
o = s.option(form.Value, 'username', _('Username'));
@@ -462,18 +465,24 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
_('Override the connection destination address.'));
o.datatype = 'host';
o.depends('type', 'direct');
o.modalonly = true;
o = s.option(form.Value, 'override_port', _('Override port'),
_('Override the connection destination port.'));
o.datatype = 'port';
o.depends('type', 'direct');
o.modalonly = true;
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
o = s.option(form.Flag, 'proxy_protocol', _('Proxy protocol'),
_('Write proxy protocol in the connection header.'));
o.value('', _('Disable'));
o.depends('type', 'direct');
o.modalonly = true;
o = s.option(form.ListValue, 'proxy_protocol_version', _('Proxy protocol version'));
o.value('1', _('v1'));
o.value('2', _('v2'));
o.depends('type', 'direct');
o.default = '2';
o.depends('proxy_protocol', '1');
o.modalonly = true;
/* Hysteria (2) config start */
@@ -707,6 +716,89 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.modalonly = true;
/* VMess config end */
/* Selector config start */
o = s.option(form.MultiValue, 'group', _('Subscription Groups'),
_('List of subscription groups.'));
o.value('', _('-- Please choose --'));
for (var key in subs_info) {
let title = subs_info[key].name;
o.value(key, _('Sub (%s)').format(title));
}
o.depends('type', 'selector');
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.MultiValue, 'order', _('Outbounds'),
_('List of outbound tags.'));
o.value('direct-out', _('Direct'));
o.value('block-out', _('Block'));
for (var key in proxy_nodes)
o.value(key, proxy_nodes[key]);
o.depends({'group': /^$/, 'type': /^(selector|urltest)$/});
o.modalonly = true;
o = s.option(form.Value, 'default_selected', _('Default Outbound'),
_('The default outbound tag. The first outbound will be used if empty.'));
o.value('', _('Default'));
o.value('direct-out', _('Direct'));
o.value('block-out', _('Block'));
for (var key in proxy_nodes)
o.value(key, proxy_nodes[key]);
o.default = '';
o.depends({'group': /^$/, 'type': 'selector'});
o.modalonly = true;
o = s.option(form.ListValue, 'filter_nodes', _('Filter nodes'),
_('Drop/keep specific nodes from outbounds.'));
o.value('', _('Disable'));
o.value('blacklist', _('Blacklist mode'));
o.value('whitelist', _('Whitelist mode'));
o.default = '';
o.depends('type', 'selector');
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.DynamicList, 'filter_keywords', _('Filter keywords'),
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> is supported.'));
o.depends({'filter_nodes': '', '!reverse': true});
o.modalonly = true;
/* Selector config end */
/* URLTest config start */
o = s.option(form.Value, 'test_url', _('Test URL'),
_('The URL to test. https://www.gstatic.com/generate_204 will be used if empty.'));
o.value('', _('Default'));
o.default = 'http://cp.cloudflare.com/';
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.Value, 'interval', _('Interval'),
_('The test interval. <code>3m</code> will be used if empty.'));
o.value('', _('Default'));
o.default = '10m';
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.Value, 'tolerance', _('Tolerance'),
_('The test tolerance in milliseconds. 50 will be used if empty.'));
o.datatype = 'uinteger';
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.Value, 'idle_timeout', _('Idle timeout'),
_('The idle timeout. <code>30m</code> will be used if empty.'));
o.default = '30m';
o.depends('type', 'urltest');
o.modalonly = true;
o = s.option(form.Flag, 'interrupt_exist_connections', _('Interrupt existing connections'),
_('Interrupt existing connections when the selected outbound has changed.'));
o.default = o.disabled;
o.depends('type', 'selector');
o.depends('type', 'urltest');
o.modalonly = true;
/* URLTest config end */
/* Transport config start */
o = s.option(form.ListValue, 'transport', _('Transport'),
_('No TCP transport, plain HTTP is merged into the HTTP transport.'));
@@ -1109,15 +1201,18 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
/* Extra settings start */
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'));
o.default = o.disabled;
o.depends({'type': /^(selector|urltest)$/, '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
o.default = o.disabled;
o.depends({'type': /^(selector|urltest)$/, '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
_('Enable UDP fragmentation.'));
o.default = o.disabled;
o.depends({'type': /^(selector|urltest)$/, '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'udp_over_tcp', _('UDP over TCP'),
@@ -1152,31 +1247,25 @@ return view.extend({
var routing_mode = uci.get(data[0], 'config', 'routing_mode');
var features = data[1];
/* Cache subscription information, it will be called multiple times */
var subinfo = [];
for (var suburl of (uci.get(data[0], 'subscription', 'subscription_url') || [])) {
const url = new URL(suburl);
const urlhash = hp.calcStringMD5(suburl.replace(/#.*$/, ''));
const title = url.hash ? decodeURIComponent(url.hash.slice(1)) : url.hostname;
subinfo.push({ 'hash': urlhash, 'title': title });
}
m = new form.Map('homeproxy', _('Edit nodes'));
/* Cache all subscription info, they will be called multiple times */
var subs_info = hp.loadSubscriptionInfo(data[0]);
/* Cache all configured proxy nodes, they will be called multiple times */
var proxy_nodes = hp.loadNodesList(data[0], subs_info);
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
/* Node settings start */
/* User nodes start */
s.tab('node', _('Nodes'));
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode, subs_info, proxy_nodes);
ss.addremove = true;
ss.filter = function(section_id) {
for (var info of subinfo)
if (info.hash === uci.get(data[0], section_id, 'grouphash'))
return false;
return true;
return uci.get(data[0], section_id, 'grouphash') ? false : true;
}
/* Import subscription links start */
/* Thanks to luci-app-shadowsocks-libev */
@@ -1239,13 +1328,31 @@ return view.extend({
])
])
}
ss.renderSectionAdd = function(/* ... */) {
ss.renderSectionAdd = function(extra_class) {
var el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments),
selectEl = E('select', {
class: 'cbi-input-select',
change: L.bind(function(section, ev) {
var el = dom.parent(ev.target, '.cbi-section-create'),
button = el.querySelector('.cbi-section-create > .cbi-button-add'),
inputname = el.querySelector('.cbi-section-create-name').value || '';
var uciconfig = section.uciconfig || section.map.config;
button.toggleAttribute('disabled',
!inputname ||
uci.get(uciconfig, inputname) ||
uci.get(uciconfig, ev.target.value + inputname));
}, this, ss)
}, [
E('option', { value: 'node_' }, _('node')),
E('option', { value: 'sub_' }, _('sub'))
]),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
var button = el.querySelector('.cbi-section-create > .cbi-button-add');
var uciconfig = this.uciconfig || this.map.config;
var prefix = el.querySelector('.cbi-input-select').value;
if (!v) {
button.disabled = true;
@@ -1253,12 +1360,17 @@ return view.extend({
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else if (uci.get(uciconfig, prefix + v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique label'));
} else {
button.disabled = null;
return true;
}
}, 'blur', 'keyup');
el.prepend(E('div', {}, selectEl));
el.appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Import share links'),
@@ -1267,16 +1379,26 @@ return view.extend({
return el;
}
ss.handleAdd = function(ev, name) {
var selectEl = ev.target.parentElement.firstElementChild.firstElementChild,
prefix = selectEl.value;
return form.GridSection.prototype.handleAdd.apply(this, [ ev, prefix + name ]);
}
/* Import subscription links end */
/* User nodes end */
/* Subscription nodes start */
for (const info of subinfo) {
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
for (var key in subs_info) {
const urlhash = key,
title = subs_info[key].name;
s.tab('sub_' + urlhash, _('Sub (%s)').format(title));
o = s.taboption('sub_' + urlhash, form.SectionValue, '_sub_' + urlhash, form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode, subs_info, proxy_nodes);
ss.filter = function(section_id) {
return (uci.get(data[0], section_id, 'grouphash') === info.hash);
return (uci.get(data[0], section_id, 'grouphash') === urlhash);
}
}
/* Subscription nodes end */
@@ -1286,14 +1408,16 @@ return view.extend({
s.tab('subscription', _('Subscriptions'));
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
_('Auto update subscriptions and geodata.'));
_('Auto update subscriptions.'));
o.default = o.disabled;
o.rmempty = false;
o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time'));
for (var i = 0; i < 24; i++)
o.value(i, i + ':00');
o.default = '2';
o = s.taboption('subscription', form.Value, 'auto_update_expr', _('Cron expression'),
_('The default value is 2:00 every day'));
o.default = '0 2 * * *';
o.placeholder = '0 2 * * *';
o.rmempty = false;
o.retain = true;
o.depends('auto_update', '1');
o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'),
@@ -1303,6 +1427,7 @@ return view.extend({
o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'),
_('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.'));
o.placeholder = 'https://sub_url#sub_name';
o.validate = function(section_id, value) {
if (section_id && value) {
try {
@@ -1320,16 +1445,15 @@ return view.extend({
o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'),
_('Drop/keep specific nodes from subscriptions.'));
o.value('disabled', _('Disable'));
o.value('', _('Disable'));
o.value('blacklist', _('Blacklist mode'));
o.value('whitelist', _('Whitelist mode'));
o.default = 'disabled';
o.rmempty = false;
o.default = '';
o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'),
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> is supported.'));
o.depends({'filter_nodes': 'disabled', '!reverse': true});
o.rmempty = false;
o.depends({'filter_nodes': '', '!reverse': true});
o.retain = true;
o = s.taboption('subscription', form.Flag, 'allow_insecure', _('Allow insecure'),
_('Allow insecure connection by default when add nodes from subscriptions.') +

View File

@@ -0,0 +1,277 @@
/*
* SPDX-License-Identifier: GPL-3.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
'use strict';
'require form';
'require fs';
'require uci';
'require ui';
'require view';
'require homeproxy as hp';
const docdata = 'base64,' + 'cmxzdHBsYWNlaG9sZGVy'
function parseRulesetLink(uri) {
var config,
filefmt = new RegExp(/^(json|srs)$/),
unuciname = new RegExp(/[^a-zA-Z0-9_]+/, "g");
uri = uri.split('://');
if (uri[0] && uri[1]) {
switch (uri[0]) {
case 'http':
case 'https':
var url = new URL('http://' + uri[1]);
var file = url.searchParams.get('file');
var rawquery = url.searchParams.get('rawquery');
var name = decodeURIComponent(url.pathname.split('/').pop())
.replace(/[\s\.-]/g, '_').replace(unuciname, '');
if (filefmt.test(file)) {
var fullpath = (url.username ? url.username + '@' : '') + url.host + url.pathname + (rawquery ? '?' + decodeURIComponent(rawquery) : '');
config = {
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : name ? name : null,
type: 'remote',
format: file.match(/^json$/) ? 'source' : file.match(/^srs$/) ? 'binary' : 'unknown',
url: String.format('%s://%s', uri[0], fullpath),
href: String.format('http://%s', fullpath)
};
}
break;
case 'file':
var url = new URL('file://' + uri[1]);
var file = url.searchParams.get('file');
var name = decodeURIComponent(url.pathname.split('/').pop())
.replace(/[\s\.-]/g, '_').replace(unuciname, '');
if (filefmt.test(file)) {
config = {
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : name ? name : null,
type: 'local',
format: file.match(/^json$/) ? 'source' : file.match(/^srs$/) ? 'binary' : 'unknown',
path: url.pathname,
href: String.format('file://%s%s', url.host, url.pathname)
};
}
break;
}
}
if (config) {
if (!config.type || !config.href)
return null;
else if (!config.label)
config.label = hp.calcStringMD5(config.href);
}
return config;
}
return view.extend({
load: function() {
return Promise.all([
uci.load('homeproxy')
]);
},
render: function(data) {
var m, s, o;
m = new form.Map('homeproxy', _('Edit ruleset'));
/* Rule set settings start */
var prefix = 'rule_';
s = m.section(form.GridSection, 'ruleset');
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.modaltitle = L.bind(hp.loadModalTitle, this, _('Rule set'), _('Add a rule set'), data[0]);
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
/* Import rule-set links start */
s.handleLinkImport = function() {
var textarea = new ui.Textarea('', {
'placeholder': 'http(s)://github.com/sagernet/sing-geoip/raw/rule-set/geoip-hk.srs?file=srs&rawquery=good%3Djob#GeoIP-HK\n' +
'file:///etc/homeproxy/ruleset/example.json?file=json#Example%20file\n'
});
ui.showModal(_('Import rule-set links'), [
E('p', _('Supports rule-set links of type: <code>local, remote</code> and format: <code>source, binary</code>.</br>') +
_('Please refer to <a href="%s" target="_blank">%s</a> for link format standards.')
.format('data:text/html;' + docdata, _('Ruleset-URI-Scheme'))),
textarea.render(),
E('div', { class: 'right' }, [
E('button', {
class: 'btn',
click: ui.hideModal
}, [ _('Cancel') ]),
'',
E('button', {
class: 'btn cbi-button-action',
click: ui.createHandlerFn(this, function() {
var input_links = textarea.getValue().trim().split('\n');
if (input_links && input_links[0]) {
/* Remove duplicate lines */
input_links = input_links.reduce((pre, cur) =>
(!pre.includes(cur) && pre.push(cur), pre), []);
var imported_ruleset = 0;
input_links.forEach((l) => {
var config = parseRulesetLink(l);
if (config) {
var hrefHash = hp.calcStringMD5(config.href);
config.href = null;
var sid = uci.add(data[0], 'ruleset', hrefHash);
Object.keys(config).forEach((k) => {
uci.set(data[0], sid, k, config[k]);
});
imported_ruleset++;
}
});
if (imported_ruleset === 0)
ui.addNotification(null, E('p', _('No valid rule-set link found.')));
else
ui.addNotification(null, E('p', _('Successfully imported %s rule-set of total %s.').format(
imported_ruleset, input_links.length)));
return uci.save()
.then(L.bind(this.map.load, this.map))
.then(L.bind(this.map.reset, this.map))
.then(L.ui.hideModal)
.catch(function() {});
} else {
return ui.hideModal();
}
})
}, [ _('Import') ])
])
])
}
s.renderSectionAdd = function(extra_class) {
var el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
var button = el.querySelector('.cbi-section-create > .cbi-button-add');
var uciconfig = this.uciconfig || this.map.config;
if (!v) {
button.disabled = true;
return true;
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else if (uci.get(uciconfig, prefix + v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique label'));
} else {
button.disabled = null;
return true;
}
}, 'blur', 'keyup');
el.appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Import rule-set links'),
'click': ui.createHandlerFn(this, 'handleLinkImport')
}, [ _('Import rule-set links') ]));
return el;
}
s.handleAdd = function(ev, name) {
return form.GridSection.prototype.handleAdd.apply(this, [ ev, prefix + name ]);
}
/* Import rule-set links end */
o = s.option(form.Value, 'label', _('Label'));
o.load = L.bind(hp.loadDefaultLabel, this, data[0]);
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'ruleset', 'label');
o.modalonly = true;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.rmempty = false;
o.editable = true;
o = s.option(form.ListValue, 'type', _('Type'));
o.value('local', _('Local'));
o.value('remote', _('Remote'));
o.default = 'remote';
o.rmempty = false;
o = s.option(form.ListValue, 'format', _('Format'));
o.value('source', _('Source file'));
o.value('binary', _('Binary file'));
o.default = 'source';
o.rmempty = false;
o = s.option(form.Value, 'path', _('Path'));
o.datatype = 'file';
o.placeholder = '/etc/homeproxy/ruleset/example.json';
o.rmempty = false;
o.depends('type', 'local');
o.modalonly = true;
o = s.option(form.Value, 'url', _('Rule set URL'));
o.validate = function(section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
try {
var url = new URL(value);
if (!url.hostname)
return _('Expecting: %s').format(_('valid URL'));
}
catch(e) {
return _('Expecting: %s').format(_('valid URL'));
}
}
return true;
}
o.rmempty = false;
o.depends('type', 'remote');
o.modalonly = true;
o = s.option(form.ListValue, 'outbound', _('Outbound'),
_('Tag of the outbound to download rule set.'));
o.load = function(section_id) {
delete this.keylist;
delete this.vallist;
this.value('direct-out', _('Direct'));
uci.sections(data[0], 'routing_node', (res) => {
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
return this.super('load', section_id);
}
o.default = 'direct-out';
o.rmempty = false;
//o.editable = true;
o.textvalue = function(section_id) {
var cval = this.cfgvalue(section_id) || this.default;
var remote = L.bind(function() {
let cval = this.cfgvalue(section_id) || this.default;
return (cval === 'remote') ? true : false;
}, s.getOption('type'))
return remote() ? cval : _('none');
};
o.depends('type', 'remote');
o = s.option(form.Value, 'update_interval', _('Update interval'),
_('Update interval of rule set.<br/><code>1d</code> will be used if empty.'));
o.depends('type', 'remote');
/* Rule set settings end */
return m.render();
}
});

View File

@@ -6,6 +6,7 @@
'use strict';
'require form';
'require fs';
'require poll';
'require rpc';
'require uci';
@@ -54,6 +55,7 @@ function handleGenKey(option) {
required_method = this.section.getOption('shadowsocks_encrypt_method')?.formvalue(section_id);
switch (required_method) {
/* AEAD */
case 'aes-128-gcm':
case '2022-blake3-aes-128-gcm':
password = hp.generateRand('base64', 16);
@@ -68,12 +70,15 @@ function handleGenKey(option) {
case '2022-blake3-chacha20-poly1305':
password = hp.generateRand('base64', 32);
break;
/* NONE */
case 'none':
password = '';
break;
/* UUID */
case 'uuid':
password = hp.generateRand('uuid');
break;
/* PLAIN */
default:
password = hp.generateRand('hex', 16);
break;
@@ -113,6 +118,17 @@ return view.extend({
s = m.section(form.NamedSection, 'server', 'homeproxy', _('Global settings'));
o = s.option(form.Button, '_reload_server', _('Quick Reload'));
o.inputtitle = _('Reload');
o.inputstyle = 'apply';
o.onclick = function() {
return fs.exec('/etc/init.d/homeproxy', ['reload', 'server'])
.then((res) => { return window.location = window.location.href.split('#')[0] })
.catch((e) => {
ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/homeproxy %s %s" reason: %s').format('reload', 'server', e)));
});
};
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.disabled;
o.rmempty = false;
@@ -122,13 +138,15 @@ return view.extend({
o.rmempty = false;
s = m.section(form.GridSection, 'server', _('Server settings'));
var prefmt = { 'prefix': 'server_', 'suffix': '' };
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.modaltitle = L.bind(hp.loadModalTitle, this, _('Server'), _('Add a server'), data[0]);
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
s.renderSectionAdd = L.bind(hp.renderSectionAdd, this, s);
s.renderSectionAdd = L.bind(hp.renderSectionAdd, this, s, prefmt, false);
s.handleAdd = L.bind(hp.handleAdd, this, s, prefmt);
o = s.option(form.Value, 'label', _('Label'));
o.load = L.bind(hp.loadDefaultLabel, this, data[0]);

View File

@@ -1,5 +1,4 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
/* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2022-2023 ImmortalWrt.org
*/
@@ -60,27 +59,27 @@ function getConnStat(self, site) {
]);
}
function getResVersion(self, type) {
function getResVersion(self, type, repo) {
var callResVersion = rpc.declare({
object: 'luci.homeproxy',
method: 'resources_get_version',
params: ['type'],
params: ['type', 'repo'],
expect: { '': {} }
});
var callResUpdate = rpc.declare({
object: 'luci.homeproxy',
method: 'resources_update',
params: ['type'],
params: ['type', 'repo'],
expect: { '': {} }
});
return L.resolveDefault(callResVersion(type), {}).then((res) => {
return L.resolveDefault(callResVersion(type, repo), {}).then((res) => {
var spanTemp = E('div', { 'style': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, function() {
return L.resolveDefault(callResUpdate(type), {}).then((res) => {
return L.resolveDefault(callResUpdate(type, repo), {}).then((res) => {
switch (res.status) {
case 0:
self.description = _('Successfully updated.');
@@ -184,7 +183,8 @@ return view.extend({
render: function(data) {
var m, s, o;
var routing_mode = uci.get(data[0], 'config', 'routing_mode') || 'bypass_mainland_china';
var routing_mode = uci.get(data[0], 'config', 'routing_mode') || 'bypass_mainland_china',
dashboard_repo = uci.get(data[0], 'experimental', 'dashboard_repo') || '';
m = new form.Map('homeproxy');
@@ -201,6 +201,12 @@ return view.extend({
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
s.anonymous = true;
if (routing_mode === 'custom' && dashboard_repo !== '') {
o = s.option(form.DummyValue, '_clash_dashboard_version', _('Clash dashboard version'));
o.cfgvalue = function() { return getResVersion(this, 'clash_dashboard', dashboard_repo) };
o.rawhtml = true;
}
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
o.cfgvalue = function() { return getResVersion(this, 'china_ip4') };
o.rawhtml = true;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,9 @@ config homeproxy 'config'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
config homeproxy 'experimental'
option clash_api_port '9090'
config homeproxy 'control'
option lan_proxy_mode 'disabled'
list wan_proxy_ipv4_ips '91.105.192.0/23'

View File

@@ -50,7 +50,7 @@ if (routing_mode !== 'custom') {
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
}
let routing_port = uci.get(cfgname, 'config', 'routing_port');
let routing_port = uci.get(cfgname, 'config', 'routing_port') || 'common';
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
@@ -88,9 +88,6 @@ const control_info = {};
for (let i in control_options)
control_info[i] = uci.get(cfgname, 'control', i);
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
dns_port = uci.get('dhcp', '@dnsmasq[0]', 'port') || '53';
/* UCI config end */
-%}
@@ -222,7 +219,7 @@ set homeproxy_wan_direct_addr_v6 {
}
{% endif /* ipv6_support */ %}
{% if (routing_port): %}
{% if (routing_port !== 'all'): %}
set homeproxy_routing_port {
type inet_service
flags interval
@@ -231,16 +228,6 @@ set homeproxy_routing_port {
}
{% endif %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if (dns_hijacked !== '1'): %}
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dns_port }} comment "!{{ cfgname }}: DNS hijack"
{% endif /* dns_hijacked */ %}
{% if (match(proxy_mode, /redirect/)): %}
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
{% endif /* proxy_mode */ %}
}
{# TCP redirect #}
{% if (match(proxy_mode, /redirect/)): %}
chain homeproxy_redirect_proxy {
@@ -248,7 +235,7 @@ chain homeproxy_redirect_proxy {
}
chain homeproxy_redirect_proxy_port {
{% if (routing_port): %}
{% if (routing_port !== 'all'): %}
tcp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_redirect_proxy
@@ -351,6 +338,10 @@ chain homeproxy_output_redir {
type nat hook output priority filter -105; policy accept
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
}
chain dstnat {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
}
{% endif %}
{# UDP tproxy #}
@@ -363,14 +354,14 @@ chain homeproxy_mangle_tproxy {
}
chain homeproxy_mangle_tproxy_port {
{% if (routing_port): %}
{% if (routing_port !== 'all'): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_mangle_tproxy
}
chain homeproxy_mangle_mark {
{% if (routing_port): %}
{% if (routing_port !== 'all'): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
meta l4proto udp mark set {{ tproxy_mark }} counter accept
@@ -380,7 +371,6 @@ chain homeproxy_mangle_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(split(join(' ', control_info.listen_interfaces) + ' lo', ' ')) }} counter return
{% endif %}
meta iifname != lo udp dport 53 counter return
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
@@ -523,7 +513,6 @@ chain mangle_output {
{% if (match(proxy_mode, /tun/)): %}
chain homeproxy_mangle_lanac {
iifname {{ tun_name }} counter return
udp dport 53 counter return
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(control_info.listen_interfaces) }} counter return
@@ -557,7 +546,7 @@ chain homeproxy_mangle_lanac {
}
chain homeproxy_mangle_tun_mark {
{% if (routing_port): %}
{% if (routing_port !== 'all'): %}
{% if (proxy_mode === 'tun'): %}
tcp dport != @homeproxy_routing_port counter return
{% endif /* proxy_mode */ %}

View File

@@ -11,9 +11,11 @@ import { readfile, writefile } from 'fs';
import { isnan } from 'math';
import { cursor } from 'uci';
import { urldecode } from 'luci.http';
import {
executeCommand, isEmpty, strToBool, strToInt,
removeBlankAttrs, validateHostname, validation,
executeCommand, shellQuote, calcStringCRC8, calcStringMD5, isEmpty, strToBool, strToInt,
removeBlankAttrs, parseURL, validateHostname, validation, filterCheck,
HP_DIR, RUN_DIR
} from 'homeproxy';
@@ -25,6 +27,7 @@ uci.load(uciconfig);
const uciinfra = 'infra',
ucimain = 'config',
ucisub = 'subscription',
uciexp = 'experimental',
ucicontrol = 'control';
@@ -49,7 +52,7 @@ else
const dns_port = uci.get(uciconfig, uciinfra, 'dns_port') || '5333';
let main_node, main_udp_node, dedicated_udp_node, default_outbound, domain_strategy, sniff_override = '1',
let main_node, main_udp_node, dedicated_udp_node, default_outbound, sniff_override = '1',
dns_server, dns_default_strategy, dns_default_server, dns_disable_cache, dns_disable_cache_expire,
dns_independent_cache, dns_client_subnet, direct_domain_list, proxy_domain_list;
@@ -80,7 +83,6 @@ if (routing_mode !== 'custom') {
/* Routing settings */
default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil';
domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy');
sniff_override = uci.get(uciconfig, uciroutingsetting, 'sniff_override');
}
@@ -91,6 +93,13 @@ const proxy_mode = uci.get(uciconfig, ucimain, 'proxy_mode') || 'redirect_tproxy
const cache_file_store_rdrc = uci.get(uciconfig, uciexp, 'cache_file_store_rdrc'),
cache_file_rdrc_timeout = uci.get(uciconfig, uciexp, 'cache_file_rdrc_timeout');
const clash_api_enabled = uci.get(uciconfig, uciexp, 'clash_api_enabled'),
nginx_support = uci.get(uciconfig, uciexp, 'nginx_support'),
clash_api_log_level = uci.get(uciconfig, uciexp, 'clash_api_log_level') || 'warn',
dashboard_repo = uci.get(uciconfig, uciexp, 'dashboard_repo'),
clash_api_port = uci.get(uciconfig, uciexp, 'clash_api_port') || '9090',
clash_api_secret = uci.get(uciconfig, uciexp, 'clash_api_secret') || trim(readfile('/proc/sys/kernel/random/uuid'));
const mixed_port = uci.get(uciconfig, uciinfra, 'mixed_port') || '5330';
let self_mark, redirect_port, tproxy_port,
tun_name, tun_addr4, tun_addr6, tun_mtu, tun_gso,
@@ -118,6 +127,24 @@ if (match(proxy_mode), /tun/) {
endpoint_independent_nat = uci.get(uciconfig, uciroutingsetting, 'endpoint_independent_nat');
}
}
let subs_info = {};
{
const suburls = uci.get(uciconfig, ucisub, 'subscription_url') || [];
for (let i = 0; i < length(suburls); i++) {
const url = parseURL(suburls[i]);
const urlhash = calcStringMD5(replace(suburls[i], /#.*$/, ''));
subs_info[urlhash] = {
"url": replace(suburls[i], /#.*$/, ''),
"name": url.hash ? urldecode(url.hash) : url.hostname
};
}
}
let checkedout_nodes = [],
nodes_tobe_checkedout = [],
checkedout_groups = [],
groups_tobe_checkedout = [];
/* UCI config end */
/* Config helper start */
@@ -145,13 +172,75 @@ function parse_dnsquery(strquery) {
}
function get_tag(cfg, failback_tag, filterable) {
if (isEmpty(cfg))
return null;
let node = {};
if (type(cfg) === 'object')
node = cfg;
else {
if (cfg in ['direct-out', 'block-out'])
return cfg;
else
node = uci.get_all(uciconfig, cfg);
}
//filter check
if (!isEmpty(filterable))
if (filterCheck(node.label, filterable.filter_nodes, filterable.filter_keywords))
return null;
const sub_info = subs_info[node.grouphash];
return node.label ? sprintf("%s%s", node.grouphash ?
sprintf("[%s] ", sub_info ? sub_info.name : calcStringCRC8(node.grouphash)) : '',
node.label) :
(failback_tag || null);
}
function generate_outbound(node) {
if (type(node) !== 'object' || isEmpty(node))
return null;
push(checkedout_nodes, node['.name']);
if (node.type in ['selector', 'urltest']) {
let outbounds = [];
for (let grouphash in node.group) {
if (!isEmpty(grouphash)) {
const output = executeCommand(`/sbin/uci -q show ${shellQuote(uciconfig)} | /bin/grep "\.grouphash='*${shellQuote(grouphash)}'*" | /usr/bin/cut -f2 -d'.'`) || {};
if (!isEmpty(trim(output.stdout)))
for (let order in split(trim(output.stdout), /\n/))
push(outbounds, get_tag(order, 'cfg-' + order + '-out', { "filter_nodes": node.filter_nodes, "filter_keywords": node.filter_keywords }));
if (!(grouphash in groups_tobe_checkedout))
push(groups_tobe_checkedout, grouphash);
}
}
for (let order in node.order) {
push(outbounds, get_tag(order, 'cfg-' + order + '-out', { "filter_nodes": node.filter_nodes, "filter_keywords": node.filter_keywords }));
if (!(order in ['direct-out', 'block-out']) && !(order in nodes_tobe_checkedout))
push(nodes_tobe_checkedout, order);
}
if (length(outbounds) === 0)
push(outbounds, 'direct-out', 'block-out');
return {
type: node.type,
tag: get_tag(node, 'cfg-' + node['.name'] + '-out'),
/* Selector */
outbounds: outbounds,
default: node.default_selected ? (get_tag(node.default_selected, 'cfg-' + node.default_selected + '-out')) : null,
/* URLTest */
url: node.test_url,
interval: node.interval,
tolerance: strToInt(node.tolerance),
idle_timeout: node.idle_timeout,
interrupt_exist_connections: strToBool(node.interrupt_exist_connections)
};
}
const outbound = {
type: node.type,
tag: 'cfg-' + node['.name'] + '-out',
tag: get_tag(node, 'cfg-' + node['.name'] + '-out'),
routing_mark: strToInt(self_mark),
server: node.address,
@@ -164,7 +253,10 @@ function generate_outbound(node) {
/* Direct */
override_address: node.override_address,
override_port: strToInt(node.override_port),
proxy_protocol: strToInt(node.proxy_protocol),
proxy_protocol: (node.proxy_protocol === '1') ? {
enabled: true,
version: strToInt(node.proxy_protocol_version)
} : null,
/* Hysteria (2) */
up_mbps: strToInt(node.hysteria_up_mbps),
down_mbps: strToInt(node.hysteria_down_mbps),
@@ -299,7 +391,7 @@ function get_outbound(cfg) {
if (isEmpty(node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else
return 'cfg-' + node + '-out';
return get_tag(node, 'cfg-' + node + '-out');
}
}
}
@@ -330,7 +422,7 @@ const config = {};
/* Log */
config.log = {
disabled: false,
level: 'warn',
level: (clash_api_enabled === '1') ? clash_api_log_level : 'warn',
output: RUN_DIR + '/sing-box-c.log',
timestamp: true
};
@@ -454,6 +546,7 @@ if (!isEmpty(main_node)) {
process_name: cfg.process_name,
process_path: cfg.process_path,
user: cfg.user,
clash_mode: cfg.clash_mode,
rule_set: get_ruleset(cfg.rule_set),
rule_set_ipcidr_match_source: (cfg.rule_set_ipcidr_match_source === '1') || null,
invert: (cfg.invert === '1') || null,
@@ -490,7 +583,6 @@ push(config.inbounds, {
udp_timeout: udp_timeout ? (udp_timeout + 's') : null,
sniff: true,
sniff_override_destination: (sniff_override === '1'),
domain_strategy: domain_strategy,
set_system_proxy: false
});
@@ -502,8 +594,7 @@ if (match(proxy_mode, /redirect/))
listen: '::',
listen_port: int(redirect_port),
sniff: true,
sniff_override_destination: (sniff_override === '1'),
domain_strategy: domain_strategy,
sniff_override_destination: (sniff_override === '1')
});
if (match(proxy_mode, /tproxy/))
push(config.inbounds, {
@@ -515,8 +606,7 @@ if (match(proxy_mode, /tproxy/))
network: 'udp',
udp_timeout: udp_timeout ? (udp_timeout + 's') : null,
sniff: true,
sniff_override_destination: (sniff_override === '1'),
domain_strategy: domain_strategy,
sniff_override_destination: (sniff_override === '1')
});
if (match(proxy_mode, /tun/))
push(config.inbounds, {
@@ -534,7 +624,6 @@ if (match(proxy_mode, /tun/))
stack: tcpip_stack,
sniff: true,
sniff_override_destination: (sniff_override === '1'),
domain_strategy: domain_strategy,
});
/* Inbound end */
@@ -574,10 +663,45 @@ if (!isEmpty(main_node)) {
const outbound = uci.get_all(uciconfig, cfg.node) || {};
push(config.outbounds, generate_outbound(outbound));
config.outbounds[length(config.outbounds)-1].domain_strategy = cfg.domain_strategy;
config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface;
config.outbounds[length(config.outbounds)-1].detour = get_outbound(cfg.outbound);
const type = config.outbounds[length(config.outbounds)-1].type;
if (!(type in ['selector', 'urltest'])) {
config.outbounds[length(config.outbounds)-1].domain_strategy = cfg.domain_strategy;
config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface;
config.outbounds[length(config.outbounds)-1].detour = get_outbound(cfg.outbound);
}
});
/* Second level outbounds */
while (length(nodes_tobe_checkedout) > 0) {
const oldarr = uniq(nodes_tobe_checkedout);
nodes_tobe_checkedout = [];
map(oldarr, (k) => {
if (!(k in checkedout_nodes)) {
const outbound = uci.get_all(uciconfig, k) || {};
push(config.outbounds, generate_outbound(outbound));
push(checkedout_nodes, k);
}
});
}
while (length(groups_tobe_checkedout) > 0) {
const oldarr = uniq(groups_tobe_checkedout);
let newarr = [];
groups_tobe_checkedout = [];
map(oldarr, (k) => {
if (!(k in checkedout_groups)) {
push(newarr, k);
push(checkedout_groups, k);
}
});
const hashexp = regexp('^' + replace(replace(replace(sprintf("%J", newarr), /^\[(.*)\]$/g, "($1)"), /[" ]/g, ''), ',', '|') + '$', 'is');
uci.foreach(uciconfig, ucinode, (cfg) => {
if (!(cfg['.name'] in checkedout_nodes) && match(cfg?.grouphash, hashexp)) {
push(config.outbounds, generate_outbound(cfg));
push(checkedout_nodes, cfg['.name']);
}
});
}
/* Outbound end */
/* Routing rules start */
@@ -639,6 +763,7 @@ if (!isEmpty(main_node)) {
process_name: cfg.process_name,
process_path: cfg.process_path,
user: cfg.user,
clash_mode: cfg.clash_mode,
rule_set: get_ruleset(cfg.rule_set),
rule_set_ipcidr_match_source: (cfg.rule_set_ipcidr_match_source === '1') || null,
invert: (cfg.invert === '1') || null,
@@ -673,11 +798,23 @@ if (routing_mode === 'custom') {
config.experimental = {
cache_file: {
enabled: true,
path: RUN_DIR + '/cache.db',
path: HP_DIR + '/cache.db',
store_rdrc: (cache_file_store_rdrc === '1') || null,
rdrc_timeout: cache_file_rdrc_timeout
}
};
/* Clash API */
if (dashboard_repo) {
system('rm -rf ' + RUN_DIR + '/ui');
const dashpkg = HP_DIR + '/resources/' + replace(dashboard_repo, '/', '_') + '.zip';
system('unzip -qo ' + dashpkg + ' -d ' + RUN_DIR + '/');
system('mv ' + RUN_DIR + '/*-gh-pages/ ' + RUN_DIR + '/ui/');
}
config.experimental.clash_api = {
external_controller: (clash_api_enabled === '1') ? (nginx_support ? '[::1]:' : '[::]:') + clash_api_port : null,
external_ui: dashboard_repo ? RUN_DIR + '/ui' : null,
secret: clash_api_secret
};
}
/* Experimental end */

View File

@@ -52,6 +52,48 @@ export function executeCommand(...args) {
};
};
export function hexencArray(str) {
if (!str || type(str) !== 'string')
return null;
const hexstr = hexenc(str);
let arr = [];
for (let i = 0; i < length(hexstr) / 2; i++)
push(arr, hex('0x' + substr(hexstr, i * 2, 2)));
return arr;
};
export function calcStringCRC8(str) {
if (!str || type(str) !== 'string')
return null;
const crc8Table = [
0, 7, 14, 9, 28, 27, 18, 21, 56, 63, 54, 49, 36, 35, 42, 45,
112, 119, 126, 121, 108, 107, 98, 101, 72, 79, 70, 65, 84, 83, 90, 93,
224, 231, 238, 233, 252, 251, 242, 245, 216, 223, 214, 209, 196, 195, 202, 205,
144, 151, 158, 153, 140, 139, 130, 133, 168, 175, 166, 161, 180, 179, 186, 189,
199, 192, 201, 206, 219, 220, 213, 210, 255, 248, 241, 246, 227, 228, 237, 234,
183, 176, 185, 190, 171, 172, 165, 162, 143, 136, 129, 134, 147, 148, 157, 154,
39, 32, 41, 46, 59, 60, 53, 50, 31, 24, 17, 22, 3, 4, 13, 10,
87, 80, 89, 94, 75, 76, 69, 66, 111, 104, 97, 102, 115, 116, 125, 122,
137, 142, 135, 128, 149, 146, 155, 156, 177, 182, 191, 184, 173, 170, 163, 164,
249, 254, 247, 240, 229, 226, 235, 236, 193, 198, 207, 200, 221, 218, 211, 212,
105, 110, 103, 96, 117, 114, 123, 124, 81, 86, 95, 88, 77, 74, 67, 68,
25, 30, 23, 16, 5, 2, 11, 12, 33, 38, 47, 40, 61, 58, 51, 52,
78, 73, 64, 71, 82, 85, 92, 91, 118, 113, 120, 127, 106, 109, 100, 99,
62, 57, 48, 55, 34, 37, 44, 43, 6, 1, 8, 15, 26, 29, 20, 19,
174, 169, 160, 167, 178, 181, 188, 187, 150, 145, 152, 159, 138, 141, 132, 131,
222, 217, 208, 215, 194, 197, 204, 203, 230, 225, 232, 239, 250, 253, 244, 243
];
const strArray = hexencArray(str);
let crc8 = 0;
for (let i = 0; i < length(strArray); i++)
crc8 = crc8Table[(crc8 ^ strArray[i]) & 255];
return substr('00' + sprintf("%X", crc8), -2);
};
export function calcStringMD5(str) {
if (!str || type(str) !== 'string')
return null;
@@ -134,6 +176,22 @@ export function validation(datatype, data) {
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
return (ret === 0);
};
export function filterCheck(name, filter_mode, filter_keywords) {
if (isEmpty(name) || isEmpty(filter_mode) || isEmpty(filter_keywords))
return false;
let ret = false;
for (let i in filter_keywords) {
const patten = regexp(i);
if (match(name, patten))
ret = true;
}
if (filter_mode === 'whitelist')
ret = !ret;
return ret;
};
/* String helper end */
/* String parser start */

View File

@@ -3,6 +3,8 @@
#
# Copyright (C) 2022-2023 ImmortalWrt.org
. /usr/share/libubox/jshn.sh
NAME="homeproxy"
RESOURCES_DIR="/etc/$NAME/resources"
@@ -37,6 +39,67 @@ to_upper() {
echo -e "$1" | tr "[a-z]" "[A-Z]"
}
get_local_vers() {
local ver_file="$1"
local repoid="$2"
local ver="$(eval "jsonfilter -qi \"$ver_file\" -e '@[\"$repoid\"].version'")"
[ -n "$ver" ] && echo "$ver" || return 1
}
check_clash_dashboard_update() {
local dashtype="$1"
local dashrepo="$2"
local dashrepoid="$(echo -n "$dashrepo" | md5sum | cut -f1 -d' ')"
local wget="wget --timeout=10 -q"
set_lock "set" "$dashtype"
local dashdata_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/releases/latest" | jsonfilter -e "@.tag_name")"
[ -n "$dashdata_ver" ] || {
dashdata_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/tags" | jsonfilter -e "@[*].name" | head -n1)"
}
if [ -z "$dashdata_ver" ]; then
log "[$(to_upper "$dashtype")] [$dashrepo] Failed to get the latest version, please retry later."
set_lock "remove" "$dashtype"
return 1
fi
local local_dashdata_ver="$(get_local_vers "$RESOURCES_DIR/$dashtype.ver" "$dashrepoid" || echo "NOT FOUND")"
if [ "$local_dashdata_ver" = "$dashdata_ver" ]; then
log "[$(to_upper "$dashtype")] [$dashrepo] Current version: $dashdata_ver."
log "[$(to_upper "$dashtype")] [$dashrepo] You're already at the latest version."
set_lock "remove" "$dashtype"
return 3
else
log "[$(to_upper "$dashtype")] [$dashrepo] Local version: $local_dashdata_ver, latest version: $dashdata_ver."
fi
$wget "https://codeload.github.com/$dashrepo/zip/refs/heads/gh-pages" -O "$RUN_DIR/$dashtype.zip"
if [ ! -s "$RUN_DIR/$dashtype.zip" ]; then
rm -f "$RUN_DIR/$dashtype.zip"
log "[$(to_upper "$dashtype")] [$dashrepo] Update failed."
set_lock "remove" "$dashtype"
return 1
fi
mv -f "$RUN_DIR/$dashtype.zip" "$RESOURCES_DIR/${dashrepo//\//_}.zip"
touch "$RESOURCES_DIR/$dashtype.ver"
json_init
json_load_file "$RESOURCES_DIR/$dashtype.ver"
json_select "$dashrepoid" 2>/dev/null || json_add_object "$dashrepoid"
json_add_string repo "$dashrepo"
json_add_string version "$dashdata_ver"
json_dump > "$RESOURCES_DIR/$dashtype.ver"
log "[$(to_upper "$dashtype")] [$dashrepo] Successfully updated."
set_lock "remove" "$dashtype"
return 0
}
check_list_update() {
local listtype="$1"
local listrepo="$2"
@@ -85,6 +148,9 @@ check_list_update() {
}
case "$1" in
"clash_dashboard")
check_clash_dashboard_update "$1" "$2"
;;
"china_ip4")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
;;
@@ -99,7 +165,7 @@ case "$1" in
sed -i -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt"
;;
*)
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
echo -e "Usage: $0 <clash_dashboard / china_ip4 / china_ip6 / gfw_list / china_list>"
exit 1
;;
esac

View File

@@ -16,7 +16,7 @@ import { init_action } from 'luci.sys';
import {
calcStringMD5, wGET, executeCommand, decodeBase64Str,
getTime, isEmpty, parseURL, validation,
getTime, isEmpty, parseURL, validation, filterCheck,
HP_DIR, RUN_DIR
} from 'homeproxy';
@@ -31,7 +31,7 @@ const ucimain = 'config',
ucisubscription = 'subscription';
const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0',
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled',
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'nil',
filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [],
packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp',
subscription_urls = uci.get(uciconfig, ucisubscription, 'subscription_url') || [],
@@ -46,21 +46,6 @@ if (routing_mode !== 'custom') {
/* UCI config end */
/* String helper start */
function filter_check(name) {
if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords))
return false;
let ret = false;
for (let i in filter_keywords) {
const patten = regexp(i);
if (match(name, patten))
ret = true;
}
if (filter_mode === 'whitelist')
ret = !ret;
return ret;
}
/* String helper end */
/* Common var start */
@@ -489,7 +474,7 @@ function main() {
nameHash = calcStringMD5(label);
config.label = label;
if (filter_check(config.label))
if (filterCheck(config.label, filter_mode, filter_keywords))
log(sprintf('Skipping blacklist node: %s.', config.label));
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
log(sprintf('Skipping duplicate node: %s.', config.label));
@@ -543,10 +528,7 @@ function main() {
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
} else {
map(keys(node_cache[cfg.grouphash][cfg['.name']]), (v) => {
if (v in node_cache[cfg.grouphash][cfg['.name']])
uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]);
else
uci.delete(uciconfig, cfg['.name'], v);
uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]);
});
node_cache[cfg.grouphash][cfg['.name']].isExisting = true;
}

View File

@@ -15,6 +15,7 @@ HP_DIR="/etc/homeproxy"
RUN_DIR="/var/run/homeproxy"
LOG_PATH="$RUN_DIR/homeproxy.log"
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
APILOCATION_PATH="/etc/nginx/conf.d/homeproxy.locations"
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
@@ -56,14 +57,24 @@ start_service() {
fi
# Auto update
local auto_update auto_update_time
local auto_update auto_update_expr
config_get_bool auto_update "subscription" "auto_update" "0"
if [ "$auto_update" = "1" ]; then
config_get auto_update_time "subscription" "auto_update_time" "2"
echo -e "0 $auto_update_time * * * $HP_DIR/scripts/update_crond.sh" >> "/etc/crontabs/root"
config_get auto_update_expr "subscription" "auto_update_expr" "0 2 * * *"
echo -e "$auto_update_expr $HP_DIR/scripts/update_crond.sh" >> "/etc/crontabs/root"
/etc/init.d/cron restart
fi
# Clash API uses Nginx reverse proxy
local clash_api_enabled clash_api_port nginx_support
config_get_bool clash_api_enabled "experimental" "clash_api_enabled" "0"
config_get_bool nginx_support "experimental" "nginx_support" "0"
if [ "$clash_api_enabled" = "1" -a "$nginx_support" = "1" ]; then
config_get clash_api_port "experimental" "clash_api_port" "9090"
[ "$(sed -En "s|^\s*proxy_pass\s+https?://[^:]+:(\d+).*|\1|p" "$APILOCATION_PATH")" = "$clash_api_port" ] || sed -Ei "/\bproxy_pass\b/{s|(proxy_pass\s+https?://[^:]+:)(\d+)(.*)|\1$clash_api_port\3|}" "$APILOCATION_PATH"
/etc/init.d/nginx reload
fi
# DNSMasq rules
local ipv6_support
config_get_bool ipv6_support "config" "ipv6_support" "0"

View File

@@ -0,0 +1,12 @@
location /homeproxy/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:9090/;
proxy_redirect default;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
add_header Cache-Control no-cache;
}

View File

@@ -15,4 +15,6 @@ uci -q batch <<-EOF >"/dev/null"
commit firewall
EOF
[ -z "$(uci -q get homeproxy.experimental)" ] && uci set homeproxy.experimental=homeproxy && uci commit homeproxy
exit 0

View File

@@ -11,11 +11,18 @@ elif echo "$china_dns_server" | grep -q ","; then
uci -q add_list "homeproxy.config.china_dns_server"="$dns"
done
fi
if [ "$(uci -q get homeproxy.config.routing_port)" = "all" ]; then
uci -q delete "homeproxy.config.routing_port"
# rm Subscription Name-s
subscription_urls="$(uci -q get "homeproxy.subscription.subscription_url")"
subscription_names="$(uci -q get "homeproxy.subscription.subscription_name")"
if [ -n "$subscription_names" ]; then
uci -q delete "homeproxy.subscription.subscription_url"
uci -q delete "homeproxy.subscription.subscription_name"
i=1
for suburl in $subscription_urls; do
uci -q add_list "homeproxy.subscription.subscription_url"="${suburl}#$(echo "$subscription_names" | cut -f$i -d' ')"
let i++
done
fi
[ -z "$(uci -q changes "homeproxy")" ] || uci -q commit "homeproxy"
exit 0

View File

@@ -26,6 +26,14 @@
"path": "homeproxy/node"
}
},
"admin/services/homeproxy/ruleset": {
"title": "Ruleset Settings",
"order": 18,
"action": {
"type": "view",
"path": "homeproxy/ruleset"
}
},
"admin/services/homeproxy/server": {
"title": "Server Settings",
"order": 20,

View File

@@ -4,6 +4,7 @@
"read": {
"file": {
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
"/etc/init.d/homeproxy reload *": [ "exec" ],
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]

View File

@@ -179,28 +179,45 @@ const methods = {
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
features.hp_has_nginx = access('/usr/sbin/nginx');
return features;
}
},
resources_get_version: {
args: { type: 'type' },
args: { type: 'type', repo: 'repo' },
call: function(req) {
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
return { version: version, error: error() };
const versions = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
if (req.args?.repo && versions) {
const vers_arr = values(json(versions));
for (obj in vers_arr) {
if (obj.repo === req.args?.repo)
return { version: obj.version, error: 0 };
}
return { version: '', error: 1 };
} else
return { version: versions, error: error() };
}
},
resources_update: {
args: { type: 'type' },
args: { type: 'type', repo: 'repo' },
call: function(req) {
if (req.args?.type) {
const type = shellquote(req.args?.type);
const exit_code = system(`${HP_DIR}/scripts/update_resources.sh ${type}`);
const type = shellquote(req.args?.type),
repo = shellquote(req.args?.repo);
const exit_code = system(`${HP_DIR}/scripts/update_resources.sh ${type} ${repo}`);
return { status: exit_code };
} else
return { status: 255, error: 'illegal type' };
}
},
clash_api_get_secret: {
call: function() {
const client_json = json(trim(readfile(`${RUN_DIR}/sing-box-c.json`)));
return { secret: client_json.experimental.clash_api.secret };
}
}
};

View File

@@ -658,8 +658,11 @@ run_chinadns_ng() {
([ -z "${_default_tag}" ] || [ "${_default_tag}" = "smart" ] || [ "${_default_tag}" = "none_noip" ]) && _default_tag="none"
echo "default-tag ${_default_tag}" >> ${_CONF_FILE}
echo "cache 4096" >> ${_CONF_FILE}
echo "cache-stale 3600" >> ${_CONF_FILE}
[ "${_flag}" = "default" ] && [ "${_default_tag}" = "none" ] && {
echo "verdict-cache 4096" >> ${_CONF_FILE}
echo "verdict-cache 5000" >> ${_CONF_FILE}
}
ln_run "$(first_type chinadns-ng)" chinadns-ng "${_LOG_FILE}" -C ${_CONF_FILE}
@@ -1379,7 +1382,6 @@ start_dns() {
LOCAL_DNS=$(config_t_get global direct_dns_udp 223.5.5.5 | sed 's/:/#/g')
china_ng_local_dns=${LOCAL_DNS}
sing_box_local_dns="direct_dns_udp_server=${LOCAL_DNS}"
IPT_APPEND_DNS=${LOCAL_DNS}
;;
tcp)
LOCAL_DNS="127.0.0.1#${dns_listen_port}"
@@ -1387,7 +1389,6 @@ start_dns() {
local DIRECT_DNS=$(config_t_get global direct_dns_tcp 223.5.5.5 | sed 's/:/#/g')
china_ng_local_dns="tcp://${DIRECT_DNS}"
sing_box_local_dns="direct_dns_tcp_server=${DIRECT_DNS}"
IPT_APPEND_DNS="${LOCAL_DNS},${DIRECT_DNS}"
ln_run "$(first_type dns2tcp)" dns2tcp "/dev/null" -L "${LOCAL_DNS}" -R "$(get_first_dns DIRECT_DNS 53)" -v
echolog " - dns2tcp(${LOCAL_DNS}) -> tcp://$(get_first_dns DIRECT_DNS 53 | sed 's/#/:/g')"
echolog " * 请确保上游直连 DNS 支持 TCP 查询。"
@@ -1405,8 +1406,8 @@ start_dns() {
local tmp_dot_ip=$(echo "$DIRECT_DNS" | sed -n 's/.*:\/\/\([^@#]*@\)*\([^@#]*\).*/\2/p')
local tmp_dot_port=$(echo "$DIRECT_DNS" | sed -n 's/.*#\([0-9]\+\).*/\1/p')
sing_box_local_dns="direct_dns_dot_server=$tmp_dot_ip#${tmp_dot_port:-853}"
IPT_APPEND_DNS="${LOCAL_DNS},$tmp_dot_ip#${tmp_dot_port:-853}"
DIRECT_DNS=$tmp_dot_ip#${tmp_dot_port:-853}
sing_box_local_dns="direct_dns_dot_server=${DIRECT_DNS}"
else
echolog " - 你的ChinaDNS-NG版本不支持DoT直连DNS将使用默认地址。"
fi
@@ -1417,6 +1418,21 @@ start_dns() {
;;
esac
# 追加直连DNS到iptables/nftables
[ "$(config_t_get global_haproxy balancing_enable 0)" != "1" ] && IPT_APPEND_DNS=
add_default_port() {
[ -z "$1" ] && echo "" || echo "$1" | awk -F',' '{for(i=1;i<=NF;i++){if($i !~ /#/) $i=$i"#53";} print $0;}' OFS=','
}
LOCAL_DNS=$(add_default_port "$LOCAL_DNS")
IPT_APPEND_DNS=$(add_default_port "${IPT_APPEND_DNS:-$LOCAL_DNS}")
echo "$IPT_APPEND_DNS" | grep -q -E "(^|,)$LOCAL_DNS(,|$)" || IPT_APPEND_DNS="${IPT_APPEND_DNS:+$IPT_APPEND_DNS,}$LOCAL_DNS"
[ -n "$DIRECT_DNS" ] && {
DIRECT_DNS=$(add_default_port "$DIRECT_DNS")
echo "$IPT_APPEND_DNS" | grep -q -E "(^|,)$DIRECT_DNS(,|$)" || IPT_APPEND_DNS="${IPT_APPEND_DNS:+$IPT_APPEND_DNS,}$DIRECT_DNS"
}
# 排除127.0.0.1的条目
IPT_APPEND_DNS=$(echo "$IPT_APPEND_DNS" | awk -F',' '{for(i=1;i<=NF;i++) if($i !~ /^127\.0\.0\.1/) printf (i>1?",":"") $i; print ""}' | sed 's/^,\|,$//g')
TUN_DNS="127.0.0.1#${dns_listen_port}"
[ "${resolve_dns}" == "1" ] && TUN_DNS="127.0.0.1#${resolve_dns_port}"

View File

@@ -21,13 +21,13 @@ define Download/geoip
HASH:=944465ad5f3a3cccebf2930624f528cae3ca054f69295979cf4c4e002a575e90
endef
GEOSITE_VER:=20240905094227
GEOSITE_VER:=20240905162746
GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER)
define Download/geosite
URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/
URL_FILE:=dlc.dat
FILE:=$(GEOSITE_FILE)
HASH:=8edb9186aea5ef40b310f29b89bcf2be67ea65b04c010b4cdb9ddb02408557f0
HASH:=859306b7bc3a7891d5e0f5c8f38c2eaa8ede776c3a0aa1512b96c4956cf511c1
endef
GEOSITE_IRAN_VER:=202409020032

View File

@@ -14,6 +14,7 @@ var (
DialUDP = net.DialUDP
DialUnix = net.DialUnix
FileConn = net.FileConn
FileListener = net.FileListener
Listen = net.Listen
ListenTCP = net.ListenTCP
ListenUDP = net.ListenUDP

View File

@@ -15,7 +15,7 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/jhump/protoreflect v1.16.0
github.com/jhump/protoreflect v1.17.0
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40
github.com/miekg/dns v1.1.62
github.com/mustafaturan/bus v1.0.2
@@ -34,10 +34,10 @@ require (
github.com/xiaokangwang/VLite v0.0.0-20220418190619-cff95160a432
go.starlark.net v0.0.0-20230612165344-9532f5667272
go4.org/netipx v0.0.0-20230303233057-f1b76eb4bb35
golang.org/x/crypto v0.26.0
golang.org/x/net v0.28.0
golang.org/x/crypto v0.27.0
golang.org/x/net v0.29.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.24.0
golang.org/x/sys v0.25.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
@@ -51,7 +51,7 @@ require (
github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/boljen/go-bitmap v0.0.0-20151001105940-23cd2fb0ce7d // indirect
github.com/bufbuild/protocompile v0.10.0 // indirect
github.com/bufbuild/protocompile v0.14.1 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect
@@ -80,7 +80,7 @@ require (
go.uber.org/mock v0.4.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect

View File

@@ -34,8 +34,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/boljen/go-bitmap v0.0.0-20151001105940-23cd2fb0ce7d h1:zsO4lp+bjv5XvPTF58Vq+qgmZEYZttJK+CWtSZhKenI=
github.com/boljen/go-bitmap v0.0.0-20151001105940-23cd2fb0ce7d/go.mod h1:f1iKL6ZhUWvbk7PdWVmOaak10o86cqMUYEmn1CZNGEI=
github.com/bufbuild/protocompile v0.10.0 h1:+jW/wnLMLxaCEG8AX9lD0bQ5v9h1RUiMKOBOT5ll9dM=
github.com/bufbuild/protocompile v0.10.0/go.mod h1:G9qQIQo0xZ6Uyj6CMNz0saGmx2so+KONo8/KrELABiY=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -168,8 +168,8 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg=
github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -366,8 +366,8 @@ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -420,8 +420,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -469,8 +469,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -489,8 +489,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=

View File

@@ -14,6 +14,7 @@ import (
"github.com/v2fly/v2ray-core/v5/common/cmdarg"
"github.com/v2fly/v2ray-core/v5/common/platform"
"github.com/v2fly/v2ray-core/v5/main/commands/base"
"github.com/v2fly/v2ray-core/v5/main/plugins"
)
// CmdRun runs V2Ray with config
@@ -75,6 +76,12 @@ func setConfigFlags(cmd *base.Command) {
func executeRun(cmd *base.Command, args []string) {
setConfigFlags(cmd)
var pluginFuncs []func() error
for _, plugin := range plugins.Plugins {
if f := plugin(cmd); f != nil {
pluginFuncs = append(pluginFuncs, f)
}
}
cmd.Flag.Parse(args)
printVersion()
configFiles = getConfigFilePath()
@@ -83,6 +90,14 @@ func executeRun(cmd *base.Command, args []string) {
base.Fatalf("Failed to start: %s", err)
}
for _, f := range pluginFuncs {
go func(f func() error) {
if err := f(); err != nil {
log.Print(err)
}
}(f)
}
if err := server.Start(); err != nil {
base.Fatalf("Failed to start: %s", err)
}

View File

@@ -33,7 +33,6 @@ import (
// Developer preview features
_ "github.com/v2fly/v2ray-core/v5/app/instman"
_ "github.com/v2fly/v2ray-core/v5/app/observatory"
_ "github.com/v2fly/v2ray-core/v5/app/restfulapi"
_ "github.com/v2fly/v2ray-core/v5/app/tun"
// Inbound and outbound proxies.

View File

@@ -0,0 +1,11 @@
package plugins
import "github.com/v2fly/v2ray-core/v5/main/commands/base"
var Plugins []Plugin
type Plugin func(*base.Command) func() error
func RegisterPlugin(plugin Plugin) {
Plugins = append(Plugins, plugin)
}

View File

@@ -0,0 +1,29 @@
package plugin_pprof
import (
"github.com/v2fly/v2ray-core/v5/main/plugins"
"net/http"
"net/http/pprof"
"github.com/v2fly/v2ray-core/v5/main/commands/base"
)
var pprofPlugin plugins.Plugin = func(cmd *base.Command) func() error {
addr := cmd.Flag.String("pprof", "", "")
return func() error {
if *addr != "" {
h := http.NewServeMux()
h.HandleFunc("/debug/pprof/", pprof.Index)
h.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
h.HandleFunc("/debug/pprof/profile", pprof.Profile)
h.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
h.HandleFunc("/debug/pprof/trace", pprof.Trace)
return (&http.Server{Addr: *addr, Handler: h}).ListenAndServe()
}
return nil
}
}
func init() {
plugins.RegisterPlugin(pprofPlugin)
}

View File

@@ -0,0 +1,13 @@
//go:build !unix
// +build !unix
package internet
import (
"fmt"
"github.com/v2fly/v2ray-core/v5/common/net"
)
func activateSocket(address string, f func(network, address string, fd uintptr)) (net.Listener, error) {
return nil, fmt.Errorf("socket activation is not supported on this platform")
}

View File

@@ -0,0 +1,63 @@
//go:build unix
// +build unix
package internet
import (
"fmt"
"os"
"path"
"strconv"
"syscall"
"github.com/v2fly/v2ray-core/v5/common/net"
)
func activateSocket(address string, f func(network, address string, fd uintptr)) (net.Listener, error) {
fd, err := strconv.Atoi(path.Base(address))
if err != nil {
return nil, err
}
err = syscall.SetNonblock(fd, true)
if err != nil {
return nil, err
}
acceptConn, err := syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_ACCEPTCONN)
if err != nil {
return nil, err
}
if acceptConn == 0 {
return nil, fmt.Errorf("socket '%s' has not been marked to accept connections", address)
}
sockType, err := syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_TYPE)
if err != nil {
return nil, err
}
if sockType != syscall.SOCK_STREAM {
// XXX: currently only stream socks are supported
return nil, fmt.Errorf("socket '%s' is not a stream socket", address)
}
ufd := uintptr(fd)
sa, err := syscall.Getsockname(fd)
if err != nil {
return nil, err
}
switch sa := sa.(type) {
case *syscall.SockaddrInet4:
addr := net.TCPAddr{IP: sa.Addr[:], Port: sa.Port, Zone: ""}
f("tcp4", addr.String(), ufd)
case *syscall.SockaddrInet6:
addr := net.TCPAddr{IP: sa.Addr[:], Port: sa.Port, Zone: strconv.Itoa(int(sa.ZoneId))}
f("tcp6", addr.String(), ufd)
}
file := os.NewFile(ufd, address)
defer file.Close()
return net.FileListener(file)
}

View File

@@ -36,29 +36,35 @@ func (l *combinedListener) Close() error {
return l.Listener.Close()
}
func getRawControlFunc(network, address string, ctx context.Context, sockopt *SocketConfig, controllers []controller) func(fd uintptr) {
return func(fd uintptr) {
if sockopt != nil {
if err := applyInboundSocketOptions(network, fd, sockopt); err != nil {
newError("failed to apply socket options to incoming connection").Base(err).WriteToLog(session.ExportIDToError(ctx))
}
}
setReusePort(fd) // nolint: staticcheck
for _, controller := range controllers {
if err := controller(network, address, fd); err != nil {
newError("failed to apply external controller").Base(err).WriteToLog(session.ExportIDToError(ctx))
}
}
}
}
func getControlFunc(ctx context.Context, sockopt *SocketConfig, controllers []controller) func(network, address string, c syscall.RawConn) error {
return func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
if sockopt != nil {
if err := applyInboundSocketOptions(network, fd, sockopt); err != nil {
newError("failed to apply socket options to incoming connection").Base(err).WriteToLog(session.ExportIDToError(ctx))
}
}
setReusePort(fd) // nolint: staticcheck
for _, controller := range controllers {
if err := controller(network, address, fd); err != nil {
newError("failed to apply external controller").Base(err).WriteToLog(session.ExportIDToError(ctx))
}
}
})
return c.Control(getRawControlFunc(network, address, ctx, sockopt, controllers))
}
}
func (dl *DefaultListener) Listen(ctx context.Context, addr net.Addr, sockopt *SocketConfig) (net.Listener, error) {
var lc net.ListenConfig
var network, address string
var l net.Listener
var err error
// callback is called after the Listen function returns
// this is used to wrap the listener and do some post processing
callback := func(l net.Listener, err error) (net.Listener, error) {
@@ -93,6 +99,14 @@ func (dl *DefaultListener) Listen(ctx context.Context, addr net.Addr, sockopt *S
copy(fullAddr, address[1:])
address = string(fullAddr)
}
} else if strings.HasPrefix(address, "/dev/fd/") {
// socket activation
l, err = activateSocket(address, func(network, address string, fd uintptr) {
getRawControlFunc(network, address, ctx, sockopt, dl.controllers)(fd)
})
if err != nil {
return nil, err
}
} else {
// normal unix domain socket
var fileMode *os.FileMode
@@ -133,13 +147,18 @@ func (dl *DefaultListener) Listen(ctx context.Context, addr net.Addr, sockopt *S
}
}
l, err := lc.Listen(ctx, network, address)
l, err = callback(l, err)
if err == nil && sockopt != nil && sockopt.AcceptProxyProtocol {
if l == nil {
l, err = lc.Listen(ctx, network, address)
l, err = callback(l, err)
if err != nil {
return nil, err
}
}
if sockopt != nil && sockopt.AcceptProxyProtocol {
policyFunc := func(upstream net.Addr) (proxyproto.Policy, error) { return proxyproto.REQUIRE, nil }
l = &proxyproto.Listener{Listener: l, Policy: policyFunc}
}
return l, err
return l, nil
}
func (dl *DefaultListener) ListenPacket(ctx context.Context, addr net.Addr, sockopt *SocketConfig) (net.PacketConn, error) {

View File

@@ -237,6 +237,10 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config {
ClientCAs: clientRoot,
}
if c.AllowInsecureIfPinnedPeerCertificate && c.PinnedPeerCertificateChainSha256 != nil {
config.InsecureSkipVerify = true
}
for _, opt := range opts {
opt(config)
}

View File

@@ -232,6 +232,8 @@ type Config struct {
MinVersion Config_TLSVersion `protobuf:"varint,9,opt,name=min_version,json=minVersion,proto3,enum=v2ray.core.transport.internet.tls.Config_TLSVersion" json:"min_version,omitempty"`
// Maximum TLS version to support.
MaxVersion Config_TLSVersion `protobuf:"varint,10,opt,name=max_version,json=maxVersion,proto3,enum=v2ray.core.transport.internet.tls.Config_TLSVersion" json:"max_version,omitempty"`
// Whether or not to allow self-signed certificates when pinned_peer_certificate_chain_sha256 is present.
AllowInsecureIfPinnedPeerCertificate bool `protobuf:"varint,11,opt,name=allow_insecure_if_pinned_peer_certificate,json=allowInsecureIfPinnedPeerCertificate,proto3" json:"allow_insecure_if_pinned_peer_certificate,omitempty"`
}
func (x *Config) Reset() {
@@ -336,6 +338,13 @@ func (x *Config) GetMaxVersion() Config_TLSVersion {
return Config_Default
}
func (x *Config) GetAllowInsecureIfPinnedPeerCertificate() bool {
if x != nil {
return x.AllowInsecureIfPinnedPeerCertificate
}
return false
}
var File_transport_internet_tls_config_proto protoreflect.FileDescriptor
var file_transport_internet_tls_config_proto_rawDesc = []byte{
@@ -367,7 +376,7 @@ var file_transport_internet_tls_config_proto_rawDesc = []byte{
0x59, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x41, 0x55, 0x54, 0x48, 0x4f, 0x52, 0x49, 0x54, 0x59,
0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x10, 0x02, 0x12, 0x1b, 0x0a, 0x17, 0x41, 0x55, 0x54, 0x48,
0x4f, 0x52, 0x49, 0x54, 0x59, 0x5f, 0x56, 0x45, 0x52, 0x49, 0x46, 0x59, 0x5f, 0x43, 0x4c, 0x49,
0x45, 0x4e, 0x54, 0x10, 0x03, 0x22, 0xd9, 0x05, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x45, 0x4e, 0x54, 0x10, 0x03, 0x22, 0xb2, 0x06, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x2d, 0x0a, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x6e, 0x73, 0x65, 0x63, 0x75,
0x72, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x42, 0x06, 0x82, 0xb5, 0x18, 0x02, 0x28, 0x01,
0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x49, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x12,
@@ -406,22 +415,28 @@ var file_transport_internet_tls_config_proto_rawDesc = []byte{
0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f,
0x72, 0x74, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2e, 0x74, 0x6c, 0x73, 0x2e,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x54, 0x4c, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x52, 0x0a, 0x6d, 0x61, 0x78, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x49, 0x0a,
0x0a, 0x54, 0x4c, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x44,
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53, 0x31,
0x5f, 0x30, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53, 0x31, 0x5f, 0x31, 0x10, 0x02,
0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53, 0x31, 0x5f, 0x32, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06,
0x54, 0x4c, 0x53, 0x31, 0x5f, 0x33, 0x10, 0x04, 0x3a, 0x17, 0x82, 0xb5, 0x18, 0x13, 0x0a, 0x08,
0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x12, 0x03, 0x74, 0x6c, 0x73, 0x90, 0xff, 0x29,
0x01, 0x42, 0x84, 0x01, 0x0a, 0x25, 0x63, 0x6f, 0x6d, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e,
0x63, 0x6f, 0x72, 0x65, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x69,
0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2e, 0x74, 0x6c, 0x73, 0x50, 0x01, 0x5a, 0x35, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x32, 0x66, 0x6c, 0x79, 0x2f,
0x76, 0x32, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x35, 0x2f, 0x74, 0x72,
0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74,
0x2f, 0x74, 0x6c, 0x73, 0xaa, 0x02, 0x21, 0x56, 0x32, 0x52, 0x61, 0x79, 0x2e, 0x43, 0x6f, 0x72,
0x65, 0x2e, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x49, 0x6e, 0x74, 0x65,
0x72, 0x6e, 0x65, 0x74, 0x2e, 0x54, 0x6c, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x6e, 0x52, 0x0a, 0x6d, 0x61, 0x78, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x57, 0x0a,
0x29, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x5f,
0x69, 0x66, 0x5f, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63,
0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08,
0x52, 0x24, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x49, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x49,
0x66, 0x50, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x43, 0x65, 0x72, 0x74, 0x69,
0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x22, 0x49, 0x0a, 0x0a, 0x54, 0x4c, 0x53, 0x56, 0x65, 0x72,
0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x10,
0x00, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53, 0x31, 0x5f, 0x30, 0x10, 0x01, 0x12, 0x0a, 0x0a,
0x06, 0x54, 0x4c, 0x53, 0x31, 0x5f, 0x31, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53,
0x31, 0x5f, 0x32, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x4c, 0x53, 0x31, 0x5f, 0x33, 0x10,
0x04, 0x3a, 0x17, 0x82, 0xb5, 0x18, 0x13, 0x0a, 0x08, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74,
0x79, 0x12, 0x03, 0x74, 0x6c, 0x73, 0x90, 0xff, 0x29, 0x01, 0x42, 0x84, 0x01, 0x0a, 0x25, 0x63,
0x6f, 0x6d, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x74, 0x72,
0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74,
0x2e, 0x74, 0x6c, 0x73, 0x50, 0x01, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63,
0x6f, 0x6d, 0x2f, 0x76, 0x32, 0x66, 0x6c, 0x79, 0x2f, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2d, 0x63,
0x6f, 0x72, 0x65, 0x2f, 0x76, 0x35, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74,
0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2f, 0x74, 0x6c, 0x73, 0xaa, 0x02, 0x21,
0x56, 0x32, 0x52, 0x61, 0x79, 0x2e, 0x43, 0x6f, 0x72, 0x65, 0x2e, 0x54, 0x72, 0x61, 0x6e, 0x73,
0x70, 0x6f, 0x72, 0x74, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2e, 0x54, 0x6c,
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -438,7 +453,7 @@ func file_transport_internet_tls_config_proto_rawDescGZIP() []byte {
var file_transport_internet_tls_config_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_transport_internet_tls_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_transport_internet_tls_config_proto_goTypes = []interface{}{
var file_transport_internet_tls_config_proto_goTypes = []any{
(Certificate_Usage)(0), // 0: v2ray.core.transport.internet.tls.Certificate.Usage
(Config_TLSVersion)(0), // 1: v2ray.core.transport.internet.tls.Config.TLSVersion
(*Certificate)(nil), // 2: v2ray.core.transport.internet.tls.Certificate
@@ -462,7 +477,7 @@ func file_transport_internet_tls_config_proto_init() {
return
}
if !protoimpl.UnsafeEnabled {
file_transport_internet_tls_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
file_transport_internet_tls_config_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*Certificate); i {
case 0:
return &v.state
@@ -474,7 +489,7 @@ func file_transport_internet_tls_config_proto_init() {
return nil
}
}
file_transport_internet_tls_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
file_transport_internet_tls_config_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*Config); i {
case 0:
return &v.state

View File

@@ -76,4 +76,6 @@ message Config {
// Maximum TLS version to support.
TLSVersion max_version = 10;
// Whether or not to allow self-signed certificates when pinned_peer_certificate_chain_sha256 is present.
bool allow_insecure_if_pinned_peer_certificate = 11;
}

View File

@@ -1,4 +1,5 @@
using YamlDotNet.Serialization;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ServiceLib.Common
@@ -35,13 +36,17 @@ namespace ServiceLib.Common
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public static string ToYaml(Object obj)
public static string ToYaml(Object? obj)
{
string result = string.Empty;
if (obj == null)
{
return result;
}
var serializer = new SerializerBuilder()
.WithNamingConvention(HyphenatedNamingConvention.Instance)
.Build();
string result = string.Empty;
try
{
result = serializer.Serialize(obj);
@@ -53,6 +58,24 @@ namespace ServiceLib.Common
return result;
}
public static string? PreprocessYaml(string str)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
.Build();
try
{
var mergingParser = new MergingParser(new Parser(new StringReader(str)));
var obj = new DeserializerBuilder().Build().Deserialize(mergingParser);
return ToYaml(obj);
}
catch (Exception ex)
{
Logging.SaveLog("PreprocessYaml", ex);
return null;
}
}
#endregion YAML
}
}

Some files were not shown because too many files have changed in this diff Show More