diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index dd65ea6b..93634d72 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -61,7 +61,6 @@ jobs: with: filters: | relevant: ${{ env.RELEVANT_PATHS }} - build_web: runs-on: ubuntu-latest needs: pre_job @@ -342,13 +341,22 @@ jobs: name: easytier-linux-aarch64 path: ./downloaded-binaries/ # 独立目录避免冲突 - # 将二进制文件复制到 Magisk 模块目录 - - name: Prepare binaries + - name: Download Magisk WebUI + uses: actions/download-artifact@v4 + with: + name: easytier-web-dashboard + path: ./downloaded-webui/ + + # 将二进制文件和 WebUI 复制到 Magisk 模块目录 + - name: Prepare Magisk module run: | - mkdir -p ./easytier-contrib/easytier-magisk/ - cp ./downloaded-binaries/easytier-core ./easytier-contrib/easytier-magisk/ - cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/ - cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/ + module_dir=./easytier-contrib/easytier-magisk + mkdir -p "$module_dir/webroot" + cp ./downloaded-binaries/easytier-core "$module_dir/" + cp ./downloaded-binaries/easytier-cli "$module_dir/" + cp ./downloaded-binaries/easytier-web "$module_dir/" + cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/" + test -f "$module_dir/webroot/index.html" # 上传生成的模块 - name: Upload Magisk Module diff --git a/easytier-contrib/easytier-magisk/README.md b/easytier-contrib/easytier-magisk/README.md index a9edd7e2..1d30b460 100644 --- a/easytier-contrib/easytier-magisk/README.md +++ b/easytier-contrib/easytier-magisk/README.md @@ -1,6 +1,23 @@ -# easytier_magisk版模块 -magisk安装后重启 +# EasyTier Magisk 模块 -目录位置:/data/adb/modules/easytier_magisk -配置文件位置://data/adb/modules/easytier_magisk/config/config.toml -修改config.conf即可,修改后配置文件后去magisk app重新开关模块即可生效 +安装模块并重启后,EasyTier 会读取 +`/data/adb/modules/easytier_magisk/config/config.toml` 启动。 + +## WebUI + +KernelSU 及支持模块 WebUI 的分支管理器可直接从模块详情页打开配置 +界面。Magisk 用户可安装兼容 KernelSU 模块 WebUI 的独立管理器后打开。 + +WebUI 使用 EasyTier 自身的配置模型加载和校验配置。点击“保存并重启”后, +模块会先验证配置,原子替换 `config.toml`,再重启正在运行的 EasyTier +进程。校验失败时原配置保持不变。 + +如果 `config/command_args` 存在,模块处于启动参数模式并会忽略 +`config.toml`。WebUI 会显示提示并禁止保存;删除 `command_args` 后即可 +使用 WebUI 管理配置。 + +## 手动配置 + +也可以直接编辑 +`/data/adb/modules/easytier_magisk/config/config.toml`。保存后在模块管理器中 +禁用 EasyTier,等待 10 秒后重新启用即可让配置生效。 diff --git a/easytier-contrib/easytier-magisk/build.sh b/easytier-contrib/easytier-magisk/build.sh index 2d48ed02..09f7d828 100644 --- a/easytier-contrib/easytier-magisk/build.sh +++ b/easytier-contrib/easytier-magisk/build.sh @@ -1,25 +1,32 @@ #!/bin/sh +WEBUI_SOURCE="../../easytier-web/config-generator/dist" -version=$(cat module.prop | grep 'version=' | awk -F '=' '{print $2}' | sed 's/ (.*//') +if [ -f "${WEBUI_SOURCE}/index.html" ]; then + rm -rf ./webroot + mkdir -p ./webroot + cp -R "${WEBUI_SOURCE}/." ./webroot/ +elif [ ! -f "./webroot/index.html" ]; then + echo "Error: WebUI 构建产物不存在,请先运行 pnpm --dir ../../easytier-web/config-generator build." + exit 1 +fi -version='v'$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2) +version=$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2) if [ -z "$version" ]; then echo "Error: 版本号不存在." exit 1 fi +version="v${version}" filename="easytier_magisk_${version}.zip" -echo $version +echo "${version}" - -if [ -f "./easytier-core" ] && [ -f "./easytier-cli" ] && [ -f "./easytier-web" ]; then - zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json' -else - wget -O "easytier_last.zip" https://github.com/EasyTier/EasyTier/releases/download/"$version"/easytier-linux-aarch64-"$version".zip +if [ ! -f "./easytier-core" ] || [ ! -f "./easytier-cli" ] || [ ! -f "./easytier-web" ]; then + wget -O "easytier_last.zip" "https://github.com/EasyTier/EasyTier/releases/download/${version}/easytier-linux-aarch64-${version}.zip" unzip -o easytier_last.zip -d ./ mv ./easytier-linux-aarch64/* ./ rm -rf ./easytier_last.zip rm -rf ./easytier-linux-aarch64 - zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json' -fi \ No newline at end of file +fi + +zip -r -o -X "${filename}" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json' \ No newline at end of file diff --git a/easytier-contrib/easytier-magisk/config/config.toml b/easytier-contrib/easytier-magisk/config/config.toml index 3fb8d1f7..75197b02 100644 --- a/easytier-contrib/easytier-magisk/config/config.toml +++ b/easytier-contrib/easytier-magisk/config/config.toml @@ -16,7 +16,7 @@ rpc_portal = "0.0.0.0:15888" network_name = "default" network_secret = "" -[[peer]] +#[[peer]] #uri = "协议://中转ip:端口" [flags] @@ -29,7 +29,7 @@ latency_first = false enable_exit_node = false no_tun = false use_smoltcp = false -foreign_network_whitelist = "*" +relay_network_whitelist = "*" disable_p2p = false relay_all_peer_rpc = false disable_udp_hole_punching = false diff --git a/easytier-contrib/easytier-magisk/customize.sh b/easytier-contrib/easytier-magisk/customize.sh index 114e47de..d04e27fb 100644 --- a/easytier-contrib/easytier-magisk/customize.sh +++ b/easytier-contrib/easytier-magisk/customize.sh @@ -11,6 +11,7 @@ ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk" ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml" ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容" ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件" +ui_print "支持模块 WebUI 的管理器可直接打开配置界面;Magisk 用户可配合 KsuWebUI 使用" ui_print "----------------------------------" ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制" ui_print "----------------------------------" diff --git a/easytier-contrib/easytier-magisk/webui.sh b/easytier-contrib/easytier-magisk/webui.sh new file mode 100755 index 00000000..2b4f5ced --- /dev/null +++ b/easytier-contrib/easytier-magisk/webui.sh @@ -0,0 +1,77 @@ +#!/system/bin/sh + +MODDIR=$(CDPATH= cd "${0%/*}" 2>/dev/null && pwd -P) || { + echo "Failed to resolve module directory" >&2 + exit 1 +} +CONFIG_FILE="${MODDIR}/config/config.toml" +COMMAND_ARGS="${MODDIR}/config/command_args" +EASYTIER="${MODDIR}/easytier-core" + +fail() { + echo "$1" >&2 + exit 1 +} + +running_pids() { + for process_dir in /proc/[0-9]*; do + [ "$(readlink "${process_dir}/exe" 2>/dev/null)" = "${EASYTIER}" ] || continue + printf '%s\n' "${process_dir#/proc/}" + done +} + +is_running() { + [ -n "$(running_pids)" ] +} + +case "$1" in + status) + if is_running; then + echo "running" + else + echo "stopped" + fi + ;; + config-mode) + if [ -f "${COMMAND_ARGS}" ]; then + echo "command-args" + else + echo "toml" + fi + ;; + read-config) + [ -f "${CONFIG_FILE}" ] || fail "Configuration file not found: ${CONFIG_FILE}" + cat "${CONFIG_FILE}" + ;; + save-and-restart) + [ -f "${COMMAND_ARGS}" ] && fail "command_args is active; config.toml would be ignored" + [ -n "$2" ] || fail "Missing base64-encoded configuration" + [ -x "${EASYTIER}" ] || fail "easytier-core is not executable" + + TEMP_FILE="${CONFIG_FILE}.webui.$$" + trap 'rm -f "${TEMP_FILE}"' EXIT HUP INT TERM + umask 077 + + printf '%s' "$2" | base64 -d > "${TEMP_FILE}" \ + || fail "Failed to decode configuration" + + VALIDATION_OUTPUT=$("${EASYTIER}" --check-config -c "${TEMP_FILE}" 2>&1) + if [ $? -ne 0 ]; then + fail "${VALIDATION_OUTPUT:-Configuration validation failed}" + fi + + chmod 0644 "${TEMP_FILE}" + mv -f "${TEMP_FILE}" "${CONFIG_FILE}" \ + || fail "Failed to replace configuration" + trap - EXIT HUP INT TERM + + RUNNING_PIDS=$(running_pids) + for pid in ${RUNNING_PIDS}; do + kill "${pid}" || fail "Failed to restart EasyTier" + done + echo "saved" + ;; + *) + fail "Usage: $0 {status|config-mode|read-config|save-and-restart BASE64}" + ;; +esac diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index 91d725cc..e621f77f 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -84,6 +84,120 @@ pub trait NetworkConfigExt { fn new_from_config(config: impl ConfigLoader) -> Result; } +#[cfg(all( + feature = "browser-config", + any(test, all(target_arch = "wasm32", target_os = "unknown")) +))] +const FORM_MANAGED_TOML_FIELDS: &[&str] = &[ + "hostname", + "instance_id", + "ipv4", + "ipv6_public_addr_provider", + "ipv6_public_addr_auto", + "ipv6_public_addr_prefix", + "dhcp", + "network_identity", + "listeners", + "mapped_listeners", + "exit_nodes", + "peer", + "proxy_network", + "vpn_portal_config", + "routes", + "socks5_proxy", + "port_forward", + "secure_mode", + "acl", + "credential_file", + "managed_credentials", +]; + +#[cfg(all( + feature = "browser-config", + any(test, all(target_arch = "wasm32", target_os = "unknown")) +))] +const FORM_MANAGED_FLAG_FIELDS: &[&str] = &[ + "latency_first", + "dev_name", + "use_smoltcp", + "enable_ipv6", + "enable_kcp_proxy", + "disable_kcp_input", + "enable_quic_proxy", + "disable_quic_input", + "disable_p2p", + "p2p_only", + "lazy_p2p", + "bind_device", + "socket_mark", + "no_tun", + "enable_exit_node", + "relay_all_peer_rpc", + "need_p2p", + "multi_thread", + "proxy_forward_by_system", + "enable_encryption", + "relay_network_whitelist", + "disable_tcp_hole_punching", + "disable_udp_hole_punching", + "disable_upnp", + "disable_relay_data", + "prefer_peer_relay", + "enable_udp_broadcast_relay", + "disable_sym_hole_punching", + "accept_dns", + "mtu", + "instance_recv_bps_limit", + "private_mode", + "encryption_algorithm", + "data_compress_algo", +]; + +#[cfg(all( + feature = "browser-config", + any(test, all(target_arch = "wasm32", target_os = "unknown")) +))] +pub(crate) fn merge_network_config_toml( + original_toml: &str, + config: &NetworkConfig, +) -> Result { + let generated_toml = config.gen_config()?.dump(); + let mut original = toml::from_str::(original_toml) + .context("failed to parse the original TOML document")?; + let mut generated = toml::from_str::(&generated_toml) + .context("failed to parse the generated TOML document")?; + + for (key, value) in original.iter() { + if key != "flags" && !FORM_MANAGED_TOML_FIELDS.contains(&key.as_str()) { + generated.insert(key.clone(), value.clone()); + } + } + if !original.contains_key("instance_name") { + generated.remove("instance_name"); + } + + let mut merged_flags: toml::Table = original + .remove("flags") + .and_then(|value| value.try_into().ok()) + .unwrap_or_default(); + let generated_flags: toml::Table = generated + .remove("flags") + .and_then(|value| value.try_into().ok()) + .unwrap_or_default(); + for key in FORM_MANAGED_FLAG_FIELDS { + if let Some(value) = generated_flags.get(*key) { + merged_flags.insert((*key).to_owned(), value.clone()); + } else { + merged_flags.remove(*key); + } + } + if !merged_flags.is_empty() { + generated.insert("flags".to_owned(), toml::Value::Table(merged_flags)); + } + + toml::to_string_pretty(&generated).context("failed to serialize the merged TOML document") +} + fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result, anyhow::Error> { let uri = peer.uri.trim(); if uri.is_empty() { @@ -825,4 +939,49 @@ mod tests { assert!(config.get_vpn_portal_config().is_none()); } + + #[cfg(feature = "browser-config")] + #[test] + fn browser_merge_preserves_fields_outside_the_shared_form() { + let original = r#" +instance_name = "module-instance" +rpc_portal = "0.0.0.0:15888" +tcp_whitelist = ["22"] +stun_servers = ["custom.example.com:3478"] + +[network_identity] +network_name = "old-network" +network_secret = "secret" + +[flags] +default_protocol = "udp" +disable_p2p = true +"#; + let parsed = TomlConfigLoader::new_from_str(original).unwrap(); + let mut network_config = NetworkConfig::new_from_config(&parsed).unwrap(); + network_config.network_name = Some("edited-network".to_owned()); + network_config.disable_p2p = Some(false); + + let merged = merge_network_config_toml(original, &network_config).unwrap(); + let merged: toml::Table = toml::from_str(&merged).unwrap(); + + assert_eq!(merged["instance_name"].as_str(), Some("module-instance")); + assert_eq!(merged["rpc_portal"].as_str(), Some("0.0.0.0:15888")); + assert_eq!(merged["tcp_whitelist"][0].as_str(), Some("22")); + assert_eq!( + merged["stun_servers"][0].as_str(), + Some("custom.example.com:3478") + ); + assert_eq!( + merged["network_identity"]["network_name"].as_str(), + Some("edited-network") + ); + assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp")); + assert!( + !merged["flags"] + .as_table() + .unwrap() + .contains_key("disable_p2p") + ); + } } diff --git a/easytier-core/src/config/browser.rs b/easytier-core/src/config/browser.rs index 2913ee83..d73d6114 100644 --- a/easytier-core/src/config/browser.rs +++ b/easytier-core/src/config/browser.rs @@ -1,7 +1,7 @@ use wasm_bindgen::prelude::*; use super::{ - api_input::{NetworkConfig, NetworkConfigExt}, + api_input::{NetworkConfig, NetworkConfigExt, merge_network_config_toml}, toml::{ConfigLoader, TomlConfig}, }; @@ -18,6 +18,12 @@ pub fn generate_config(config_json: &str) -> Result { .map_err(js_error) } +#[wasm_bindgen] +pub fn merge_config(original_toml: &str, config_json: &str) -> Result { + let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?; + merge_network_config_toml(original_toml, &config).map_err(js_error) +} + #[wasm_bindgen] pub fn parse_config(toml_config: &str) -> Result { let config = TomlConfig::new_from_str(toml_config) diff --git a/easytier-web/config-generator/package.json b/easytier-web/config-generator/package.json index e7cb3a66..01ccbb4d 100644 --- a/easytier-web/config-generator/package.json +++ b/easytier-web/config-generator/package.json @@ -12,6 +12,7 @@ "dependencies": { "@primeuix/themes": "^1.2.3", "easytier-frontend-lib": "workspace:*", + "kernelsu": "3.0.2", "primevue": "^4.3.9", "vue": "^3.5.12" }, diff --git a/easytier-web/config-generator/src/App.vue b/easytier-web/config-generator/src/App.vue index 747e8598..36979895 100644 --- a/easytier-web/config-generator/src/App.vue +++ b/easytier-web/config-generator/src/App.vue @@ -1,20 +1,59 @@