Compare commits
	
		
			155 Commits
		
	
	
		
			v2.3.2
			...
			make_ospf_
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 936790be8b | ||
|   | 71679e889a | ||
|   | 7485f5f64e | ||
|   | bbe8f9f810 | ||
|   | eba9504fc2 | ||
|   | 67ac9b00ff | ||
|   | 3ffa6214ca | ||
|   | 6f278ab167 | ||
|   | f10b45a67c | ||
|   | cc8f35787e | ||
|   | 8f1786fa23 | ||
|   | 70dddeace3 | ||
|   | 8cc9da9d6d | ||
|   | 5292b87275 | ||
|   | 87b7b7ed7c | ||
|   | 999a486928 | ||
|   | 627e989faa | ||
|   | af95312949 | ||
|   | a452c34390 | ||
|   | 4d5330fa0a | ||
|   | 5e48626cb9 | ||
|   | ad7dc3a129 | ||
|   | 92fab5aafa | ||
|   | 841d525913 | ||
|   | d2efbbef04 | ||
|   | 971ef82679 | ||
|   | 020bf04ec4 | ||
|   | 4d91582fd8 | ||
|   | e9b4dbce6e | ||
|   | 00fd02c739 | ||
|   | c0d2045e52 | ||
|   | 835cd407bf | ||
|   | f5ba5bb146 | ||
|   | 7a694257d9 | ||
|   | 67abf4446d | ||
|   | 7035a3fef4 | ||
|   | 4445916ba7 | ||
|   | a102a8bfc7 | ||
|   | c9e8c35e77 | ||
|   | 1a1be8138a | ||
|   | e06e8a9e8a | ||
|   | 56fd6e4ab6 | ||
|   | 215db09925 | ||
|   | 9fff5e4fec | ||
|   | 802d3f78d7 | ||
|   | 3593035eb9 | ||
|   | 757d76c9da | ||
|   | 445e68ddd1 | ||
|   | b540ec3f46 | ||
|   | 5c90431876 | ||
|   | 793889c3b7 | ||
|   | eb42086f9c | ||
|   | d0efc40efb | ||
|   | ae704d1d5f | ||
|   | 525dfd9fc1 | ||
|   | 18bd178bbd | ||
|   | 088155f6f3 | ||
|   | b750faa66f | ||
|   | ef3309814d | ||
|   | b87a05b457 | ||
|   | 754439f03c | ||
|   | 2145ef40b9 | ||
|   | a3806e0190 | ||
|   | 0ceb58586b | ||
|   | 719a1fe7cf | ||
|   | 671b8d5a0c | ||
|   | e29206aef9 | ||
|   | 3299a77da3 | ||
|   | 0804fd6632 | ||
|   | ea76114d50 | ||
|   | 9304d3b227 | ||
|   | 78004de5e5 | ||
|   | 5b7384fddd | ||
|   | 08a92a53c3 | ||
|   | 34560af141 | ||
|   | 2e7e0088dd | ||
|   | d23366ea84 | ||
|   | df7eb47593 | ||
|   | 839a28a3d5 | ||
|   | 9c6d1dabdf | ||
|   | e6ec7f405c | ||
|   | 8f37d4ef7c | ||
|   | c37af8c1be | ||
|   | 489661a2ce | ||
|   | fa3e208668 | ||
|   | 4d240efde9 | ||
|   | d9bcbd9b31 | ||
|   | 35ff9b82fc | ||
|   | a511abb613 | ||
|   | 1eec27b5ff | ||
|   | 1de7777a71 | ||
|   | 975ca8bd9c | ||
|   | e43537939a | ||
|   | 0087ac3ffc | ||
|   | 7de4b33dd1 | ||
|   | 8ffc2f12e4 | ||
|   | 37b24164b6 | ||
|   | 8cdb27d43d | ||
|   | efa17a7c10 | ||
|   | 6d14e9e441 | ||
|   | e3e406dcde | ||
|   | d0a6c93c2c | ||
|   | 84bfac144c | ||
|   | 9eddb4b072 | ||
|   | 4fca0f40fe | ||
|   | 43b9e6e6e9 | ||
|   | 583c768f40 | ||
|   | b1b2421561 | ||
|   | 3d610c0f0f | ||
|   | 2ec88da823 | ||
|   | 5514de1187 | ||
|   | e70eed74e2 | ||
|   | 7dc5988620 | ||
|   | 354a4e1d7b | ||
|   | 5409c5bbe7 | ||
|   | 33ff9554cd | ||
|   | 975b4e7664 | ||
|   | 1f6a715939 | ||
|   | 8e7a8de5e5 | ||
|   | 4f53fccd25 | ||
|   | 876d550f68 | ||
|   | 2660ed5fda | ||
|   | 50c6f5ae6c | ||
|   | 85f0091056 | ||
|   | e25cd9be37 | ||
|   | 1fb5ca9475 | ||
|   | 7f3a9c021c | ||
|   | 0427b48d75 | ||
|   | 0b729b99e7 | ||
|   | 940238f158 | ||
|   | 3f6c7ba1d2 | ||
|   | 0025973453 | ||
|   | c3a217c9d2 | ||
|   | 13c2e72871 | ||
|   | 3c65594030 | ||
|   | f85b031402 | ||
|   | ac3e994682 | ||
|   | 139f6b3c4c | ||
|   | a4bb555fac | ||
|   | d0cfc49806 | ||
|   | 01e491ec07 | ||
|   | bf021a9ead | ||
|   | 70e69a382e | ||
|   | cd26d9f669 | ||
|   | 4fd0253e99 | ||
|   | ebab70ca3b | ||
|   | ae4a158e36 | ||
|   | 760a1e6306 | ||
|   | fded8b1de0 | ||
|   | 762d5cd392 | ||
|   | 09ac79b9f3 | ||
|   | 16f6fb0c59 | ||
|   | 385e790600 | ||
|   | 95e4e5a931 | ||
|   | e1bfec6fe2 | 
| @@ -5,10 +5,24 @@ rustflags = ["-C", "linker-flavor=ld.lld"] | |||||||
| [target.aarch64-unknown-linux-gnu] | [target.aarch64-unknown-linux-gnu] | ||||||
| linker = "aarch64-linux-gnu-gcc" | linker = "aarch64-linux-gnu-gcc" | ||||||
|  |  | ||||||
|  | [target.aarch64-unknown-linux-ohos] | ||||||
|  | ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar" | ||||||
|  | linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh" | ||||||
|  |  | ||||||
|  | [target.aarch64-unknown-linux-ohos.env] | ||||||
|  | PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig" | ||||||
|  | PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib" | ||||||
|  | PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot" | ||||||
|  | SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot" | ||||||
|  |  | ||||||
| [target.aarch64-unknown-linux-musl] | [target.aarch64-unknown-linux-musl] | ||||||
| linker = "aarch64-unknown-linux-musl-gcc" | linker = "aarch64-unknown-linux-musl-gcc" | ||||||
| rustflags = ["-C", "target-feature=+crt-static"] | rustflags = ["-C", "target-feature=+crt-static"] | ||||||
|  |  | ||||||
|  | [target.riscv64gc-unknown-linux-musl] | ||||||
|  | linker = "riscv64-unknown-linux-musl-gcc" | ||||||
|  | rustflags = ["-C", "target-feature=+crt-static"] | ||||||
|  |  | ||||||
| [target.'cfg(all(windows, target_env = "msvc"))'] | [target.'cfg(all(windows, target_env = "msvc"))'] | ||||||
| rustflags = ["-C", "target-feature=+crt-static"] | rustflags = ["-C", "target-feature=+crt-static"] | ||||||
|  |  | ||||||
| @@ -58,6 +72,10 @@ rustflags = ["-C", "target-feature=+crt-static"] | |||||||
| linker = "armv7-unknown-linux-musleabi-gcc" | linker = "armv7-unknown-linux-musleabi-gcc" | ||||||
| rustflags = ["-C", "target-feature=+crt-static"] | rustflags = ["-C", "target-feature=+crt-static"] | ||||||
|  |  | ||||||
|  | [target.loongarch64-unknown-linux-musl] | ||||||
|  | linker = "loongarch64-unknown-linux-musl-gcc" | ||||||
|  | rustflags = ["-C", "target-feature=+crt-static"] | ||||||
|  |  | ||||||
| [target.arm-unknown-linux-musleabihf] | [target.arm-unknown-linux-musleabihf] | ||||||
| linker = "arm-unknown-linux-musleabihf-gcc" | linker = "arm-unknown-linux-musleabihf-gcc" | ||||||
| rustflags = [ | rustflags = [ | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.envrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | |||||||
|  | PROFILE=$(cat .flake-profile 2>/dev/null) | ||||||
|  | use flake .#${PROFILE} | ||||||
							
								
								
									
										108
									
								
								.github/ISSUE_TEMPLATE/bug_report.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -23,31 +23,113 @@ body: | |||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: description |     id: description | ||||||
|     attributes: |     attributes: | ||||||
|       label: 描述问题 / Describe the bug |       label: 问题简要描述 / Brief Description | ||||||
|       description: 对 bug 的明确描述。如果条件允许,请包括屏幕截图。 / A clear description of what the bug is. Include screenshots if applicable. |       description: 对问题的简要描述,包括期望的行为和实际发生的情况。 / A brief description of the issue, including expected vs actual behavior. | ||||||
|       placeholder: 问题描述 / Bug description |       placeholder: | | ||||||
|  |         例如:节点 A 无法连接到节点 B,期望能够正常建立连接 | ||||||
|  |         Example: Node A cannot connect to Node B, expected to establish connection normally | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: environment-info | ||||||
|  |     attributes: | ||||||
|  |       label: 环境信息 / Environment Information | ||||||
|  |       description: 请提供网络拓扑、节点信息和系统环境详情。 / Please provide network topology, node information and system environment details. | ||||||
|  |       placeholder: | | ||||||
|  |         **EasyTier 版本(非常重要)/ EasyTier Version (Very Important):** v1.2.0 | ||||||
|  |          | ||||||
|  |         **网络拓扑 / Network Topology:** | ||||||
|  |         - 节点 A (10.1.1.1): Windows 11 Pro 22H2, Wifi,有 IPV6 地址 | ||||||
|  |         - 节点 B (10.1.1.2): Ubuntu 22.04.3 LTS (Linux 5.15.0-72-generic), 公网 IP | ||||||
|  |         - 节点 C (10.1.1.3): macOS Ventura 13.4.1, 5G 流量,无 IPV6 地址 | ||||||
|  |          | ||||||
|  |         **Network Topology:** | ||||||
|  |         - Node A (10.1.1.1): Windows 11 Pro 22H2, Wifi, has IPV6 address | ||||||
|  |         - Node B (10.1.1.2): Ubuntu 22.04.3 LTS (Linux 5.15.0-72-generic), public IP | ||||||
|  |         - Node C (10.1.1.3): macOS Ventura 13.4.1, 5G traffic, no IPV6 address | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: node-configs | ||||||
|  |     attributes: | ||||||
|  |       label: 节点配置 / Node Configurations | ||||||
|  |       description: 请提供每个节点的配置文件或启动参数。 / Please provide configuration files or startup parameters for each node. | ||||||
|  |       placeholder: | | ||||||
|  |         **节点 A 配置 / Node A Config:** | ||||||
|  |         ``` | ||||||
|  |         easytier-core --config-file config.toml | ||||||
|  |         ``` | ||||||
|  |          | ||||||
|  |         **节点 B 配置 / Node B Config:** | ||||||
|  |         ``` | ||||||
|  |         easytier-core --ipv4 10.1.1.2 --peers tcp://1.2.3.4:11010 | ||||||
|  |         ``` | ||||||
|  |          | ||||||
|  |         请贴出完整的配置文件内容或命令行参数 | ||||||
|  |         Please paste complete configuration file contents or command line arguments | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: logs | ||||||
|  |     attributes: | ||||||
|  |       label: 日志信息 / Log Information | ||||||
|  |       description: 请提供相关的日志信息,包括 GUI 的事件日志或命令行的控制台输出。 / Please provide relevant log information, including GUI event logs or command line console output. | ||||||
|  |       placeholder: | | ||||||
|  |         请粘贴相关的日志信息: | ||||||
|  |         - GUI 用户:请提供事件日志中的错误信息 | ||||||
|  |         - 命令行用户:请提供控制台输出的详细日志 | ||||||
|  |         - 一般情况下,提供默认输出的事件日志即可 | ||||||
|  |         - 如果能提供 --file-log-level debug 输出的日志,会更方便 debug | ||||||
|  |          | ||||||
|  |         Please paste relevant log information: | ||||||
|  |         - GUI users: Please provide error messages from event logs | ||||||
|  |         - CLI users: Please provide detailed console output logs | ||||||
|  |         - Default log output is usually sufficient | ||||||
|  |         - If possible, logs with --file-log-level debug would be more helpful for debugging | ||||||
|  |          | ||||||
|     validations: |     validations: | ||||||
|       required: true |       required: true | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: reproduction |     id: reproduction | ||||||
|     attributes: |     attributes: | ||||||
|       label: 重现步骤 / Reproduction |       label: 重现步骤 / Reproduction Steps | ||||||
|       description: 能够重现行为的步骤或指向能够复现的存储库链接。 / A link to a reproduction repo or steps to reproduce the behaviour. |       description: 请提供详细的步骤来重现这个问题。 / Please provide detailed steps to reproduce this issue. | ||||||
|       placeholder: | |       placeholder: | | ||||||
|         请提供一个最小化的复现示例或复现步骤,请参考这个指南 https://stackoverflow.com/help/minimal-reproducible-example |         1. 启动节点 A,使用配置 xxx / Start Node A with config xxx | ||||||
|         Please provide a minimal reproduction or steps to reproduce, see this guide https://stackoverflow.com/help/minimal-reproducible-example |         2. 启动节点 B,使用配置 yyy / Start Node B with config yyy   | ||||||
|         为什么需要重现(问题)?请参阅这篇文章 https://antfu.me/posts/why-reproductions-are-required |         3. 尝试从节点 A ping 节点 B / Try to ping Node B from Node A | ||||||
|         Why reproduction is required? see this article https://antfu.me/posts/why-reproductions-are-required |         4. 观察到错误:xxx / Observe error: xxx | ||||||
|  |          | ||||||
|  |         请提供详细的操作步骤,以便我们能够重现问题 | ||||||
|  |         Please provide detailed steps so we can reproduce the issue | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: expected-behavior |     id: expected-behavior | ||||||
|     attributes: |     attributes: | ||||||
|       label: 预期结果 / Expected behavior |       label: 预期结果 / Expected Behavior | ||||||
|       description: 清楚地描述您期望发生的事情。 / A clear description of what you expected to happen. |       description: 清楚地描述您期望发生的事情。 / A clear description of what you expected to happen. | ||||||
|  |       placeholder: | | ||||||
|  |         例如:节点 A 应该能够成功 ping 通节点 B,延迟在 100ms 以内 | ||||||
|  |         Example: Node A should be able to ping Node B successfully with latency under 100ms | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: context |     id: additional-context | ||||||
|     attributes: |     attributes: | ||||||
|       label: 额外上下文 / Additional context  |       label: 额外信息 / Additional Context | ||||||
|       description:  在这里添加关于问题的任何其他上下文。 / Add any other context about the problem here. |       description: 在这里添加关于问题的任何其他上下文信息。 / Add any other context about the problem here. | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 这个问题是否在特定时间出现? | ||||||
|  |         - 是否有网络环境的特殊配置? | ||||||
|  |         - 是否尝试过其他解决方案? | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - Does this issue occur at specific times? | ||||||
|  |         - Are there any special network environment configurations? | ||||||
|  |         - Have you tried any other solutions? | ||||||
							
								
								
									
										171
									
								
								.github/ISSUE_TEMPLATE/feature_request.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,36 +3,177 @@ | |||||||
|  |  | ||||||
| name: 💡 新功能请求 / Feature Request | name: 💡 新功能请求 / Feature Request | ||||||
| title: '[feat] ' | title: '[feat] ' | ||||||
| description: 提出一个想法 /  Suggest an idea | description: 提出一个想法 / Suggest an idea | ||||||
| labels: ['type: feature request'] | labels: ['type: feature request'] | ||||||
|  |  | ||||||
| body: | body: | ||||||
|   - type: textarea |   - type: markdown | ||||||
|     id: problem |  | ||||||
|     attributes: |     attributes: | ||||||
|       label: 描述问题 / Describe the problem |       value: | | ||||||
|       description: 明确描述此功能将解决的问题 / A clear description of the problem this feature would solve |         ## 提交功能请求前请注意 / Before Submitting | ||||||
|       placeholder: "我总是在...感觉困惑 / I'm always frustrated when..." |         1. 请先搜索 [现有的功能请求](https://github.com/EasyTier/EasyTier/issues?q=is%3Aissue+label%3A%22type%3A+feature+request%22) 确保您的想法尚未被提出。 | ||||||
|  |         1. Please search [existing feature requests](https://github.com/EasyTier/EasyTier/issues?q=is%3Aissue+label%3A%22type%3A+feature+request%22) to ensure your idea hasn't been suggested already. | ||||||
|  |         2. 请确保这个功能确实适合 EasyTier 项目的目标和范围。 | ||||||
|  |         2. Please ensure this feature fits within EasyTier's goals and scope. | ||||||
|  |         3. 考虑这个功能是否能让更多用户受益,而不只是解决个人需求。 | ||||||
|  |         3. Consider whether this feature would benefit many users, not just personal needs. | ||||||
|  |  | ||||||
|  |   - type: dropdown | ||||||
|  |     id: feature-category | ||||||
|  |     attributes: | ||||||
|  |       label: 功能类别 / Feature Category | ||||||
|  |       description: 请选择这个功能请求属于哪个类别 / Please select which category this feature request belongs to | ||||||
|  |       options: | ||||||
|  |         - 网络连接 / Network Connectivity | ||||||
|  |         - 安全和加密 / Security & Encryption   | ||||||
|  |         - 性能优化 / Performance Optimization | ||||||
|  |         - 用户界面 / User Interface | ||||||
|  |         - 配置管理 / Configuration Management | ||||||
|  |         - 监控和日志 / Monitoring & Logging | ||||||
|  |         - 平台支持 / Platform Support | ||||||
|  |         - API 和集成 / API & Integration | ||||||
|  |         - 其他 / Other | ||||||
|     validations: |     validations: | ||||||
|       required: true |       required: true | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: solution |     id: use-case | ||||||
|     attributes: |     attributes: | ||||||
|       label: "描述您想要的解决方案 / Describe the solution you'd like" |       label: 使用场景 / Use Case | ||||||
|       description: 明确说明您希望做出的改变 / A clear description of what change you would like |       description: 描述您希望这个功能解决的具体使用场景或问题 / Describe the specific use case or problem you want this feature to solve | ||||||
|       placeholder: '我希望... / I would like to...' |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 作为企业用户,我需要在多个分支机构之间建立安全的网络连接 | ||||||
|  |         - 作为开发者,我希望能够通过 API 监控网络状态 | ||||||
|  |         - 作为系统管理员,我需要更详细的连接日志来排查问题 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - As an enterprise user, I need to establish secure network connections between multiple branch offices | ||||||
|  |         - As a developer, I want to monitor network status through APIs | ||||||
|  |         - As a system administrator, I need more detailed connection logs for troubleshooting | ||||||
|     validations: |     validations: | ||||||
|       required: true |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: current-limitations | ||||||
|  |     attributes: | ||||||
|  |       label: 当前限制 / Current Limitations | ||||||
|  |       description: 描述当前 EasyTier 的哪些限制阻止了您实现这个使用场景 / Describe what current limitations in EasyTier prevent you from achieving this use case | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 目前不支持基于用户角色的访问控制 | ||||||
|  |         - 缺少对 IPv6 的完整支持 | ||||||
|  |         - 没有提供 REST API 来获取网络状态 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - Currently lacks role-based access control | ||||||
|  |         - Missing complete IPv6 support | ||||||
|  |         - No REST API available for network status | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: proposed-solution | ||||||
|  |     attributes: | ||||||
|  |       label: 建议的解决方案 / Proposed Solution | ||||||
|  |       description: 详细描述您希望添加的功能以及它应该如何工作 / Describe in detail the feature you'd like to add and how it should work | ||||||
|  |       placeholder: | | ||||||
|  |         请描述: | ||||||
|  |         - 功能的具体实现方式 | ||||||
|  |         - 用户界面或 API 设计 | ||||||
|  |         - 配置选项和参数 | ||||||
|  |         - 与现有功能的集成方式 | ||||||
|  |          | ||||||
|  |         Please describe: | ||||||
|  |         - Specific implementation approach | ||||||
|  |         - User interface or API design | ||||||
|  |         - Configuration options and parameters   | ||||||
|  |         - Integration with existing features | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: benefits | ||||||
|  |     attributes: | ||||||
|  |       label: 预期收益 / Expected Benefits | ||||||
|  |       description: 说明这个功能会带来什么好处,会影响哪些用户群体 / Explain what benefits this feature would bring and which user groups it would affect | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 提高网络连接的稳定性和性能 | ||||||
|  |         - 简化大规模部署的管理复杂度 | ||||||
|  |         - 增强企业用户的安全性需求 | ||||||
|  |         - 降低新用户的学习成本 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - Improve network connection stability and performance | ||||||
|  |         - Simplify management complexity for large-scale deployments | ||||||
|  |         - Enhance security requirements for enterprise users | ||||||
|  |         - Reduce learning curve for new users | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: technical-considerations | ||||||
|  |     attributes: | ||||||
|  |       label: 技术考虑 / Technical Considerations | ||||||
|  |       description: 如果您了解技术细节,请分享相关的技术考虑或约束 / If you have technical knowledge, please share relevant technical considerations or constraints | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 可能需要修改网络协议栈 | ||||||
|  |         - 需要考虑跨平台兼容性 | ||||||
|  |         - 可能影响现有性能 | ||||||
|  |         - 依赖第三方库或协议 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - May require modifications to network protocol stack | ||||||
|  |         - Cross-platform compatibility needs consideration | ||||||
|  |         - Potential impact on existing performance | ||||||
|  |         - Dependencies on third-party libraries or protocols | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: alternatives |     id: alternatives | ||||||
|     attributes: |     attributes: | ||||||
|       label: 替代方案 / Alternatives considered |       label: 备选方案 / Alternative Solutions | ||||||
|       description: "您考虑过的任何替代解决方案 / Any alternative solutions you've considered" |       description: 您是否考虑过其他解决方案?是否有现有的替代方案? / Have you considered other solutions? Are there existing alternatives? | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 使用第三方工具 X 可以部分解决,但缺少 Y 功能 | ||||||
|  |         - 通过脚本workaround可以实现,但不够优雅 | ||||||
|  |         - 其他类似项目 Z 有这个功能,可以参考其实现 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - Third-party tool X can partially solve this, but lacks Y functionality | ||||||
|  |         - Can be achieved through script workarounds, but not elegant | ||||||
|  |         - Similar project Z has this feature, could reference its implementation | ||||||
|  |  | ||||||
|   - type: textarea |   - type: textarea | ||||||
|     id: context |     id: implementation-priority | ||||||
|     attributes: |     attributes: | ||||||
|       label: 额外上下文 / Additional context |       label: 实现优先级 / Implementation Priority | ||||||
|       description: 在此处添加有关问题的任何其他上下文。 / Add any other context about the problem here. |       description: 这个功能对您有多重要?是否有时间要求? / How important is this feature to you? Any time requirements? | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 高优先级:阻碍了我们的生产部署 | ||||||
|  |         - 中优先级:会显著改善用户体验 | ||||||
|  |         - 低优先级:锦上添花的功能 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - High priority: Blocking our production deployment | ||||||
|  |         - Medium priority: Would significantly improve user experience | ||||||
|  |         - Low priority: Nice-to-have feature | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional-context | ||||||
|  |     attributes: | ||||||
|  |       label: 补充信息 / Additional Context | ||||||
|  |       description: 添加任何其他相关信息,如截图、链接、参考资料等 / Add any other relevant information such as screenshots, links, or references | ||||||
|  |       placeholder: | | ||||||
|  |         例如: | ||||||
|  |         - 相关的 RFC 或技术规范 | ||||||
|  |         - 其他项目的实现示例 | ||||||
|  |         - 用户调研或反馈数据 | ||||||
|  |         - 设计草图或流程图 | ||||||
|  |          | ||||||
|  |         Example: | ||||||
|  |         - Relevant RFCs or technical specifications | ||||||
|  |         - Implementation examples from other projects | ||||||
|  |         - User research or feedback data | ||||||
|  |         - Design sketches or flowcharts | ||||||
							
								
								
									
										20
									
								
								.github/workflows/Dockerfile
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,29 +1,35 @@ | |||||||
| FROM alpine:latest AS builder | FROM alpine:latest AS base | ||||||
|  | FROM base AS builder | ||||||
|  |  | ||||||
| ARG TARGETPLATFORM | ARG TARGETPLATFORM | ||||||
|  |  | ||||||
| COPY . /tmp/artifacts | COPY . /tmp/artifacts | ||||||
| RUN mkdir -p /tmp/output; \ | WORKDIR /tmp/output | ||||||
|     cd /tmp/artifacts; \ | RUN ARTIFACT_ARCH=""; \ | ||||||
|     ARTIFACT_ARCH=""; \ |  | ||||||
|     if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ |     if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ | ||||||
|         ARTIFACT_ARCH="x86_64"; \ |         ARTIFACT_ARCH="x86_64"; \ | ||||||
|  |     elif [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then \ | ||||||
|  |         ARTIFACT_ARCH="armhf"; \ | ||||||
|  |     elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ | ||||||
|  |         ARTIFACT_ARCH="armv7hf"; \ | ||||||
|     elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ |     elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ | ||||||
|         ARTIFACT_ARCH="aarch64"; \ |         ARTIFACT_ARCH="aarch64"; \ | ||||||
|  |     elif [ "$TARGETPLATFORM" = "linux/riscv64" ]; then \ | ||||||
|  |         ARTIFACT_ARCH="riscv64"; \ | ||||||
|     else \ |     else \ | ||||||
|         echo "Unsupported architecture: $TARGETARCH"; \ |         echo "Unsupported architecture: $TARGETPLATFORM"; \ | ||||||
|         exit 1; \ |         exit 1; \ | ||||||
|     fi; \ |     fi; \ | ||||||
|     cp /tmp/artifacts/easytier-linux-${ARTIFACT_ARCH}/* /tmp/output; |     cp /tmp/artifacts/easytier-linux-${ARTIFACT_ARCH}/* /tmp/output; | ||||||
|  |  | ||||||
| FROM alpine:latest | FROM base | ||||||
|  |  | ||||||
| RUN apk add --no-cache tzdata tini | RUN apk add --no-cache tzdata tini | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
| COPY --from=builder --chmod=755 /tmp/output/* /usr/local/bin | COPY --from=builder --chmod=755 /tmp/output/* /usr/local/bin | ||||||
|  |  | ||||||
| # users can use "-e TZ=xxx" to adjust it | # users can use "-e TZ=xxx" to adjust it | ||||||
| ENV TZ Asia/Shanghai | ENV TZ=Asia/Shanghai | ||||||
|  |  | ||||||
| # tcp | # tcp | ||||||
| EXPOSE 11010/tcp | EXPOSE 11010/tcp | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								.github/workflows/core.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -40,12 +40,12 @@ jobs: | |||||||
|  |  | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 21 |           node-version: 22 | ||||||
|  |  | ||||||
|       - name: Install pnpm |       - name: Install pnpm | ||||||
|         uses: pnpm/action-setup@v3 |         uses: pnpm/action-setup@v4 | ||||||
|         with: |         with: | ||||||
|           version: 9 |           version: 10 | ||||||
|           run_install: false |           run_install: false | ||||||
|  |  | ||||||
|       - name: Get pnpm store directory |       - name: Get pnpm store directory | ||||||
| @@ -83,6 +83,9 @@ jobs: | |||||||
|           - TARGET: x86_64-unknown-linux-musl |           - TARGET: x86_64-unknown-linux-musl | ||||||
|             OS: ubuntu-22.04 |             OS: ubuntu-22.04 | ||||||
|             ARTIFACT_NAME: linux-x86_64 |             ARTIFACT_NAME: linux-x86_64 | ||||||
|  |           - TARGET: riscv64gc-unknown-linux-musl | ||||||
|  |             OS: ubuntu-22.04 | ||||||
|  |             ARTIFACT_NAME: linux-riscv64 | ||||||
|           - TARGET: mips-unknown-linux-musl |           - TARGET: mips-unknown-linux-musl | ||||||
|             OS: ubuntu-22.04 |             OS: ubuntu-22.04 | ||||||
|             ARTIFACT_NAME: linux-mips |             ARTIFACT_NAME: linux-mips | ||||||
| @@ -102,6 +105,10 @@ jobs: | |||||||
|             OS: ubuntu-22.04 |             OS: ubuntu-22.04 | ||||||
|             ARTIFACT_NAME: linux-arm |             ARTIFACT_NAME: linux-arm | ||||||
|  |  | ||||||
|  |           - TARGET: loongarch64-unknown-linux-musl | ||||||
|  |             OS: ubuntu-24.04 | ||||||
|  |             ARTIFACT_NAME: linux-loongarch64 | ||||||
|  |  | ||||||
|           - TARGET: x86_64-apple-darwin |           - TARGET: x86_64-apple-darwin | ||||||
|             OS: macos-latest |             OS: macos-latest | ||||||
|             ARTIFACT_NAME: macos-x86_64 |             ARTIFACT_NAME: macos-x86_64 | ||||||
| @@ -147,17 +154,16 @@ jobs: | |||||||
|           name: easytier-web-dashboard |           name: easytier-web-dashboard | ||||||
|           path: easytier-web/frontend/dist/ |           path: easytier-web/frontend/dist/ | ||||||
|  |  | ||||||
|       - name: Cargo cache |       - uses: Swatinem/rust-cache@v2 | ||||||
|         if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }} |         if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }} | ||||||
|         uses: actions/cache@v4 |  | ||||||
|         with: |         with: | ||||||
|           path: | |           # The prefix cache key, this can be changed to start a new cache manually. | ||||||
|             ~/.cargo |           # default: "v0-rust" | ||||||
|             ./target |           prefix-key: "" | ||||||
|           key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|  |  | ||||||
|       - name: Setup protoc |       - name: Setup protoc | ||||||
|         uses: arduino/setup-protoc@v2 |         uses: arduino/setup-protoc@v3 | ||||||
|         with: |         with: | ||||||
|           # GitHub repo token to use to avoid rate limiter |           # GitHub repo token to use to avoid rate limiter | ||||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} |           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
| @@ -167,6 +173,11 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           bash ./.github/workflows/install_rust.sh |           bash ./.github/workflows/install_rust.sh | ||||||
|  |  | ||||||
|  |           # loongarch need llvm-18 | ||||||
|  |           if [[ $TARGET =~ ^loongarch.*$ ]]; then | ||||||
|  |             sudo apt-get install -qq llvm-18 clang-18 | ||||||
|  |             export LLVM_CONFIG_PATH=/usr/lib/llvm-18/bin/llvm-config | ||||||
|  |           fi | ||||||
|           # we set the sysroot when sysroot is a dir |           # we set the sysroot when sysroot is a dir | ||||||
|           # this dir is a soft link generated by install_rust.sh |           # this dir is a soft link generated by install_rust.sh | ||||||
|           # kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h |           # kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h | ||||||
| @@ -175,14 +186,19 @@ jobs: | |||||||
|           fi |           fi | ||||||
|  |  | ||||||
|           if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then |           if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then | ||||||
|             cargo +nightly build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier |             cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc | ||||||
|           else |           else | ||||||
|             if [[ $OS =~ ^windows.*$ ]]; then |             if [[ $OS =~ ^windows.*$ ]]; then | ||||||
|               SUFFIX=.exe |               SUFFIX=.exe | ||||||
|  |               CORE_FEATURES="--features=mimalloc" | ||||||
|  |             elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then | ||||||
|  |               CORE_FEATURES="--features=mimalloc" | ||||||
|  |             else | ||||||
|  |               CORE_FEATURES="--features=jemalloc" | ||||||
|             fi |             fi | ||||||
|             cargo build --release --target $TARGET --package=easytier-web --features=embed |             cargo build --release --target $TARGET --package=easytier-web --features=embed | ||||||
|             mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX" |             mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX" | ||||||
|             cargo build --release --target $TARGET |             cargo build --release --target $TARGET $CORE_FEATURES | ||||||
|           fi |           fi | ||||||
|  |  | ||||||
|       # Copied and slightly modified from @lmq8267 (https://github.com/lmq8267) |       # Copied and slightly modified from @lmq8267 (https://github.com/lmq8267) | ||||||
| @@ -212,8 +228,8 @@ jobs: | |||||||
|  |  | ||||||
|             rustup set auto-self-update disable |             rustup set auto-self-update disable | ||||||
|  |  | ||||||
|             rustup install 1.86 |             rustup install 1.89 | ||||||
|             rustup default 1.86 |             rustup default 1.89 | ||||||
|  |  | ||||||
|             export CC=clang |             export CC=clang | ||||||
|             export CXX=clang++ |             export CXX=clang++ | ||||||
| @@ -221,7 +237,7 @@ jobs: | |||||||
|  |  | ||||||
|             cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed |             cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed | ||||||
|             mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed |             mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed | ||||||
|             cargo build --release --verbose --target $TARGET |             cargo build --release --verbose --target $TARGET --features=mimalloc | ||||||
|  |  | ||||||
|       - name: Compress |       - name: Compress | ||||||
|         run: | |         run: | | ||||||
| @@ -243,7 +259,7 @@ jobs: | |||||||
|             TAG=$GITHUB_SHA |             TAG=$GITHUB_SHA | ||||||
|           fi |           fi | ||||||
|  |  | ||||||
|           if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ ]]; then |           if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ && ! $TARGET =~ ^loongarch.*$ && ! $TARGET =~ ^riscv64.*$ ]]; then | ||||||
|             UPX_VERSION=4.2.4 |             UPX_VERSION=4.2.4 | ||||||
|             curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf - |             curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf - | ||||||
|             cp upx-${UPX_VERSION}-amd64_linux/upx . |             cp upx-${UPX_VERSION}-amd64_linux/upx . | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -11,13 +11,18 @@ on: | |||||||
|       image_tag: |       image_tag: | ||||||
|         description: 'Tag for this image build' |         description: 'Tag for this image build' | ||||||
|         type: string |         type: string | ||||||
|         default: 'v2.3.2' |         default: 'v2.4.5' | ||||||
|         required: true |         required: true | ||||||
|       mark_latest: |       mark_latest: | ||||||
|         description: 'Mark this image as latest' |         description: 'Mark this image as latest' | ||||||
|         type: boolean |         type: boolean | ||||||
|         default: false |         default: false | ||||||
|         required: true |         required: true | ||||||
|  |       mark_unstable: | ||||||
|  |         description: 'Mark this image as unstable' | ||||||
|  |         type: boolean | ||||||
|  |         default: false | ||||||
|  |         required: true | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   docker: |   docker: | ||||||
| @@ -27,6 +32,13 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Checkout |         name: Checkout | ||||||
|         uses: actions/checkout@v4 |         uses: actions/checkout@v4 | ||||||
|  |       - | ||||||
|  |         name: Validate inputs | ||||||
|  |         run: | | ||||||
|  |           if [[ "${{ inputs.mark_latest }}" == "true" && "${{ inputs.mark_unstable }}" == "true" ]]; then | ||||||
|  |             echo "Error: mark_latest and mark_unstable cannot both be true" | ||||||
|  |             exit 1 | ||||||
|  |           fi | ||||||
|       - |       - | ||||||
|         name: Set up QEMU |         name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v3 |         uses: docker/setup-qemu-action@v3 | ||||||
| @@ -47,7 +59,7 @@ jobs: | |||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Download artifact |       - name: Download artifact | ||||||
|         id: download-artifact |         id: download-artifact | ||||||
|         uses: dawidd6/action-download-artifact@v6 |         uses: dawidd6/action-download-artifact@v11 | ||||||
|         with: |         with: | ||||||
|           github_token: ${{secrets.GITHUB_TOKEN}} |           github_token: ${{secrets.GITHUB_TOKEN}} | ||||||
|           run_id: ${{ inputs.run_id }} |           run_id: ${{ inputs.run_id }} | ||||||
| @@ -56,14 +68,36 @@ jobs: | |||||||
|       - name: List files |       - name: List files | ||||||
|         run: | |         run: | | ||||||
|           ls -l -R . |           ls -l -R . | ||||||
|  |       - name: Prepare Docker tags | ||||||
|  |         id: tags | ||||||
|  |         run: | | ||||||
|  |           # Base tags with version | ||||||
|  |           DOCKERHUB_TAGS="easytier/easytier:${{ inputs.image_tag }}" | ||||||
|  |           GHCR_TAGS="ghcr.io/easytier/easytier:${{ inputs.image_tag }}" | ||||||
|  |  | ||||||
|  |           # Add latest tags if requested | ||||||
|  |           if [[ "${{ inputs.mark_latest }}" == "true" ]]; then | ||||||
|  |             DOCKERHUB_TAGS="${DOCKERHUB_TAGS},easytier/easytier:latest" | ||||||
|  |             GHCR_TAGS="${GHCR_TAGS},ghcr.io/easytier/easytier:latest" | ||||||
|  |           fi | ||||||
|  |  | ||||||
|  |           # Add unstable tags if requested | ||||||
|  |           if [[ "${{ inputs.mark_unstable }}" == "true" ]]; then | ||||||
|  |             DOCKERHUB_TAGS="${DOCKERHUB_TAGS},easytier/easytier:unstable" | ||||||
|  |             GHCR_TAGS="${GHCR_TAGS},ghcr.io/easytier/easytier:unstable" | ||||||
|  |           fi | ||||||
|  |  | ||||||
|  |           # Combine all tags | ||||||
|  |           ALL_TAGS="${DOCKERHUB_TAGS},${GHCR_TAGS}" | ||||||
|  |  | ||||||
|  |           echo "tags=${ALL_TAGS}" >> $GITHUB_OUTPUT | ||||||
|  |           echo "Generated tags: ${ALL_TAGS}" | ||||||
|       - |       - | ||||||
|         name: Build and push |         name: Build and push | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v6 | ||||||
|         with: |         with: | ||||||
|           context: ./docker_context |           context: ./docker_context | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64 | ||||||
|           push: true |           push: true | ||||||
|           file: .github/workflows/Dockerfile |           file: .github/workflows/Dockerfile | ||||||
|           tags: | |           tags: ${{ steps.tags.outputs.tags }} | ||||||
|             easytier/easytier:${{ inputs.image_tag }}${{ inputs.mark_latest && ',easytier/easytier:latest' || '' }}, |  | ||||||
|             ghcr.io/easytier/easytier:${{ inputs.image_tag }}${{ inputs.mark_latest && ',easytier/easytier:latest' || '' }}, |  | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								.github/workflows/gui.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -29,7 +29,7 @@ jobs: | |||||||
|           concurrent_skipping: 'same_content_newer' |           concurrent_skipping: 'same_content_newer' | ||||||
|           skip_after_successful_duplicate: 'true' |           skip_after_successful_duplicate: 'true' | ||||||
|           cancel_others: 'true' |           cancel_others: 'true' | ||||||
|           paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh"]' |           paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh"]' | ||||||
|   build-gui: |   build-gui: | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
| @@ -78,20 +78,11 @@ jobs: | |||||||
|     needs: pre_job |     needs: pre_job | ||||||
|     if: needs.pre_job.outputs.should_skip != 'true'     |     if: needs.pre_job.outputs.should_skip != 'true'     | ||||||
|     steps: |     steps: | ||||||
|  |       - uses: actions/checkout@v3 | ||||||
|  |  | ||||||
|       - name: Install GUI dependencies (x86 only) |       - name: Install GUI dependencies (x86 only) | ||||||
|         if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }} |         if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }} | ||||||
|         run: | |         run: bash ./.github/workflows/install_gui_dep.sh | ||||||
|           sudo apt update |  | ||||||
|           sudo apt install -qq libwebkit2gtk-4.1-dev \ |  | ||||||
|               build-essential \ |  | ||||||
|               curl \ |  | ||||||
|               wget \ |  | ||||||
|               file \ |  | ||||||
|               libgtk-3-dev \ |  | ||||||
|               librsvg2-dev \ |  | ||||||
|               libxdo-dev \ |  | ||||||
|               libssl-dev \ |  | ||||||
|               patchelf |  | ||||||
|  |  | ||||||
|       - name: Install GUI cross compile (aarch64 only) |       - name: Install GUI cross compile (aarch64 only) | ||||||
|         if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }} |         if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }} | ||||||
| @@ -124,24 +115,22 @@ jobs: | |||||||
|           sudo apt install aptitude |           sudo apt install aptitude | ||||||
|           sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \ |           sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \ | ||||||
|             libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \ |             libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \ | ||||||
|             libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu |             libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64 | ||||||
|           echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV" |           echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV" | ||||||
|           echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV" |           echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV" | ||||||
|  |  | ||||||
|       - uses: actions/checkout@v3 |  | ||||||
|  |  | ||||||
|       - name: Set current ref as env variable |       - name: Set current ref as env variable | ||||||
|         run: | |         run: | | ||||||
|           echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV |           echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV | ||||||
|  |  | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 21 |           node-version: 22 | ||||||
|  |  | ||||||
|       - name: Install pnpm |       - name: Install pnpm | ||||||
|         uses: pnpm/action-setup@v3 |         uses: pnpm/action-setup@v4 | ||||||
|         with: |         with: | ||||||
|           version: 9 |           version: 10 | ||||||
|           run_install: false |           run_install: false | ||||||
|  |  | ||||||
|       - name: Get pnpm store directory |       - name: Get pnpm store directory | ||||||
| @@ -162,19 +151,17 @@ jobs: | |||||||
|           pnpm -r install |           pnpm -r install | ||||||
|           pnpm -r build |           pnpm -r build | ||||||
|  |  | ||||||
|       - name: Cargo cache |       - uses: Swatinem/rust-cache@v2 | ||||||
|         uses: actions/cache@v4 |  | ||||||
|         with: |         with: | ||||||
|           path: | |           # The prefix cache key, this can be changed to start a new cache manually. | ||||||
|             ~/.cargo |           # default: "v0-rust" | ||||||
|             ./target |           prefix-key: "" | ||||||
|           key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|  |  | ||||||
|       - name: Install rust target |       - name: Install rust target | ||||||
|         run: bash ./.github/workflows/install_rust.sh |         run: bash ./.github/workflows/install_rust.sh | ||||||
|  |  | ||||||
|       - name: Setup protoc |       - name: Setup protoc | ||||||
|         uses: arduino/setup-protoc@v2 |         uses: arduino/setup-protoc@v3 | ||||||
|         with: |         with: | ||||||
|           # GitHub repo token to use to avoid rate limiter |           # GitHub repo token to use to avoid rate limiter | ||||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} |           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								.github/workflows/install_gui_dep.sh
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | |||||||
|  | sudo apt update | ||||||
|  | sudo apt install -qq libwebkit2gtk-4.1-dev \ | ||||||
|  |     build-essential \ | ||||||
|  |     curl \ | ||||||
|  |     wget \ | ||||||
|  |     file \ | ||||||
|  |     libgtk-3-dev \ | ||||||
|  |     librsvg2-dev \ | ||||||
|  |     libxdo-dev \ | ||||||
|  |     libssl-dev \ | ||||||
|  |     patchelf | ||||||
							
								
								
									
										10
									
								
								.github/workflows/install_rust.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -15,6 +15,8 @@ if [[ $OS =~ ^ubuntu.*$ ]]; then | |||||||
|     # if target is mips or mipsel, we should use soft-float version of musl |     # if target is mips or mipsel, we should use soft-float version of musl | ||||||
|     if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then |     if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then | ||||||
|         MUSL_TARGET=${TARGET}sf |         MUSL_TARGET=${TARGET}sf | ||||||
|  |     elif [[ $TARGET =~ ^riscv64gc-.*$ ]]; then | ||||||
|  |         MUSL_TARGET=${TARGET/#riscv64gc-/riscv64-} | ||||||
|     fi |     fi | ||||||
|     if [[ $MUSL_TARGET =~ musl ]]; then |     if [[ $MUSL_TARGET =~ musl ]]; then | ||||||
|         mkdir -p ./musl_gcc |         mkdir -p ./musl_gcc | ||||||
| @@ -29,8 +31,8 @@ fi | |||||||
|  |  | ||||||
| # see https://github.com/rust-lang/rustup/issues/3709 | # see https://github.com/rust-lang/rustup/issues/3709 | ||||||
| rustup set auto-self-update disable | rustup set auto-self-update disable | ||||||
| rustup install 1.86 | rustup install 1.89 | ||||||
| rustup default 1.86 | rustup default 1.89 | ||||||
|  |  | ||||||
| # mips/mipsel cannot add target from rustup, need compile by ourselves | # mips/mipsel cannot add target from rustup, need compile by ourselves | ||||||
| if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then | if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then | ||||||
| @@ -42,8 +44,8 @@ if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then | |||||||
|     ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o |     ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o | ||||||
|     ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o |     ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o | ||||||
|  |  | ||||||
|     rustup toolchain install nightly-x86_64-unknown-linux-gnu |     rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu | ||||||
|     rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu |     rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu | ||||||
|  |  | ||||||
|     # https://github.com/rust-lang/rust/issues/128808 |     # https://github.com/rust-lang/rust/issues/128808 | ||||||
|     # remove it after Cargo or rustc fix this. |     # remove it after Cargo or rustc fix this. | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								.github/workflows/mobile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -56,7 +56,7 @@ jobs: | |||||||
|       - uses: actions/setup-java@v4 |       - uses: actions/setup-java@v4 | ||||||
|         with: |         with: | ||||||
|           distribution: 'oracle' |           distribution: 'oracle' | ||||||
|           java-version: '20' |           java-version: '21' | ||||||
|  |  | ||||||
|       - name: Setup Android SDK |       - name: Setup Android SDK | ||||||
|         uses: android-actions/setup-android@v3 |         uses: android-actions/setup-android@v3 | ||||||
| @@ -72,12 +72,12 @@ jobs: | |||||||
|  |  | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 21 |           node-version: 22 | ||||||
|  |  | ||||||
|       - name: Install pnpm |       - name: Install pnpm | ||||||
|         uses: pnpm/action-setup@v3 |         uses: pnpm/action-setup@v4 | ||||||
|         with: |         with: | ||||||
|           version: 9 |           version: 10 | ||||||
|           run_install: false |           run_install: false | ||||||
|  |  | ||||||
|       - name: Get pnpm store directory |       - name: Get pnpm store directory | ||||||
| @@ -98,13 +98,11 @@ jobs: | |||||||
|           pnpm -r install |           pnpm -r install | ||||||
|           pnpm -r build |           pnpm -r build | ||||||
|  |  | ||||||
|       - name: Cargo cache |       - uses: Swatinem/rust-cache@v2 | ||||||
|         uses: actions/cache@v4 |  | ||||||
|         with: |         with: | ||||||
|           path: | |           # The prefix cache key, this can be changed to start a new cache manually. | ||||||
|             ~/.cargo |           # default: "v0-rust" | ||||||
|             ./target |           prefix-key: "" | ||||||
|           key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|  |  | ||||||
|       - name: Install rust target |       - name: Install rust target | ||||||
|         run: | |         run: | | ||||||
| @@ -115,7 +113,7 @@ jobs: | |||||||
|           rustup target add x86_64-linux-android |           rustup target add x86_64-linux-android | ||||||
|  |  | ||||||
|       - name: Setup protoc |       - name: Setup protoc | ||||||
|         uses: arduino/setup-protoc@v2 |         uses: arduino/setup-protoc@v3 | ||||||
|         with: |         with: | ||||||
|           # GitHub repo token to use to avoid rate limiter |           # GitHub repo token to use to avoid rate limiter | ||||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} |           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|   | |||||||
							
								
								
									
										127
									
								
								.github/workflows/ohos.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,127 @@ | |||||||
|  | name: EasyTier OHOS | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     branches: ["develop", "main", "releases/**"] | ||||||
|  |   pull_request: | ||||||
|  |     branches: ["develop", "main"] | ||||||
|  |   workflow_dispatch: | ||||||
|  |  | ||||||
|  | env: | ||||||
|  |   CARGO_TERM_COLOR: always | ||||||
|  |  | ||||||
|  | defaults: | ||||||
|  |   run: | ||||||
|  |     # necessary for windows | ||||||
|  |     shell: bash | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   cargo_fmt_check: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - name: fmt check | ||||||
|  |         working-directory: ./easytier-contrib/easytier-ohrs | ||||||
|  |         run: | | ||||||
|  |           bash ../../.github/workflows/install_rust.sh | ||||||
|  |           rustup component add rustfmt | ||||||
|  |           cargo fmt --all -- --check | ||||||
|  |   pre_job: | ||||||
|  |     # continue-on-error: true # Uncomment once integration is finished | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     # Map a step output to a job output | ||||||
|  |     outputs: | ||||||
|  |       # do not skip push on branch starts with releases/ | ||||||
|  |       should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }} | ||||||
|  |     steps: | ||||||
|  |       - id: skip_check | ||||||
|  |         uses: fkirc/skip-duplicate-actions@v5 | ||||||
|  |         with: | ||||||
|  |           # All of these options are optional, so you can remove them if you are happy with the defaults | ||||||
|  |           concurrent_skipping: "same_content_newer" | ||||||
|  |           skip_after_successful_duplicate: "true" | ||||||
|  |           cancel_others: "true" | ||||||
|  |           paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]' | ||||||
|  |   build-ohos: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     needs: pre_job | ||||||
|  |     if: needs.pre_job.outputs.should_skip != 'true' | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - name: Install dependencies | ||||||
|  |         run: | | ||||||
|  |           sudo apt-get update | ||||||
|  |           sudo apt-get install -y \ | ||||||
|  |             build-essential \ | ||||||
|  |             wget \ | ||||||
|  |             unzip \ | ||||||
|  |             git \ | ||||||
|  |             pkg-config | ||||||
|  |           sudo apt-get clean | ||||||
|  |  | ||||||
|  |       - name: Download and extract native SDK | ||||||
|  |         working-directory: ../../../ | ||||||
|  |         run: | | ||||||
|  |           echo $PWD | ||||||
|  |           wget -q \ | ||||||
|  |             https://github.com/openharmony-rs/ohos-sdk/releases/download/v5.1.0/ohos-sdk-windows_linux-public.tar.gz.aa | ||||||
|  |           wget -q \ | ||||||
|  |             https://github.com/openharmony-rs/ohos-sdk/releases/download/v5.1.0/ohos-sdk-windows_linux-public.tar.gz.ab | ||||||
|  |           cat ohos-sdk-windows_linux-public.tar.gz.aa ohos-sdk-windows_linux-public.tar.gz.ab > sdk.tar.gz | ||||||
|  |           echo "Extracting native..." | ||||||
|  |           mkdir sdk | ||||||
|  |           tar -xzf sdk.tar.gz ohos-sdk/linux/native-linux-x64-5.1.0.107-Release.zip | ||||||
|  |           tar -xzf sdk.tar.gz ohos-sdk/linux/toolchains-linux-x64-5.1.0.107-Release.zip | ||||||
|  |           unzip -qq ohos-sdk/linux/native-linux-x64-5.1.0.107-Release.zip -d sdk | ||||||
|  |           unzip -qq ohos-sdk/linux/toolchains-linux-x64-5.1.0.107-Release.zip -d sdk | ||||||
|  |           ls -la sdk/native/llvm/bin/ | ||||||
|  |           rm -rf ohos-sdk-windows_linux-public.tar.gz.aa ohos-sdk-windows_linux-public.tar.gz.ab ohos-sdk/ | ||||||
|  |  | ||||||
|  |       - name: Download and Extract Custom SDK | ||||||
|  |         run: | | ||||||
|  |           wget https://github.com/FrankHan052176/Easytier-OHOS-sdk/releases/download/v1/ohos-sdk.zip -O /tmp/ohos-sdk.zip | ||||||
|  |           sudo unzip -o /tmp/ohos-sdk.zip -d /tmp/custom-sdk | ||||||
|  |           sudo cp -rf /tmp/custom-sdk/linux/native/* $HOME/sdk/native | ||||||
|  |           echo "Custom SDK files deployed to $HOME/sdk/native" | ||||||
|  |           ls -a $HOME/sdk/native | ||||||
|  |  | ||||||
|  |       - name: Setup build environment | ||||||
|  |         run: | | ||||||
|  |           echo "OHOS_NDK_HOME=$HOME/sdk" >> $GITHUB_ENV | ||||||
|  |           echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV | ||||||
|  |  | ||||||
|  |       - name: Create clang wrapper script | ||||||
|  |         run: | | ||||||
|  |           sudo mkdir -p $OHOS_NDK_HOME/native/llvm | ||||||
|  |           sudo tee $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh > /dev/null <<'EOF' | ||||||
|  |           #!/bin/sh | ||||||
|  |           exec $OHOS_NDK_HOME/native/llvm/bin/clang \ | ||||||
|  |             -target aarch64-linux-ohos \ | ||||||
|  |             --sysroot=$OHOS_NDK_HOME/native/sysroot \ | ||||||
|  |             -D__MUSL__ \ | ||||||
|  |             "$@" | ||||||
|  |           EOF | ||||||
|  |           sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh | ||||||
|  |  | ||||||
|  |       - name: Build | ||||||
|  |         working-directory: ./easytier-contrib/easytier-ohrs | ||||||
|  |         run: | | ||||||
|  |           sudo apt-get install -y llvm clang lldb lld | ||||||
|  |           sudo apt-get install -y protobuf-compiler | ||||||
|  |           bash ../../.github/workflows/install_rust.sh | ||||||
|  |           source env.sh | ||||||
|  |           cargo install ohrs | ||||||
|  |           rustup target add aarch64-unknown-linux-ohos | ||||||
|  |           cargo update easytier | ||||||
|  |           ohrs doctor | ||||||
|  |           ohrs build --release --arch aarch | ||||||
|  |           ohrs artifact | ||||||
|  |           mv package.har easytier-ohrs.har | ||||||
|  |  | ||||||
|  |       - name: Upload artifact | ||||||
|  |         uses: actions/upload-artifact@v4 | ||||||
|  |         with: | ||||||
|  |           name: easytier-ohos | ||||||
|  |           path: ./easytier-contrib/easytier-ohrs/easytier-ohrs.har | ||||||
|  |           retention-days: 5 | ||||||
|  |           if-no-files-found: error | ||||||
							
								
								
									
										8
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -21,7 +21,7 @@ on: | |||||||
|       version: |       version: | ||||||
|         description: 'Version for this release' |         description: 'Version for this release' | ||||||
|         type: string |         type: string | ||||||
|         default: 'v2.3.2' |         default: 'v2.4.5' | ||||||
|         required: true |         required: true | ||||||
|       make_latest: |       make_latest: | ||||||
|         description: 'Mark this release as latest' |         description: 'Mark this release as latest' | ||||||
| @@ -42,7 +42,7 @@ jobs: | |||||||
|         uses: actions/checkout@v4 |         uses: actions/checkout@v4 | ||||||
|  |  | ||||||
|       - name: Download Core Artifact |       - name: Download Core Artifact | ||||||
|         uses: dawidd6/action-download-artifact@v6 |         uses: dawidd6/action-download-artifact@v11 | ||||||
|         with: |         with: | ||||||
|           github_token: ${{secrets.GITHUB_TOKEN}} |           github_token: ${{secrets.GITHUB_TOKEN}} | ||||||
|           run_id: ${{ inputs.core_run_id }} |           run_id: ${{ inputs.core_run_id }} | ||||||
| @@ -50,7 +50,7 @@ jobs: | |||||||
|           path: release_assets |           path: release_assets | ||||||
|  |  | ||||||
|       - name: Download GUI Artifact |       - name: Download GUI Artifact | ||||||
|         uses: dawidd6/action-download-artifact@v6 |         uses: dawidd6/action-download-artifact@v11 | ||||||
|         with: |         with: | ||||||
|           github_token: ${{secrets.GITHUB_TOKEN}} |           github_token: ${{secrets.GITHUB_TOKEN}} | ||||||
|           run_id: ${{ inputs.gui_run_id }} |           run_id: ${{ inputs.gui_run_id }} | ||||||
| @@ -58,7 +58,7 @@ jobs: | |||||||
|           path: release_assets_nozip |           path: release_assets_nozip | ||||||
|  |  | ||||||
|       - name: Download Mobile Artifact |       - name: Download Mobile Artifact | ||||||
|         uses: dawidd6/action-download-artifact@v6 |         uses: dawidd6/action-download-artifact@v11 | ||||||
|         with: |         with: | ||||||
|           github_token: ${{secrets.GITHUB_TOKEN}} |           github_token: ${{secrets.GITHUB_TOKEN}} | ||||||
|           run_id: ${{ inputs.mobile_run_id }} |           run_id: ${{ inputs.mobile_run_id }} | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -28,7 +28,7 @@ jobs: | |||||||
|           # All of these options are optional, so you can remove them if you are happy with the defaults |           # All of these options are optional, so you can remove them if you are happy with the defaults | ||||||
|           concurrent_skipping: 'never' |           concurrent_skipping: 'never' | ||||||
|           skip_after_successful_duplicate: 'true' |           skip_after_successful_duplicate: 'true' | ||||||
|           paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml"]' |           paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]' | ||||||
|   test: |   test: | ||||||
|     runs-on: ubuntu-22.04 |     runs-on: ubuntu-22.04 | ||||||
|     needs: pre_job |     needs: pre_job | ||||||
| @@ -37,7 +37,7 @@ jobs: | |||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|  |  | ||||||
|       - name: Setup protoc |       - name: Setup protoc | ||||||
|         uses: arduino/setup-protoc@v2 |         uses: arduino/setup-protoc@v3 | ||||||
|         with: |         with: | ||||||
|           # GitHub repo token to use to avoid rate limiter |           # GitHub repo token to use to avoid rate limiter | ||||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} |           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
| @@ -55,12 +55,12 @@ jobs: | |||||||
|  |  | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 21 |           node-version: 22 | ||||||
|  |  | ||||||
|       - name: Install pnpm |       - name: Install pnpm | ||||||
|         uses: pnpm/action-setup@v3 |         uses: pnpm/action-setup@v4 | ||||||
|         with: |         with: | ||||||
|           version: 9 |           version: 10 | ||||||
|           run_install: false |           run_install: false | ||||||
|  |  | ||||||
|       - name: Get pnpm store directory |       - name: Get pnpm store directory | ||||||
| @@ -89,6 +89,24 @@ jobs: | |||||||
|             ./target |             ./target | ||||||
|           key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} |           key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} | ||||||
|  |  | ||||||
|  |       - name: Install GUI dependencies (Used by clippy) | ||||||
|  |         run: | | ||||||
|  |           bash ./.github/workflows/install_gui_dep.sh | ||||||
|  |           bash ./.github/workflows/install_rust.sh | ||||||
|  |           rustup component add rustfmt | ||||||
|  |           rustup component add clippy | ||||||
|  |  | ||||||
|  |       - name: Check formatting | ||||||
|  |         if: ${{ !cancelled() }} | ||||||
|  |         run: cargo fmt --all -- --check | ||||||
|  |  | ||||||
|  |       - name: Check Clippy | ||||||
|  |         if: ${{ !cancelled() }} | ||||||
|  |         # NOTE: tauri need `dist` dir in build.rs | ||||||
|  |         run: | | ||||||
|  |           mkdir -p easytier-gui/dist | ||||||
|  |           cargo clippy --all-targets --all-features --all -- -D warnings | ||||||
|  |  | ||||||
|       - name: Run tests |       - name: Run tests | ||||||
|         run: | |         run: | | ||||||
|           sudo prlimit --pid $$ --nofile=1048576:1048576 |           sudo prlimit --pid $$ --nofile=1048576:1048576 | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -12,6 +12,7 @@ target-*/ | |||||||
|  |  | ||||||
| .vscode | .vscode | ||||||
| /.idea | /.idea | ||||||
|  | /.direnv/ | ||||||
|  |  | ||||||
| # perf & flamegraph | # perf & flamegraph | ||||||
| perf.data | perf.data | ||||||
| @@ -37,3 +38,7 @@ node_modules | |||||||
| .vite | .vite | ||||||
|  |  | ||||||
| easytier-gui/src-tauri/*.dll | easytier-gui/src-tauri/*.dll | ||||||
|  | /easytier-contrib/easytier-ohrs/dist/ | ||||||
|  |  | ||||||
|  | .direnv | ||||||
|  | .flake-profile | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
							
								
								
									
										225
									
								
								CONTRIBUTING.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,225 @@ | |||||||
|  | # Contributing to EasyTier | ||||||
|  |  | ||||||
|  | [中文版](CONTRIBUTING_zh.md) | ||||||
|  |  | ||||||
|  | Thank you for your interest in contributing to EasyTier! This document provides guidelines and instructions for contributing to the project. | ||||||
|  |  | ||||||
|  | ## Table of Contents | ||||||
|  |  | ||||||
|  | - [Development Environment Setup](#development-environment-setup) | ||||||
|  |   - [Prerequisites](#prerequisites) | ||||||
|  |   - [Installation Steps](#installation-steps) | ||||||
|  | - [Project Structure](#project-structure) | ||||||
|  | - [Build Guide](#build-guide) | ||||||
|  |   - [Building Core](#building-core) | ||||||
|  |   - [Building GUI](#building-gui) | ||||||
|  |   - [Building Mobile](#building-mobile) | ||||||
|  | - [Development Workflow](#development-workflow) | ||||||
|  | - [Testing Guidelines](#testing-guidelines) | ||||||
|  | - [Pull Request Guidelines](#pull-request-guidelines) | ||||||
|  | - [Additional Resources](#additional-resources) | ||||||
|  |  | ||||||
|  | ## Development Environment Setup | ||||||
|  |  | ||||||
|  | ### Prerequisites | ||||||
|  |  | ||||||
|  | #### Required Tools | ||||||
|  | - Node.js v21 or higher | ||||||
|  | - pnpm v9 or higher | ||||||
|  | - Rust toolchain (version 1.89) | ||||||
|  | - LLVM and Clang | ||||||
|  | - Protoc (Protocol Buffers compiler) | ||||||
|  |  | ||||||
|  | #### Platform-Specific Dependencies | ||||||
|  |  | ||||||
|  | **Linux (Ubuntu/Debian)** | ||||||
|  | ```bash | ||||||
|  | # Core build dependencies | ||||||
|  | sudo apt-get update && sudo apt-get install -y \ | ||||||
|  |     musl-tools \ | ||||||
|  |     llvm \ | ||||||
|  |     clang \ | ||||||
|  |     protobuf-compiler | ||||||
|  |  | ||||||
|  | # GUI build dependencies | ||||||
|  | sudo apt install -y \ | ||||||
|  |     libwebkit2gtk-4.1-dev \ | ||||||
|  |     build-essential \ | ||||||
|  |     curl \ | ||||||
|  |     wget \ | ||||||
|  |     file \ | ||||||
|  |     libgtk-3-dev \ | ||||||
|  |     librsvg2-dev \ | ||||||
|  |     libxdo-dev \ | ||||||
|  |     libssl-dev \ | ||||||
|  |     libappindicator3-dev \ | ||||||
|  |     patchelf | ||||||
|  |  | ||||||
|  | # Testing dependencies | ||||||
|  | sudo apt install -y bridge-utils | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **For Cross-Compilation** | ||||||
|  | - musl-cross toolchain (for MIPS and other architectures) | ||||||
|  | - Additional setup may be required (see `.github/workflows/` for details) | ||||||
|  |  | ||||||
|  | **For Android Development** | ||||||
|  | - Java 20 | ||||||
|  | - Android SDK (Build Tools 34.0.0) | ||||||
|  | - Android NDK (26.0.10792818) | ||||||
|  |  | ||||||
|  | ### Installation Steps | ||||||
|  |  | ||||||
|  | 1. Clone the repository: | ||||||
|  |    ```bash | ||||||
|  |    git clone https://github.com/EasyTier/EasyTier.git | ||||||
|  |    cd EasyTier | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. Install dependencies: | ||||||
|  |    ```bash | ||||||
|  |    # Install Rust toolchain | ||||||
|  |    rustup install 1.89 | ||||||
|  |    rustup default 1.89 | ||||||
|  |  | ||||||
|  |    # Install project dependencies | ||||||
|  |    pnpm -r install | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## Project Structure | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | easytier/          # Core functionality and libraries | ||||||
|  | easytier-web/      # Web dashboard and frontend | ||||||
|  | easytier-gui/      # Desktop GUI application | ||||||
|  | .github/workflows/ # CI/CD configuration files | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Build Guide | ||||||
|  |  | ||||||
|  | ### Building Core | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Standard build | ||||||
|  | cargo build --release | ||||||
|  |  | ||||||
|  | # Platform-specific builds | ||||||
|  | cargo build --release --target x86_64-unknown-linux-musl     # Linux x86_64 | ||||||
|  | cargo build --release --target aarch64-unknown-linux-musl    # Linux ARM64 | ||||||
|  | cargo build --release --target x86_64-apple-darwin           # macOS x86_64 | ||||||
|  | cargo build --release --target aarch64-apple-darwin          # macOS M1/M2 | ||||||
|  | cargo build --release --target x86_64-pc-windows-msvc        # Windows x86_64 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Build artifacts: `target/[target-triple]/release/` | ||||||
|  |  | ||||||
|  | ### Building GUI | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 1. Build frontend | ||||||
|  | pnpm -r build | ||||||
|  |  | ||||||
|  | # 2. Build GUI application | ||||||
|  | cd easytier-gui | ||||||
|  |  | ||||||
|  | # Linux | ||||||
|  | pnpm tauri build --target x86_64-unknown-linux-gnu | ||||||
|  |  | ||||||
|  | # macOS | ||||||
|  | pnpm tauri build --target x86_64-apple-darwin      # Intel | ||||||
|  | pnpm tauri build --target aarch64-apple-darwin     # Apple Silicon | ||||||
|  |  | ||||||
|  | # Windows | ||||||
|  | pnpm tauri build --target x86_64-pc-windows-msvc   # x64 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Build artifacts: `easytier-gui/src-tauri/target/release/bundle/` | ||||||
|  |  | ||||||
|  | ### Building Mobile | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 1. Install Android targets | ||||||
|  | rustup target add aarch64-linux-android | ||||||
|  | rustup target add armv7-linux-androideabi | ||||||
|  | rustup target add i686-linux-android | ||||||
|  | rustup target add x86_64-linux-android | ||||||
|  |  | ||||||
|  | # 2. Build Android application | ||||||
|  | cd easytier-gui | ||||||
|  | pnpm tauri android build | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Build artifacts: `easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/` | ||||||
|  |  | ||||||
|  | ### Build Notes | ||||||
|  |  | ||||||
|  | 1. Cross-compilation for ARM/MIPS requires additional setup | ||||||
|  | 2. Windows builds need correct DLL files | ||||||
|  | 3. Check `.github/workflows/` for detailed build configurations | ||||||
|  |  | ||||||
|  | ## Development Workflow | ||||||
|  |  | ||||||
|  | 1. Create a feature branch from `develop`: | ||||||
|  |    ```bash | ||||||
|  |    git checkout develop | ||||||
|  |    git checkout -b feature/your-feature-name | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. Make your changes following our coding standards | ||||||
|  |  | ||||||
|  | 3. Write or update tests as needed | ||||||
|  |  | ||||||
|  | 4. Use conventional commit messages: | ||||||
|  |    ``` | ||||||
|  |    feat: add new feature | ||||||
|  |    fix: resolve bug | ||||||
|  |    docs: update documentation | ||||||
|  |    test: add tests | ||||||
|  |    chore: update dependencies | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 5. Submit a pull request to `develop` | ||||||
|  |  | ||||||
|  | ## Testing Guidelines | ||||||
|  |  | ||||||
|  | ### Running Tests | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Configure system (Linux) | ||||||
|  | sudo modprobe br_netfilter | ||||||
|  | sudo sysctl net.bridge.bridge-nf-call-iptables=0 | ||||||
|  | sudo sysctl net.bridge.bridge-nf-call-ip6tables=0 | ||||||
|  |  | ||||||
|  | # Run tests | ||||||
|  | cargo test --no-default-features --features=full --verbose | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Test Requirements | ||||||
|  |  | ||||||
|  | - Write tests for new features | ||||||
|  | - Maintain existing test coverage | ||||||
|  | - Tests should be isolated and repeatable | ||||||
|  | - Include both unit and integration tests | ||||||
|  |  | ||||||
|  | ## Pull Request Guidelines | ||||||
|  |  | ||||||
|  | 1. Target the `develop` branch | ||||||
|  | 2. Ensure all tests pass | ||||||
|  | 3. Include clear description and purpose | ||||||
|  | 4. Reference related issues | ||||||
|  | 5. Keep changes focused and atomic | ||||||
|  | 6. Update documentation as needed | ||||||
|  |  | ||||||
|  | ## Additional Resources | ||||||
|  |  | ||||||
|  | - [Issue Tracker](https://github.com/EasyTier/EasyTier/issues) | ||||||
|  | - [Project Documentation](https://github.com/EasyTier/EasyTier/wiki) | ||||||
|  |  | ||||||
|  | ## Questions or Need Help? | ||||||
|  |  | ||||||
|  | Feel free to: | ||||||
|  | - Open an issue for questions | ||||||
|  | - Join our community discussions | ||||||
|  | - Reach out to maintainers | ||||||
|  |  | ||||||
|  | Thank you for contributing to EasyTier!  | ||||||
							
								
								
									
										233
									
								
								CONTRIBUTING_zh.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,233 @@ | |||||||
|  | # EasyTier 贡献指南 | ||||||
|  |  | ||||||
|  | [English Version](CONTRIBUTING.md) | ||||||
|  |  | ||||||
|  | 感谢您对 EasyTier 项目的关注!本文档提供了参与项目贡献的指南和说明。 | ||||||
|  |  | ||||||
|  | ## 目录 | ||||||
|  |  | ||||||
|  | - [EasyTier 贡献指南](#easytier-贡献指南) | ||||||
|  |   - [目录](#目录) | ||||||
|  |   - [开发环境配置](#开发环境配置) | ||||||
|  |     - [前置要求](#前置要求) | ||||||
|  |       - [必需工具](#必需工具) | ||||||
|  |       - [平台特定依赖](#平台特定依赖) | ||||||
|  |     - [安装步骤](#安装步骤) | ||||||
|  |   - [项目结构](#项目结构) | ||||||
|  |   - [构建指南](#构建指南) | ||||||
|  |     - [构建核心组件](#构建核心组件) | ||||||
|  |     - [构建桌面应用](#构建桌面应用) | ||||||
|  |     - [构建移动应用](#构建移动应用) | ||||||
|  |     - [构建注意事项](#构建注意事项) | ||||||
|  |   - [开发工作流](#开发工作流) | ||||||
|  |   - [测试指南](#测试指南) | ||||||
|  |     - [运行测试](#运行测试) | ||||||
|  |     - [测试要求](#测试要求) | ||||||
|  |   - [Pull Request 规范](#pull-request-规范) | ||||||
|  |   - [其他资源](#其他资源) | ||||||
|  |   - [需要帮助?](#需要帮助) | ||||||
|  |  | ||||||
|  | ## 开发环境配置 | ||||||
|  |  | ||||||
|  | ### 前置要求 | ||||||
|  |  | ||||||
|  | #### 必需工具 | ||||||
|  | - Node.js v21 或更高版本 | ||||||
|  | - pnpm v9 或更高版本 | ||||||
|  | - Rust 工具链(版本 1.89) | ||||||
|  | - LLVM 和 Clang | ||||||
|  | - Protoc(Protocol Buffers 编译器) | ||||||
|  |  | ||||||
|  | #### 平台特定依赖 | ||||||
|  |  | ||||||
|  | **Linux (Ubuntu/Debian)** | ||||||
|  | ```bash | ||||||
|  | # 核心构建依赖 | ||||||
|  | sudo apt-get update && sudo apt-get install -y \ | ||||||
|  |     musl-tools \ | ||||||
|  |     llvm \ | ||||||
|  |     clang \ | ||||||
|  |     protobuf-compiler | ||||||
|  |  | ||||||
|  | # GUI 构建依赖 | ||||||
|  | sudo apt install -y \ | ||||||
|  |     libwebkit2gtk-4.1-dev \ | ||||||
|  |     build-essential \ | ||||||
|  |     curl \ | ||||||
|  |     wget \ | ||||||
|  |     file \ | ||||||
|  |     libgtk-3-dev \ | ||||||
|  |     librsvg2-dev \ | ||||||
|  |     libxdo-dev \ | ||||||
|  |     libssl-dev \ | ||||||
|  |     libappindicator3-dev \ | ||||||
|  |     patchelf | ||||||
|  |  | ||||||
|  | # 测试依赖 | ||||||
|  | sudo apt install -y bridge-utils | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **交叉编译依赖** | ||||||
|  | - musl-cross 工具链(用于 MIPS 和其他架构) | ||||||
|  | - 可能需要额外配置(详见 `.github/workflows/` 目录) | ||||||
|  |  | ||||||
|  | **Android 开发依赖** | ||||||
|  | - Java 20 | ||||||
|  | - Android SDK(Build Tools 34.0.0) | ||||||
|  | - Android NDK(26.0.10792818) | ||||||
|  |  | ||||||
|  | ### 安装步骤 | ||||||
|  |  | ||||||
|  | 1. 克隆仓库: | ||||||
|  |    ```bash | ||||||
|  |    git clone https://github.com/EasyTier/EasyTier.git | ||||||
|  |    cd EasyTier | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. 安装依赖: | ||||||
|  |    ```bash | ||||||
|  |    # 安装 Rust 工具链 | ||||||
|  |    rustup install 1.89 | ||||||
|  |    rustup default 1.89 | ||||||
|  |  | ||||||
|  |    # 安装项目依赖 | ||||||
|  |    pnpm -r install | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## 项目结构 | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | easytier/          # 核心功能和库 | ||||||
|  | easytier-web/      # Web 仪表盘和前端 | ||||||
|  | easytier-gui/      # 桌面 GUI 应用 | ||||||
|  | .github/workflows/ # CI/CD 配置文件 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 构建指南 | ||||||
|  |  | ||||||
|  | ### 构建核心组件 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 标准构建 | ||||||
|  | cargo build --release | ||||||
|  |  | ||||||
|  | # 特定平台构建 | ||||||
|  | cargo build --release --target x86_64-unknown-linux-musl     # Linux x86_64 | ||||||
|  | cargo build --release --target aarch64-unknown-linux-musl    # Linux ARM64 | ||||||
|  | cargo build --release --target x86_64-apple-darwin           # macOS x86_64 | ||||||
|  | cargo build --release --target aarch64-apple-darwin          # macOS M1/M2 | ||||||
|  | cargo build --release --target x86_64-pc-windows-msvc        # Windows x86_64 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 构建产物位置:`target/[target-triple]/release/` | ||||||
|  |  | ||||||
|  | ### 构建桌面应用 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 1. 构建前端 | ||||||
|  | pnpm -r build | ||||||
|  |  | ||||||
|  | # 2. 构建 GUI 应用 | ||||||
|  | cd easytier-gui | ||||||
|  |  | ||||||
|  | # Linux | ||||||
|  | pnpm tauri build --target x86_64-unknown-linux-gnu | ||||||
|  |  | ||||||
|  | # macOS | ||||||
|  | pnpm tauri build --target x86_64-apple-darwin      # Intel | ||||||
|  | pnpm tauri build --target aarch64-apple-darwin     # Apple Silicon | ||||||
|  |  | ||||||
|  | # Windows | ||||||
|  | pnpm tauri build --target x86_64-pc-windows-msvc   # x64 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 构建产物位置:`easytier-gui/src-tauri/target/release/bundle/` | ||||||
|  |  | ||||||
|  | ### 构建移动应用 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 1. 安装 Android 目标平台 | ||||||
|  | rustup target add aarch64-linux-android | ||||||
|  | rustup target add armv7-linux-androideabi | ||||||
|  | rustup target add i686-linux-android | ||||||
|  | rustup target add x86_64-linux-android | ||||||
|  |  | ||||||
|  | # 2. 构建 Android 应用 | ||||||
|  | cd easytier-gui | ||||||
|  | pnpm tauri android build | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 构建产物位置:`easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/` | ||||||
|  |  | ||||||
|  | ### 构建注意事项 | ||||||
|  |  | ||||||
|  | 1. ARM/MIPS 的交叉编译需要额外配置 | ||||||
|  | 2. Windows 构建需要正确的 DLL 文件 | ||||||
|  | 3. 详细构建配置请参考 `.github/workflows/` 目录 | ||||||
|  |  | ||||||
|  | ## 开发工作流 | ||||||
|  |  | ||||||
|  | 1. 从 `develop` 分支创建特性分支: | ||||||
|  |    ```bash | ||||||
|  |    git checkout develop | ||||||
|  |    git checkout -b feature/your-feature-name | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. 按照代码规范进行修改 | ||||||
|  |  | ||||||
|  | 3. 编写或更新测试 | ||||||
|  |  | ||||||
|  | 4. 使用规范的提交信息: | ||||||
|  |    ``` | ||||||
|  |    feat: 添加新功能 | ||||||
|  |    fix: 修复问题 | ||||||
|  |    docs: 更新文档 | ||||||
|  |    test: 添加测试 | ||||||
|  |    chore: 更新依赖 | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 5. 提交 Pull Request 到 `develop` 分支 | ||||||
|  |  | ||||||
|  | ## 测试指南 | ||||||
|  |  | ||||||
|  | ### 运行测试 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 配置系统(Linux) | ||||||
|  | sudo modprobe br_netfilter | ||||||
|  | sudo sysctl net.bridge.bridge-nf-call-iptables=0 | ||||||
|  | sudo sysctl net.bridge.bridge-nf-call-ip6tables=0 | ||||||
|  |  | ||||||
|  | # 运行测试 | ||||||
|  | cargo test --no-default-features --features=full --verbose | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 测试要求 | ||||||
|  |  | ||||||
|  | - 为新功能编写测试 | ||||||
|  | - 维护现有测试覆盖率 | ||||||
|  | - 测试应该是独立且可重复的 | ||||||
|  | - 包含单元测试和集成测试 | ||||||
|  |  | ||||||
|  | ## Pull Request 规范 | ||||||
|  |  | ||||||
|  | 1. 目标分支为 `develop` | ||||||
|  | 2. 确保所有测试通过 | ||||||
|  | 3. 包含清晰的描述和目的 | ||||||
|  | 4. 关联相关的 issues | ||||||
|  | 5. 保持变更的原子性和聚焦性 | ||||||
|  | 6. 及时更新相关文档 | ||||||
|  |  | ||||||
|  | ## 其他资源 | ||||||
|  |  | ||||||
|  | - [问题追踪](https://github.com/EasyTier/EasyTier/issues) | ||||||
|  | - [项目文档](https://github.com/EasyTier/EasyTier/wiki) | ||||||
|  |  | ||||||
|  | ## 需要帮助? | ||||||
|  |  | ||||||
|  | 欢迎: | ||||||
|  | - 提出问题 | ||||||
|  | - 参与社区讨论 | ||||||
|  | - 联系维护者 | ||||||
|  |  | ||||||
|  | 感谢您为 EasyTier 做出贡献!  | ||||||
							
								
								
									
										2028
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -6,11 +6,17 @@ members = [ | |||||||
|     "easytier-rpc-build", |     "easytier-rpc-build", | ||||||
|     "easytier-web", |     "easytier-web", | ||||||
|     "easytier-contrib/easytier-ffi", |     "easytier-contrib/easytier-ffi", | ||||||
|  |     "easytier-contrib/easytier-uptime", | ||||||
|  |     "easytier-contrib/easytier-android-jni", | ||||||
| ] | ] | ||||||
| default-members = ["easytier", "easytier-web"] | default-members = ["easytier", "easytier-web"] | ||||||
|  | exclude = [ | ||||||
|  |     "easytier-contrib/easytier-ohrs", # it needs ohrs sdk | ||||||
|  | ] | ||||||
|  |  | ||||||
| [profile.dev] | [profile.dev] | ||||||
| panic = "unwind" | panic = "unwind" | ||||||
|  | debug = 2 | ||||||
|  |  | ||||||
| [profile.release] | [profile.release] | ||||||
| panic = "abort" | panic = "abort" | ||||||
|   | |||||||
| @@ -3,13 +3,33 @@ | |||||||
| 		{ | 		{ | ||||||
| 			"path": "." | 			"path": "." | ||||||
| 		}, | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"name": "core", | ||||||
|  | 			"path": "easytier" | ||||||
|  | 		}, | ||||||
| 		{ | 		{ | ||||||
| 			"name": "gui", | 			"name": "gui", | ||||||
| 			"path": "easytier-gui" | 			"path": "easytier-gui" | ||||||
| 		}, | 		}, | ||||||
| 		{ | 		{ | ||||||
| 			"name": "core", | 			"name": "web", | ||||||
| 			"path": "easytier" | 			"path": "easytier-web" | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"name": "ffi", | ||||||
|  | 			"path": "easytier-contrib/easytier-ffi" | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"name": "magisk", | ||||||
|  | 			"path": "easytier-contrib/easytier-magisk" | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"name": "openharmony", | ||||||
|  | 			"path": "easytier-contrib/easytier-ohrs" | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"name": "uptime", | ||||||
|  | 			"path": "easytier-contrib/easytier-uptime" | ||||||
| 		}, | 		}, | ||||||
| 		{ | 		{ | ||||||
| 			"name": "vpnservice", | 			"name": "vpnservice", | ||||||
| @@ -26,5 +46,12 @@ | |||||||
| 		"i18n-ally.sortKeys": true, | 		"i18n-ally.sortKeys": true, | ||||||
| 		// Disable the default formatter | 		// Disable the default formatter | ||||||
| 		"prettier.enable": false, | 		"prettier.enable": false, | ||||||
|  | 		"editor.formatOnSave": true, | ||||||
|  | 		"editor.formatOnSaveMode": "modifications", | ||||||
|  | 		"editor.formatOnPaste": false, | ||||||
|  | 		"editor.formatOnType": true, | ||||||
|  | 		"[nix]": { | ||||||
|  | 			"editor.formatOnSave": false, | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
							
								
								
									
										499
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -11,263 +11,234 @@ | |||||||
|  |  | ||||||
| [简体中文](/README_CN.md) | [English](/README.md) | [简体中文](/README_CN.md) | [English](/README.md) | ||||||
|  |  | ||||||
| **Please visit the [EasyTier Official Website](https://easytier.cn/en/) to view the full documentation.** | > ✨ A simple, secure, decentralized virtual private network solution powered by Rust and Tokio | ||||||
|  |  | ||||||
| EasyTier is a simple, safe and decentralized VPN networking solution implemented with the Rust language and Tokio framework. |  | ||||||
|  |  | ||||||
| <p align="center"> | <p align="center"> | ||||||
| <img src="assets/image-5.png" width="300"> | <img src="assets/config-page.png" width="300" alt="config page"> | ||||||
| <img src="assets/image-4.png" width="300"> | <img src="assets/running-page.png" width="300" alt="running page"> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
|  | 📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)** | ||||||
|  |  | ||||||
| ## Features | ## Features | ||||||
|  |  | ||||||
| - **Decentralized**: No need to rely on centralized services, nodes are equal and independent. | ### Core Features | ||||||
| - **Safe**: Use WireGuard protocol to encrypt data. |  | ||||||
| - **High Performance**: Full-link zero-copy, with performance comparable to mainstream networking software. |  | ||||||
| - **Cross-platform**: Supports MacOS/Linux/Windows/Android, will support IOS in the future. The executable file is statically linked, making deployment simple. |  | ||||||
| - **Networking without public IP**: Supports networking using shared public nodes, refer to [Configuration Guide](#Networking-without-public-IP) |  | ||||||
| - **NAT traversal**: Supports UDP-based NAT traversal, able to establish stable connections even in complex network environments. |  | ||||||
| - **Subnet Proxy (Point-to-Network)**: Nodes can expose accessible network segments as proxies to the VPN subnet, allowing other nodes to access these subnets through the node. |  | ||||||
| - **Smart Routing**: Selects links based on traffic to reduce latency and increase throughput. |  | ||||||
| - **TCP Support**: Provides reliable data transmission through concurrent TCP links when UDP is limited, optimizing performance. |  | ||||||
| - **High Availability**: Supports multi-path and switches to healthy paths when high packet loss or network errors are detected. |  | ||||||
| - **IPv6 Support**: Supports networking using IPv6. |  | ||||||
| - **Multiple Protocol Types**: Supports communication between nodes using protocols such as WebSocket and QUIC. |  | ||||||
| - **Web Management Interface**: Provides a [web-based management](https://easytier.cn/web) interface for easy configuration and monitoring. |  | ||||||
|  |  | ||||||
| ## Installation | - 🔒 **Decentralized**: Nodes are equal and independent, no centralized services required   | ||||||
|  | - 🚀 **Easy to Use**: Multiple operation methods via web, client, and command line   | ||||||
|  | - 🌍 **Cross-Platform**: Supports Win/MacOS/Linux/FreeBSD/Android and X86/ARM/MIPS architectures   | ||||||
|  | - 🔐 **Secure**: AES-GCM or WireGuard encryption, prevents man-in-the-middle attacks   | ||||||
|  |  | ||||||
| 1. **Download the precompiled binary file** | ### Advanced Capabilities | ||||||
|  |  | ||||||
|     Visit the [GitHub Release page](https://github.com/EasyTier/EasyTier/releases) to download the binary file suitable for your operating system. Release includes both command-line programs and GUI programs in the compressed package. | - 🔌 **Efficient NAT Traversal**: Supports UDP and IPv6 traversal, works with NAT4-NAT4 networks   | ||||||
|  | - 🌐 **Subnet Proxy**: Nodes can share subnets for other nodes to access   | ||||||
|  | - 🔄 **Intelligent Routing**: Latency priority and automatic route selection for best network experience   | ||||||
|  | - ⚡ **High Performance**: Zero-copy throughout the entire link, supports TCP/UDP/WSS/WG protocols   | ||||||
|  |  | ||||||
| 2. **Install via crates.io** | ### Network Optimization | ||||||
|  |  | ||||||
|     ```sh | - 📊 **UDP Loss Resistance**: KCP/QUIC proxy optimizes latency and bandwidth in high packet loss environments   | ||||||
|     cargo install easytier | - 🔧 **Web Management**: Easy configuration and monitoring through web interface   | ||||||
|     ``` | - 🛠️ **Zero Config**: Simple deployment with statically linked executables   | ||||||
|  |  | ||||||
| 3. **Install from source code** |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     cargo install --git https://github.com/EasyTier/EasyTier.git easytier |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 4. **Install by Docker Compose** |  | ||||||
|  |  | ||||||
|     Please visit the [EasyTier Official Website](https://easytier.cn/en/) to view the full documentation. |  | ||||||
|  |  | ||||||
| 5. **Install by script (For Linux Only)** |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     wget -O /tmp/easytier.sh "https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh" && bash /tmp/easytier.sh install |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     The script supports the following commands and options: |  | ||||||
|  |  | ||||||
|     Commands: |  | ||||||
|     - `install`: Install EasyTier |  | ||||||
|     - `uninstall`: Uninstall EasyTier |  | ||||||
|     - `update`: Update EasyTier to the latest version |  | ||||||
|     - `help`: Show help message |  | ||||||
|  |  | ||||||
|     Options: |  | ||||||
|     - `--skip-folder-verify`: Skip folder verification during installation |  | ||||||
|     - `--skip-folder-fix`: Skip automatic folder path fixing |  | ||||||
|     - `--no-gh-proxy`: Disable GitHub proxy |  | ||||||
|     - `--gh-proxy`: Set custom GitHub proxy URL (default: https://ghfast.top/) |  | ||||||
|  |  | ||||||
|     Examples: |  | ||||||
|     ```sh |  | ||||||
|     # Show help |  | ||||||
|     bash /tmp/easytier.sh help |  | ||||||
|  |  | ||||||
|     # Install with options |  | ||||||
|     bash /tmp/easytier.sh install --skip-folder-verify |  | ||||||
|     bash /tmp/easytier.sh install --no-gh-proxy |  | ||||||
|     bash /tmp/easytier.sh install --gh-proxy https://your-proxy.com/ |  | ||||||
|  |  | ||||||
|     # Update EasyTier |  | ||||||
|     bash /tmp/easytier.sh update |  | ||||||
|  |  | ||||||
|     # Uninstall EasyTier |  | ||||||
|     bash /tmp/easytier.sh uninstall |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 6. **Install by Homebrew (For MacOS Only)** |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     brew tap brewforge/chinese |  | ||||||
|     brew install --cask easytier-gui |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| ## Quick Start | ## Quick Start | ||||||
|  |  | ||||||
| > The following text only describes the use of the command-line tool; the GUI program can be configured by referring to the following concepts. | ### 📥 Installation | ||||||
|  |  | ||||||
| Make sure EasyTier is installed according to the [Installation Guide](#Installation), and both easytier-core and easytier-cli commands are available. | Choose the installation method that best suits your needs: | ||||||
|  |  | ||||||
| ### Two-node Networking | ```bash | ||||||
|  | # 1. Download pre-built binary (Recommended, All platforms supported) | ||||||
|  | # Visit https://github.com/EasyTier/EasyTier/releases | ||||||
|  |  | ||||||
| Assuming the network topology of the two nodes is as follows | # 2. Install via cargo (Latest development version) | ||||||
|  | cargo install --git https://github.com/EasyTier/EasyTier.git easytier | ||||||
|  |  | ||||||
|  | # 3. Install via Docker | ||||||
|  | # See https://easytier.cn/en/guide/installation.html#installation-methods | ||||||
|  |  | ||||||
|  | # 4. Linux Quick Install | ||||||
|  | wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install | ||||||
|  |  | ||||||
|  | # 5. MacOS via Homebrew | ||||||
|  | brew tap brewforge/chinese | ||||||
|  | brew install --cask easytier-gui | ||||||
|  |  | ||||||
|  | # 6. OpenWrt Luci Web UI | ||||||
|  | # Visit https://github.com/EasyTier/luci-app-easytier | ||||||
|  |  | ||||||
|  | # 7. (Optional) Install shell completions: | ||||||
|  | easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish | ||||||
|  | easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 🚀 Basic Usage | ||||||
|  |  | ||||||
|  | #### Quick Networking with Shared Nodes | ||||||
|  |  | ||||||
|  | EasyTier supports quick networking using shared public nodes. When you don't have a public IP, you can use the free shared nodes provided by the EasyTier community. Nodes will automatically attempt NAT traversal and establish P2P connections. When P2P fails, data will be relayed through shared nodes. | ||||||
|  |  | ||||||
|  | The currently deployed shared public node is `tcp://public.easytier.cn:11010`. | ||||||
|  |  | ||||||
|  | When using shared nodes, each node entering the network needs to provide the same `--network-name` and `--network-secret` parameters as the unique identifier of the network. | ||||||
|  |  | ||||||
|  | Taking two nodes as an example (Please use more complex network name to avoid conflicts): | ||||||
|  |  | ||||||
|  | 1. Run on Node A: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Run with administrator privileges | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 2. Run on Node B: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Run with administrator privileges | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | After successful execution, you can check the network status using `easytier-cli`: | ||||||
|  |  | ||||||
|  | ```text | ||||||
|  | | ipv4         | hostname       | cost  | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id         | version         | | ||||||
|  | | ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- | | ||||||
|  | | 10.126.126.1 | abc-1          | Local | *      | *         | *        | *        | udp          | FullCone | 439804259  | 2.4.5-70e69a38~ | | ||||||
|  | | 10.126.126.2 | abc-2          | p2p   | 3.452  | 0         | 17.33 kB | 20.42 kB | udp          | FullCone | 390879727  | 2.4.5-70e69a38~ | | ||||||
|  | |              | PublicServer_a | p2p   | 27.796 | 0.000     | 50.01 kB | 67.46 kB | tcp          | Unknown  | 3771642457 | 2.4.5-70e69a38~ | | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | You can test connectivity between nodes: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Test connectivity | ||||||
|  | ping 10.126.126.1 | ||||||
|  | ping 10.126.126.2 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Note: If you cannot ping through, it may be that the firewall is blocking incoming traffic. Please turn off the firewall or add allow rules. | ||||||
|  |  | ||||||
|  | To improve availability, you can connect to multiple shared nodes simultaneously: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Connect to multiple shared nodes | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Once your network is set up successfully, you can easily configure it to start automatically on system boot. Refer to the [One-Click Register Service guide](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) for step-by-step instructions on registering EasyTier as a system service. | ||||||
|  |  | ||||||
|  | #### Decentralized Networking | ||||||
|  |  | ||||||
|  | EasyTier is fundamentally decentralized, with no distinction between server and client. As long as one device can communicate with any node in the virtual network, it can join the virtual network. Here's how to set up a decentralized network: | ||||||
|  |  | ||||||
|  | 1. Start First Node (Node A): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Start the first node | ||||||
|  | sudo easytier-core -i 10.144.144.1 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | After startup, this node will listen on the following ports by default: | ||||||
|  | - TCP: 11010 | ||||||
|  | - UDP: 11010 | ||||||
|  | - WebSocket: 11011 | ||||||
|  | - WebSocket SSL: 11012 | ||||||
|  | - WireGuard: 11013 | ||||||
|  |  | ||||||
|  | 2. Connect Second Node (Node B): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Connect to the first node using its public IP | ||||||
|  | sudo easytier-core -i 10.144.144.2 -p udp://FIRST_NODE_PUBLIC_IP:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 3. Verify Connection: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Test connectivity | ||||||
|  | ping 10.144.144.2 | ||||||
|  |  | ||||||
|  | # View connected peers | ||||||
|  | easytier-cli peer | ||||||
|  |  | ||||||
|  | # View routing information | ||||||
|  | easytier-cli route | ||||||
|  |  | ||||||
|  | # View local node information | ||||||
|  | easytier-cli node | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | For more nodes to join the network, they can connect to any existing node in the network using the `-p` parameter: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Connect to any existing node using its public IP | ||||||
|  | sudo easytier-core -i 10.144.144.3 -p udp://ANY_EXISTING_NODE_PUBLIC_IP:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 🔍 Advanced Features | ||||||
|  |  | ||||||
|  | #### Subnet Proxy | ||||||
|  |  | ||||||
|  | Assuming the network topology is as follows, Node B wants to share its accessible subnet 10.1.1.0/24 with other nodes: | ||||||
|  |  | ||||||
| ```mermaid | ```mermaid | ||||||
| flowchart LR | flowchart LR | ||||||
|  |  | ||||||
| subgraph Node A IP 22.1.1.1 | subgraph Node A Public IP 22.1.1.1 | ||||||
| nodea[EasyTier\n10.144.144.1] | nodea[EasyTier<br/>10.144.144.1] | ||||||
| end | end | ||||||
|  |  | ||||||
| subgraph Node B | subgraph Node B | ||||||
| nodeb[EasyTier\n10.144.144.2] | nodeb[EasyTier<br/>10.144.144.2] | ||||||
| end |  | ||||||
|  |  | ||||||
| nodea <-----> nodeb |  | ||||||
|  |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| 1. Execute on Node A: |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     sudo easytier-core --ipv4 10.144.144.1 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     Successful execution of the command will print the following. |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
| 2. Execute on Node B |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     sudo easytier-core --ipv4 10.144.144.2 --peers udp://22.1.1.1:11010 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 3. Test Connectivity |  | ||||||
|  |  | ||||||
|     The two nodes should connect successfully and be able to communicate within the virtual subnet |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     ping 10.144.144.2 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     Use easytier-cli to view node information in the subnet |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli peer |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli route |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli node |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### Multi-node Networking |  | ||||||
|  |  | ||||||
| Based on the two-node networking example just now, if more nodes need to join the virtual network, you can use the following command. |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| sudo easytier-core --ipv4 10.144.144.2 --peers udp://22.1.1.1:11010 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| The `--peers` parameter can fill in the listening address of any node already in the virtual network. |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### Subnet Proxy (Point-to-Network) Configuration |  | ||||||
|  |  | ||||||
| Assuming the network topology is as follows, Node B wants to share its accessible subnet 10.1.1.0/24 with other nodes. |  | ||||||
|  |  | ||||||
| ```mermaid |  | ||||||
| flowchart LR |  | ||||||
|  |  | ||||||
| subgraph Node A IP 22.1.1.1 |  | ||||||
| nodea[EasyTier\n10.144.144.1] |  | ||||||
| end |  | ||||||
|  |  | ||||||
| subgraph Node B |  | ||||||
| nodeb[EasyTier\n10.144.144.2] |  | ||||||
| end | end | ||||||
|  |  | ||||||
| id1[[10.1.1.0/24]] | id1[[10.1.1.0/24]] | ||||||
|  |  | ||||||
| nodea <--> nodeb <-.-> id1 | nodea <--> nodeb <-.-> id1 | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Then the startup parameters for Node B's easytier are (new -n parameter) | To share a subnet, add the `-n` parameter when starting EasyTier: | ||||||
|  |  | ||||||
| ```sh | ```bash | ||||||
| sudo easytier-core --ipv4 10.144.144.2 -n 10.1.1.0/24 | # Share subnet 10.1.1.0/24 with other nodes | ||||||
|  | sudo easytier-core -i 10.144.144.2 -n 10.1.1.0/24 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Subnet proxy information will automatically sync to each node in the virtual network, and each node will automatically configure the corresponding route. Node A can check whether the subnet proxy is effective through the following command. | Subnet proxy information will automatically sync to each node in the virtual network, and each node will automatically configure the corresponding route. You can verify the subnet proxy setup: | ||||||
|  |  | ||||||
| 1. Check whether the routing information has been synchronized, the proxy_cidrs column shows the proxied subnets. | 1. Check if the routing information has been synchronized (the proxy_cidrs column shows the proxied subnets): | ||||||
|  |  | ||||||
|     ```sh | ```bash | ||||||
|     easytier-cli route | # View routing information | ||||||
|     ``` | easytier-cli route | ||||||
|  |  | ||||||
|     |  | ||||||
|  |  | ||||||
| 2. Test whether Node A can access nodes under the proxied subnet |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     ping 10.1.1.2 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### Networking without Public IP |  | ||||||
|  |  | ||||||
| EasyTier supports networking using shared public nodes. The currently deployed shared public node is ``tcp://public.easytier.cn:11010``. |  | ||||||
|  |  | ||||||
| When using shared nodes, each node entering the network needs to provide the same ``--network-name`` and ``--network-secret`` parameters as the unique identifier of the network. |  | ||||||
|  |  | ||||||
| Taking two nodes as an example, Node A executes: |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| sudo easytier-core -i 10.144.144.1 --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Node B executes |  | ||||||
|  |  | ||||||
| ```sh | 2. Test if you can access nodes in the proxied subnet: | ||||||
| sudo easytier-core --ipv4 10.144.144.2 --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 |  | ||||||
|  | ```bash | ||||||
|  | # Test connectivity to proxied subnet | ||||||
|  | ping 10.1.1.2 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| After the command is successfully executed, Node A can access Node B through the virtual IP 10.144.144.2. | #### WireGuard Integration | ||||||
|  |  | ||||||
| ### Use EasyTier with WireGuard Client | EasyTier can act as a WireGuard server, allowing any device with a WireGuard client (including iOS and Android) to access the EasyTier network. Here's an example setup: | ||||||
|  |  | ||||||
| EasyTier can be used as a WireGuard server to allow any device with WireGuard client installed to access the EasyTier network. For platforms currently unsupported by EasyTier (such as iOS, Android, etc.), this method can be used to connect to the EasyTier network. |  | ||||||
|  |  | ||||||
| Assuming the network topology is as follows: |  | ||||||
|  |  | ||||||
| ```mermaid | ```mermaid | ||||||
| flowchart LR | flowchart LR | ||||||
|  |  | ||||||
| ios[[iPhone \n WireGuard Installed]] | ios[[iPhone<br/>WireGuard Installed]] | ||||||
|  |  | ||||||
| subgraph Node A IP 22.1.1.1 | subgraph Node A Public IP 22.1.1.1 | ||||||
| nodea[EasyTier\n10.144.144.1] | nodea[EasyTier<br/>10.144.144.1] | ||||||
| end | end | ||||||
|  |  | ||||||
| subgraph Node B | subgraph Node B | ||||||
| nodeb[EasyTier\n10.144.144.2] | nodeb[EasyTier<br/>10.144.144.2] | ||||||
| end | end | ||||||
|  |  | ||||||
| id1[[10.1.1.0/24]] | id1[[10.1.1.0/24]] | ||||||
| @@ -275,86 +246,80 @@ id1[[10.1.1.0/24]] | |||||||
| ios <-.-> nodea <--> nodeb <-.-> id1 | ios <-.-> nodea <--> nodeb <-.-> id1 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| To enable an iPhone to access the EasyTier network through Node A, the following configuration can be applied: | 1. Start EasyTier with WireGuard portal enabled: | ||||||
|  |  | ||||||
| Include the --vpn-portal parameter in the easytier-core command on Node A to specify the port that the WireGuard service listens on and the subnet used by the WireGuard network. | ```bash | ||||||
|  | # Listen on 0.0.0.0:11013 and use 10.14.14.0/24 subnet for WireGuard clients | ||||||
| ```sh | sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 | ||||||
| # The following parameters mean: listen on port 0.0.0.0:11013, and use the 10.14.14.0/24 subnet for WireGuard |  | ||||||
| sudo easytier-core --ipv4 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| After successfully starting easytier-core, use easytier-cli to obtain the WireGuard client configuration. | 2. Get WireGuard client configuration: | ||||||
|  |  | ||||||
| ```sh | ```bash | ||||||
| $> easytier-cli vpn-portal | # Get WireGuard client configuration | ||||||
| portal_name: wireguard | easytier-cli vpn-portal | ||||||
|  |  | ||||||
| ############### client_config_start ############### |  | ||||||
|  |  | ||||||
| [Interface] |  | ||||||
| PrivateKey = 9VDvlaIC9XHUvRuE06hD2CEDrtGF+0lDthgr9SZfIho= |  | ||||||
| Address = 10.14.14.0/32 # should assign an ip from this cidr manually |  | ||||||
|  |  | ||||||
| [Peer] |  | ||||||
| PublicKey = zhrZQg4QdPZs8CajT3r4fmzcNsWpBL9ImQCUsnlXyGM= |  | ||||||
| AllowedIPs = 10.144.144.0/24,10.14.14.0/24 |  | ||||||
| Endpoint = 0.0.0.0:11013 # should be the public ip(or domain) of the vpn server |  | ||||||
| PersistentKeepalive = 25 |  | ||||||
|  |  | ||||||
| ############### client_config_end ############### |  | ||||||
|  |  | ||||||
| connected_clients: |  | ||||||
| [] |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Before using the Client Config, you need to modify the Interface Address and Peer Endpoint to the client's IP and the IP of the EasyTier node, respectively. Import the configuration file into the WireGuard client to access the EasyTier network. | 3. In the output configuration: | ||||||
|  |    - Set `Interface.Address` to an available IP from the WireGuard subnet | ||||||
|  |    - Set `Peer.Endpoint` to the public IP/domain of your EasyTier node | ||||||
|  |    - Import the modified configuration into your WireGuard client | ||||||
|  |  | ||||||
| ### Self-Hosted Public Server | #### Self-Hosted Public Shared Node | ||||||
|  |  | ||||||
| Every virtual network (with same network name and secret) can act as a public server cluster. Nodes of other network can connect to arbitrary nodes in public server cluster to discover each other without public IP. | You can run your own public shared node to help other nodes discover each other. A public shared node is just a regular EasyTier network (with same network name and secret) that other networks can connect to. | ||||||
|  |  | ||||||
| Run you own public server cluster is exactly same as running an virtual network, except that you can skip config the ipv4 addr. | To run a public shared node: | ||||||
|  |  | ||||||
| You can also join the official public server cluster with following command: | ```bash | ||||||
|  | # No need to specify IPv4 address for public shared nodes | ||||||
| ``` | sudo easytier-core --network-name mysharednode --network-secret mysharednode | ||||||
| sudo easytier-core --network-name easytier --network-secret easytier -p tcp://public.easytier.cn:11010 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ## Related Projects | ||||||
| ### Configurations |  | ||||||
|  |  | ||||||
| You can use ``easytier-core --help`` to view all configuration items |  | ||||||
|  |  | ||||||
| ## Roadmap |  | ||||||
|  |  | ||||||
| - [ ] Support features such TCP hole punching, KCP, FEC etc. |  | ||||||
| - [ ] Support iOS. |  | ||||||
|  |  | ||||||
| ## Community and Contribution |  | ||||||
|  |  | ||||||
| We welcome and encourage community contributions! If you want to get involved, please submit a [GitHub PR](https://github.com/EasyTier/EasyTier/pulls). Detailed contribution guidelines can be found in [CONTRIBUTING.md](https://github.com/EasyTier/EasyTier/blob/main/CONTRIBUTING.md). |  | ||||||
|  |  | ||||||
| ## Related Projects and Resources |  | ||||||
|  |  | ||||||
| - [ZeroTier](https://www.zerotier.com/): A global virtual network for connecting devices. | - [ZeroTier](https://www.zerotier.com/): A global virtual network for connecting devices. | ||||||
| - [TailScale](https://tailscale.com/): A VPN solution aimed at simplifying network configuration. | - [TailScale](https://tailscale.com/): A VPN solution aimed at simplifying network configuration. | ||||||
| - [vpncloud](https://github.com/dswd/vpncloud): A P2P Mesh VPN | - [vpncloud](https://github.com/dswd/vpncloud): A P2P Mesh VPN | ||||||
| - [Candy](https://github.com/lanthora/candy): A reliable, low-latency, and anti-censorship virtual private network | - [Candy](https://github.com/lanthora/candy): A reliable, low-latency, and anti-censorship virtual private network | ||||||
|  |  | ||||||
|  | ### Contact Us | ||||||
|  |  | ||||||
|  | - 💬 **[Telegram Group](https://t.me/easytier)** | ||||||
|  | - 👥 **[QQ Group]** | ||||||
|  |   - No.1 [949700262](https://qm.qq.com/q/wFoTUChqZW) | ||||||
|  |   - No.2 [837676408](https://qm.qq.com/q/4V33DrfgHe) | ||||||
|  |   - No.3 [957189589](https://qm.qq.com/q/YNyTQjwlai) | ||||||
|  |  | ||||||
| ## License | ## License | ||||||
|  |  | ||||||
| EasyTier is released under the [Apache License 2.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE). | EasyTier is released under the [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE). | ||||||
|  |  | ||||||
| ## Contact |  | ||||||
|  |  | ||||||
| - Ask questions or report problems: [GitHub Issues](https://github.com/EasyTier/EasyTier/issues) |  | ||||||
| - Discussion and exchange: [GitHub Discussions](https://github.com/EasyTier/EasyTier/discussions) |  | ||||||
| - Telegram:https://t.me/easytier |  | ||||||
| - QQ Group: 949700262 |  | ||||||
|  |  | ||||||
| ## Sponsor | ## Sponsor | ||||||
|  |  | ||||||
| <img src="assets/image-8.png" width="300"> | CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne. | ||||||
| <img src="assets/image-9.png" width="300"> |  | ||||||
|  | <p align="center"> | ||||||
|  |   <a href="https://edgeone.ai/?from=github" target="_blank"> | ||||||
|  |     <img src="assets/edgeone.png" width="200" alt="EdgeOne Logo"> | ||||||
|  |   </a> | ||||||
|  | </p> | ||||||
|  |  | ||||||
|  | Special thanks to [Langlang Cloud](https://langlangy.cn/?i26c5a5)  and [RainCloud](https://www.rainyun.com/NjM0NzQ1_) for sponsoring our public servers. | ||||||
|  |  | ||||||
|  | <p align="center"> | ||||||
|  | <a href="https://langlangy.cn/?i26c5a5" target="_blank"> | ||||||
|  | <img src="assets/langlang.png" width="200"> | ||||||
|  | </a> | ||||||
|  | <a href="https://langlangy.cn/?i26c5a5" target="_blank"> | ||||||
|  | <img src="assets/raincloud.png" width="200"> | ||||||
|  | </a> | ||||||
|  | </p> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | If you find EasyTier helpful, please consider sponsoring us. Software development and maintenance require a lot of time and effort, and your sponsorship will help us better maintain and improve EasyTier. | ||||||
|  |  | ||||||
|  | <p align="center"> | ||||||
|  | <img src="assets/wechat.png" width="200"> | ||||||
|  | <img src="assets/alipay.png" width="200"> | ||||||
|  | </p> | ||||||
|   | |||||||
							
								
								
									
										507
									
								
								README_CN.md
									
									
									
									
									
								
							
							
						
						| @@ -1,271 +1,243 @@ | |||||||
| # EasyTier | # EasyTier | ||||||
|  |  | ||||||
|  | [](https://github.com/EasyTier/EasyTier/releases) | ||||||
| [](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) | [](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) | ||||||
| [](https://github.com/EasyTier/EasyTier/commits/main) | [](https://github.com/EasyTier/EasyTier/commits/main) | ||||||
| [](https://github.com/EasyTier/EasyTier/issues) | [](https://github.com/EasyTier/EasyTier/issues) | ||||||
| [](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml) | [](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml) | ||||||
| [](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml) | [](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml) | ||||||
|  | [](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml) | ||||||
|  | [](https://deepwiki.com/EasyTier/EasyTier) | ||||||
|  |  | ||||||
| [简体中文](/README_CN.md) | [English](/README.md) | [简体中文](/README_CN.md) | [English](/README.md) | ||||||
|  |  | ||||||
| **请访问 [EasyTier 官网](https://easytier.cn/) 以查看完整的文档。** | > ✨ 一个由 Rust 和 Tokio 驱动的简单、安全、去中心化的异地组网方案 | ||||||
|  |  | ||||||
| 一个简单、安全、去中心化的内网穿透 VPN 组网方案,使用 Rust 语言和 Tokio 框架实现。 |  | ||||||
|  |  | ||||||
| <p align="center"> | <p align="center"> | ||||||
| <img src="assets/image-6.png" width="300"> | <img src="assets/config-page.png" width="300" alt="配置页面"> | ||||||
| <img src="assets/image-7.png" width="300"> | <img src="assets/running-page.png" width="300" alt="运行页面"> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
| ## 特点 | 📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)** | ||||||
|  |  | ||||||
| - **去中心化**:无需依赖中心化服务,节点平等且独立。 | ## 特性 | ||||||
| - **安全**:支持利用 WireGuard 加密通信,也支持 AES-GCM 加密保护中转流量。 |  | ||||||
| - **高性能**:全链路零拷贝,性能与主流组网软件相当。 |  | ||||||
| - **跨平台**:支持 MacOS/Linux/Windows/Android,未来将支持 IOS。可执行文件静态链接,部署简单。 |  | ||||||
| - **无公网 IP 组网**:支持利用共享的公网节点组网,可参考 [配置指南](#无公网IP组网) |  | ||||||
| - **NAT 穿透**:支持基于 UDP 的 NAT 穿透,即使在复杂的网络环境下也能建立稳定的连接。 |  | ||||||
| - **子网代理(点对网)**:节点可以将可访问的网段作为代理暴露给 VPN 子网,允许其他节点通过该节点访问这些子网。 |  | ||||||
| - **智能路由**:根据流量智能选择链路,减少延迟,提高吞吐量。 |  | ||||||
| - **TCP 支持**:在 UDP 受限的情况下,通过并发 TCP 链接提供可靠的数据传输,优化性能。 |  | ||||||
| - **高可用性**:支持多路径和在检测到高丢包率或网络错误时切换到健康路径。 |  | ||||||
| - **IPV6 支持**:支持利用 IPV6 组网。 |  | ||||||
| - **多协议类型**: 支持使用 WebSocket、QUIC 等协议进行节点间通信。 |  | ||||||
| - **Web 管理界面**:支持通过 [Web 界面](https://easytier.cn)管理节点。 |  | ||||||
|  |  | ||||||
| ## 安装 | ### 核心特性 | ||||||
|  |  | ||||||
| 1. **下载预编译的二进制文件** | - 🔒 **去中心化**:节点平等且独立,无需中心化服务 | ||||||
|  | - 🚀 **易于使用**:支持通过网页、客户端和命令行多种操作方式 | ||||||
|  | - 🌍 **跨平台**:支持 Win/MacOS/Linux/FreeBSD/Android 和 X86/ARM/MIPS 架构 | ||||||
|  | - 🔐 **安全**:AES-GCM 或 WireGuard 加密,防止中间人攻击 | ||||||
|  |  | ||||||
|     访问 [GitHub Release 页面](https://github.com/EasyTier/EasyTier/releases) 下载适用于您操作系统的二进制文件。Release 压缩包中同时包含命令行程序和图形界面程序。 | ### 高级功能 | ||||||
|  |  | ||||||
| 2. **通过 crates.io 安装** | - 🔌 **高效 NAT 穿透**:支持 UDP 和 IPv6 穿透,可在 NAT4-NAT4 网络中工作 | ||||||
|  | - 🌐 **子网代理**:节点可以共享子网供其他节点访问 | ||||||
|  | - 🔄 **智能路由**:延迟优先和自动路由选择,提供最佳网络体验 | ||||||
|  | - ⚡ **高性能**:整个链路零拷贝,支持 TCP/UDP/WSS/WG 协议 | ||||||
|  |  | ||||||
|     ```sh | ### 网络优化 | ||||||
|     cargo install easytier |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 3. **通过源码安装** | - 📊 **UDP 丢包抗性**:KCP/QUIC 代理在高丢包环境下优化延迟和带宽 | ||||||
|  | - 🔧 **Web 管理**:通过 Web 界面轻松配置和监控 | ||||||
|     ```sh | - 🛠️ **零配置**:静态链接的可执行文件,简单部署 | ||||||
|     cargo install --git https://github.com/EasyTier/EasyTier.git easytier |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 4. **通过Docker Compose安装** |  | ||||||
|  |  | ||||||
|     请访问 [EasyTier 官网](https://easytier.cn/) 以查看完整的文档。 |  | ||||||
|  |  | ||||||
| 5. **使用一键脚本安装 (仅适用于 Linux)** |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     wget -O /tmp/easytier.sh "https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh" && bash /tmp/easytier.sh install |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     脚本支持以下命令和选项: |  | ||||||
|  |  | ||||||
|     命令: |  | ||||||
|     - `install`: 安装 EasyTier |  | ||||||
|     - `uninstall`: 卸载 EasyTier |  | ||||||
|     - `update`: 更新 EasyTier 到最新版本 |  | ||||||
|     - `help`: 显示帮助信息 |  | ||||||
|  |  | ||||||
|     选项: |  | ||||||
|     - `--skip-folder-verify`: 跳过安装过程中的文件夹验证 |  | ||||||
|     - `--skip-folder-fix`: 跳过自动修复文件夹路径 |  | ||||||
|     - `--no-gh-proxy`: 禁用 GitHub 代理 |  | ||||||
|     - `--gh-proxy`: 设置自定义 GitHub 代理 URL (默认值: https://ghfast.top/) |  | ||||||
|  |  | ||||||
|     示例: |  | ||||||
|     ```sh |  | ||||||
|     # 查看帮助 |  | ||||||
|     bash /tmp/easytier.sh help |  | ||||||
|  |  | ||||||
|     # 安装(带选项) |  | ||||||
|     bash /tmp/easytier.sh install --skip-folder-verify |  | ||||||
|     bash /tmp/easytier.sh install --no-gh-proxy |  | ||||||
|     bash /tmp/easytier.sh install --gh-proxy https://your-proxy.com/ |  | ||||||
|  |  | ||||||
|     # 更新 EasyTier |  | ||||||
|     bash /tmp/easytier.sh update |  | ||||||
|  |  | ||||||
|     # 卸载 EasyTier |  | ||||||
|     bash /tmp/easytier.sh uninstall |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 6. **使用 Homebrew 安装 (仅适用于 MacOS)** |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     brew tap brewforge/chinese |  | ||||||
|     brew install --cask easytier-gui |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| ## 快速开始 | ## 快速开始 | ||||||
|  |  | ||||||
| > 下文仅描述命令行工具的使用,图形界面程序可参考下述概念自行配置。 | ### 📥 安装 | ||||||
|  |  | ||||||
| 确保已按照 [安装指南](#安装) 安装 EasyTier,并且 easytier-core 和 easytier-cli 两个命令都已经可用。 | 选择最适合您需求的安装方式: | ||||||
|  |  | ||||||
| ### 双节点组网 | ```bash | ||||||
|  | # 1. 下载预编译二进制文件(推荐,支持所有平台) | ||||||
|  | # 访问 https://github.com/EasyTier/EasyTier/releases | ||||||
|  |  | ||||||
| 假设双节点的网络拓扑如下 | # 2. 通过 cargo 安装(最新开发版本) | ||||||
|  | cargo install --git https://github.com/EasyTier/EasyTier.git easytier | ||||||
|  |  | ||||||
|  | # 3. 通过 Docker 安装 | ||||||
|  | # 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F | ||||||
|  |  | ||||||
|  | # 4. Linux 快速安装 | ||||||
|  | wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install | ||||||
|  |  | ||||||
|  | # 5. MacOS 通过 Homebrew 安装 | ||||||
|  | brew tap brewforge/chinese | ||||||
|  | brew install --cask easytier-gui | ||||||
|  |  | ||||||
|  | # 6. OpenWrt Luci Web 界面 | ||||||
|  | # 访问 https://github.com/EasyTier/luci-app-easytier | ||||||
|  |  | ||||||
|  | # 7.(可选)安装 Shell 补全功能: | ||||||
|  | # Fish 补全 | ||||||
|  | easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish | ||||||
|  | easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 🚀 基本用法 | ||||||
|  |  | ||||||
|  | #### 使用共享节点快速组网 | ||||||
|  |  | ||||||
|  | EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时,可以使用 EasyTier 社区提供的免费共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。 | ||||||
|  |  | ||||||
|  | 当前部署的共享公共节点是 `tcp://public.easytier.cn:11010`。 | ||||||
|  |  | ||||||
|  | 使用共享节点时,每个进入网络的节点需要提供相同的 `--network-name` 和 `--network-secret` 参数作为网络的唯一标识符。 | ||||||
|  |  | ||||||
|  | 以两个节点为例(请使用更复杂的网络名称以避免冲突): | ||||||
|  |  | ||||||
|  | 1. 在节点 A 上运行: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 以管理员权限运行 | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 2. 在节点 B 上运行: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 以管理员权限运行 | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 执行成功后,可以使用 `easytier-cli` 检查网络状态: | ||||||
|  |  | ||||||
|  | ```text | ||||||
|  | | ipv4         | hostname       | cost  | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id         | version         | | ||||||
|  | | ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- | | ||||||
|  | | 10.126.126.1 | abc-1          | Local | *      | *         | *        | *        | udp          | FullCone | 439804259  | 2.4.5-70e69a38~ | | ||||||
|  | | 10.126.126.2 | abc-2          | p2p   | 3.452  | 0         | 17.33 kB | 20.42 kB | udp          | FullCone | 390879727  | 2.4.5-70e69a38~ | | ||||||
|  | |              | PublicServer_a | p2p   | 27.796 | 0.000     | 50.01 kB | 67.46 kB | tcp          | Unknown  | 3771642457 | 2.4.5-70e69a38~ | | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 您可以测试节点之间的连通性: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 测试连通性 | ||||||
|  | ping 10.126.126.1 | ||||||
|  | ping 10.126.126.2 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 注意:如果无法 ping 通,可能是防火墙阻止了入站流量。请关闭防火墙或添加允许规则。 | ||||||
|  |  | ||||||
|  | 为了提高可用性,您可以同时连接多个共享节点: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 连接多个共享节点 | ||||||
|  | sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### 去中心化组网 | ||||||
|  |  | ||||||
|  | EasyTier 本质上是去中心化的,没有服务器和客户端的区分。只要一个设备能与虚拟网络中的任何节点通信,它就可以加入虚拟网络。以下是如何设置去中心化网络: | ||||||
|  |  | ||||||
|  | 1. 启动第一个节点(节点 A): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 启动第一个节点 | ||||||
|  | sudo easytier-core -i 10.144.144.1 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 启动后,该节点将默认监听以下端口: | ||||||
|  | - TCP:11010 | ||||||
|  | - UDP:11010 | ||||||
|  | - WebSocket:11011 | ||||||
|  | - WebSocket SSL:11012 | ||||||
|  | - WireGuard:11013 | ||||||
|  |  | ||||||
|  | 2. 连接第二个节点(节点 B): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 使用第一个节点的公网 IP 连接 | ||||||
|  | sudo easytier-core -i 10.144.144.2 -p udp://第一个节点的公网IP:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 3. 验证连接: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 测试连通性 | ||||||
|  | ping 10.144.144.2 | ||||||
|  |  | ||||||
|  | # 查看已连接的对等节点 | ||||||
|  | easytier-cli peer | ||||||
|  |  | ||||||
|  | # 查看路由信息 | ||||||
|  | easytier-cli route | ||||||
|  |  | ||||||
|  | # 查看本地节点信息 | ||||||
|  | easytier-cli node | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 更多节点要加入网络,可以使用 `-p` 参数连接到网络中的任何现有节点: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 使用任何现有节点的公网 IP 连接 | ||||||
|  | sudo easytier-core -i 10.144.144.3 -p udp://任何现有节点的公网IP:11010 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 🔍 高级功能 | ||||||
|  |  | ||||||
|  | #### 子网代理 | ||||||
|  |  | ||||||
|  | 假设网络拓扑如下,节点 B 想要与其他节点共享其可访问的子网 10.1.1.0/24: | ||||||
|  |  | ||||||
| ```mermaid | ```mermaid | ||||||
| flowchart LR | flowchart LR | ||||||
|  |  | ||||||
| subgraph 节点 A IP 22.1.1.1 | subgraph 节点 A 公网 IP 22.1.1.1 | ||||||
| nodea[EasyTier\n10.144.144.1] | nodea[EasyTier<br/>10.144.144.1] | ||||||
| end | end | ||||||
|  |  | ||||||
| subgraph 节点 B | subgraph 节点 B | ||||||
| nodeb[EasyTier\n10.144.144.2] | nodeb[EasyTier<br/>10.144.144.2] | ||||||
| end |  | ||||||
|  |  | ||||||
| nodea <-----> nodeb |  | ||||||
|  |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| 1. 在节点 A 上执行: |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     sudo easytier-core --ipv4 10.144.144.1 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     命令执行成功会有如下打印。 |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
| 2. 在节点 B 执行 |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     sudo easytier-core --ipv4 10.144.144.2 --peers udp://22.1.1.1:11010 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| 3. 测试联通性 |  | ||||||
|  |  | ||||||
|     两个节点应成功连接并能够在虚拟子网内通信 |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     ping 10.144.144.2 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     使用 easytier-cli 查看子网中的节点信息 |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli peer |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli route |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     easytier-cli node |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### 多节点组网 |  | ||||||
|  |  | ||||||
| 基于刚才的双节点组网例子,如果有更多的节点需要加入虚拟网络,可以使用如下命令。 |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| sudo easytier-core --ipv4 10.144.144.2 --peers udp://22.1.1.1:11010 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| 其中 `--peers` 参数可以填写任意一个已经在虚拟网络中的节点的监听地址。 |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### 子网代理(点对网)配置 |  | ||||||
|  |  | ||||||
| 假设网络拓扑如下,节点 B 想将其可访问的子网 10.1.1.0/24 共享给其他节点。 |  | ||||||
|  |  | ||||||
| ```mermaid |  | ||||||
| flowchart LR |  | ||||||
|  |  | ||||||
| subgraph 节点 A IP 22.1.1.1 |  | ||||||
| nodea[EasyTier\n10.144.144.1] |  | ||||||
| end |  | ||||||
|  |  | ||||||
| subgraph 节点 B |  | ||||||
| nodeb[EasyTier\n10.144.144.2] |  | ||||||
| end | end | ||||||
|  |  | ||||||
| id1[[10.1.1.0/24]] | id1[[10.1.1.0/24]] | ||||||
|  |  | ||||||
| nodea <--> nodeb <-.-> id1 | nodea <--> nodeb <-.-> id1 | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 则节点 B 的 easytier 启动参数为(新增 -n 参数) | 要共享子网,在启动 EasyTier 时添加 `-n` 参数: | ||||||
|  |  | ||||||
| ```sh | ```bash | ||||||
| sudo easytier-core --ipv4 10.144.144.2 -n 10.1.1.0/24 | # 与其他节点共享子网 10.1.1.0/24 | ||||||
|  | sudo easytier-core -i 10.144.144.2 -n 10.1.1.0/24 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 子网代理信息会自动同步到虚拟网络的每个节点,各个节点会自动配置相应的路由,节点 A 可以通过如下命令检查子网代理是否生效。 | 子网代理信息将自动同步到虚拟网络中的每个节点,每个节点将自动配置相应的路由。您可以验证子网代理设置: | ||||||
|  |  | ||||||
| 1. 检查路由信息是否已经同步,proxy_cidrs 列展示了被代理的子网。 | 1. 检查路由信息是否已同步(proxy_cidrs 列显示代理的子网): | ||||||
|  |  | ||||||
|     ```sh | ```bash | ||||||
|     easytier-cli route | # 查看路由信息 | ||||||
|     ``` | easytier-cli route | ||||||
|  |  | ||||||
|      |  | ||||||
|  |  | ||||||
| 2. 测试节点 A 是否可访问被代理子网下的节点 |  | ||||||
|  |  | ||||||
|     ```sh |  | ||||||
|     ping 10.1.1.2 |  | ||||||
|     ``` |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| ### 无公网IP组网 |  | ||||||
|  |  | ||||||
| EasyTier 支持共享公网节点进行组网。目前已部署共享的公网节点 ``tcp://public.easytier.cn:11010``。 |  | ||||||
|  |  | ||||||
| 使用共享节点时,需要每个入网节点提供相同的 ``--network-name`` 和 ``--network-secret`` 参数,作为网络的唯一标识。 |  | ||||||
|  |  | ||||||
| 以双节点为例,节点 A 执行: |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| sudo easytier-core -i 10.144.144.1 --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 节点 B 执行 |  | ||||||
|  |  | ||||||
| ```sh | 2. 测试是否可以访问代理子网中的节点: | ||||||
| sudo easytier-core --ipv4 10.144.144.2 --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 |  | ||||||
|  | ```bash | ||||||
|  | # 测试到代理子网的连通性 | ||||||
|  | ping 10.1.1.2 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 命令执行成功后,节点 A 即可通过虚拟 IP 10.144.144.2 访问节点 B。 | #### WireGuard 集成 | ||||||
|  |  | ||||||
| --- | EasyTier 可以作为 WireGuard 服务器,允许任何安装了 WireGuard 客户端的设备(包括 iOS 和 Android)访问 EasyTier 网络。以下是设置示例: | ||||||
|  |  | ||||||
| ### 使用 WireGuard 客户端接入 |  | ||||||
|  |  | ||||||
| EasyTier 可以用作 WireGuard 服务端,让任意安装了 WireGuard 客户端的设备访问 EasyTier 网络。对于目前 EasyTier 不支持的平台 (如 iOS、Android 等),可以使用这种方式接入 EasyTier 网络。 |  | ||||||
|  |  | ||||||
| 假设网络拓扑如下: |  | ||||||
|  |  | ||||||
| ```mermaid | ```mermaid | ||||||
| flowchart LR | flowchart LR | ||||||
|  |  | ||||||
| ios[[iPhone \n 安装 WireGuard]] | ios[[iPhone<br/>已安装 WireGuard]] | ||||||
|  |  | ||||||
| subgraph 节点 A IP 22.1.1.1 | subgraph 节点 A 公网 IP 22.1.1.1 | ||||||
| nodea[EasyTier\n10.144.144.1] | nodea[EasyTier<br/>10.144.144.1] | ||||||
| end | end | ||||||
|  |  | ||||||
| subgraph 节点 B | subgraph 节点 B | ||||||
| nodeb[EasyTier\n10.144.144.2] | nodeb[EasyTier<br/>10.144.144.2] | ||||||
| end | end | ||||||
|  |  | ||||||
| id1[[10.1.1.0/24]] | id1[[10.1.1.0/24]] | ||||||
| @@ -273,88 +245,81 @@ id1[[10.1.1.0/24]] | |||||||
| ios <-.-> nodea <--> nodeb <-.-> id1 | ios <-.-> nodea <--> nodeb <-.-> id1 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 我们需要 iPhone 通过节点 A 访问 EasyTier 网络,则可进行如下配置: | 1. 启动启用 WireGuard 门户的 EasyTier: | ||||||
|  |  | ||||||
| 在节点 A 的 easytier-core 命令中,加入 --vpn-portal 参数,指定 WireGuard 服务监听的端口,以及 WireGuard 网络使用的网段。 | ```bash | ||||||
|  | # 在 0.0.0.0:11013 上监听,并使用 10.14.14.0/24 子网作为 WireGuard 客户端 | ||||||
| ```sh | sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 | ||||||
| # 以下参数的含义为: 监听 0.0.0.0:11013 端口,WireGuard 使用 10.14.14.0/24 网段 |  | ||||||
| sudo easytier-core --ipv4 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| easytier-core 启动成功后,使用 easytier-cli 获取 WireGuard Client 的配置。 | 2. 获取 WireGuard 客户端配置: | ||||||
|  |  | ||||||
| ```sh | ```bash | ||||||
| $> easytier-cli vpn-portal | # 获取 WireGuard 客户端配置 | ||||||
| portal_name: wireguard | easytier-cli vpn-portal | ||||||
|  |  | ||||||
| ############### client_config_start ############### |  | ||||||
|  |  | ||||||
| [Interface] |  | ||||||
| PrivateKey = 9VDvlaIC9XHUvRuE06hD2CEDrtGF+0lDthgr9SZfIho= |  | ||||||
| Address = 10.14.14.0/32 # should assign an ip from this cidr manually |  | ||||||
|  |  | ||||||
| [Peer] |  | ||||||
| PublicKey = zhrZQg4QdPZs8CajT3r4fmzcNsWpBL9ImQCUsnlXyGM= |  | ||||||
| AllowedIPs = 10.144.144.0/24,10.14.14.0/24 |  | ||||||
| Endpoint = 0.0.0.0:11013 # should be the public ip(or domain) of the vpn server |  | ||||||
| PersistentKeepalive = 25 |  | ||||||
|  |  | ||||||
| ############### client_config_end ############### |  | ||||||
|  |  | ||||||
| connected_clients: |  | ||||||
| [] |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 使用 Client Config 前,需要将 Interface Address 和 Peer Endpoint 分别修改为客户端的 IP 和 EasyTier 节点的 IP。将配置文件导入 WireGuard 客户端,即可访问 EasyTier 网络。 | 3. 在输出配置中: | ||||||
|  |    - 将 `Interface.Address` 设置为 WireGuard 子网中的可用 IP | ||||||
|  |    - 将 `Peer.Endpoint` 设置为您的 EasyTier 节点的公网 IP/域名 | ||||||
|  |    - 将修改后的配置导入到您的 WireGuard 客户端 | ||||||
|  |  | ||||||
| --- | #### 自建公共共享节点 | ||||||
|  |  | ||||||
| ### 自建公共中转服务器 | 您可以运行自己的公共共享节点来帮助其他节点相互发现。公共共享节点只是一个普通的 EasyTier 网络(具有相同的网络名称和密钥),其他网络可以连接到它。 | ||||||
|  |  | ||||||
| 每个虚拟网络(通过相同的网络名称和密钥建链)都可以充当公共服务器集群。其他网络的节点可以连接到公共服务器集群中的任意节点,无需公共 IP 即可发现彼此。 | 要运行公共共享节点: | ||||||
|  |  | ||||||
| 运行自建的公共服务器集群与运行虚拟网络完全相同,不过可以跳过配置 ipv4 地址。 | ```bash | ||||||
|  | # 公共共享节点无需指定 IPv4 地址 | ||||||
| 也可以使用以下命令加入官方公共服务器集群,后续将实现公共服务器集群的节点间负载均衡: | sudo easytier-core --network-name mysharednode --network-secret mysharednode | ||||||
|  |  | ||||||
| ``` |  | ||||||
| sudo easytier-core --network-name easytier --network-secret easytier -p tcp://public.easytier.cn:11010 |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ### 其他配置 | 网络设置成功后,您可以轻松配置它以在系统启动时自动启动。请参阅 [一键注册服务指南](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) 了解如何将 EasyTier 注册为系统服务。 | ||||||
|  |  | ||||||
| 可使用 ``easytier-core --help`` 查看全部配置项 | ## 相关项目 | ||||||
|  |  | ||||||
| ## 路线图 | - [ZeroTier](https://www.zerotier.com/):用于连接设备的全球虚拟网络。 | ||||||
|  | - [TailScale](https://tailscale.com/):旨在简化网络配置的 VPN 解决方案。 | ||||||
|  | - [vpncloud](https://github.com/dswd/vpncloud):一个 P2P 网状 VPN | ||||||
|  | - [Candy](https://github.com/lanthora/candy):一个可靠、低延迟、反审查的虚拟专用网络 | ||||||
|  |  | ||||||
| - [ ] 完善文档和用户指南。 | ### 联系我们 | ||||||
| - [ ] 支持 TCP 打洞、KCP、FEC 等特性。 |  | ||||||
| - [ ] 支持 iOS。 |  | ||||||
|  |  | ||||||
| ## 社区和贡献 | - 💬 **[Telegram 群组](https://t.me/easytier)** | ||||||
|  | - 👥 **QQ 群** | ||||||
| 我们欢迎并鼓励社区贡献!如果你想参与进来,请提交 [GitHub PR](https://github.com/EasyTier/EasyTier/pulls)。详细的贡献指南可以在 [CONTRIBUTING.md](https://github.com/EasyTier/EasyTier/blob/main/CONTRIBUTING.md) 中找到。 |   - 一群 [949700262](https://qm.qq.com/q/wFoTUChqZW) | ||||||
|  |   - 二群 [837676408](https://qm.qq.com/q/4V33DrfgHe) | ||||||
| ## 相关项目和资源 |   - 三群 [957189589](https://qm.qq.com/q/YNyTQjwlai) | ||||||
|  |  | ||||||
| - [ZeroTier](https://www.zerotier.com/): 一个全球虚拟网络,用于连接设备。 |  | ||||||
| - [TailScale](https://tailscale.com/): 一个旨在简化网络配置的 VPN 解决方案。 |  | ||||||
| - [vpncloud](https://github.com/dswd/vpncloud): 一个 P2P Mesh VPN |  | ||||||
| - [Candy](https://github.com/lanthora/candy): 可靠、低延迟、抗审查的虚拟专用网络 |  | ||||||
|  |  | ||||||
| ## 许可证 | ## 许可证 | ||||||
|  |  | ||||||
| EasyTier 根据 [Apache License 2.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可证发布。 | EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可下发布。 | ||||||
|  |  | ||||||
| ## 联系方式 |  | ||||||
|  |  | ||||||
| - 提问或报告问题:[GitHub Issues](https://github.com/EasyTier/EasyTier/issues) |  | ||||||
| - 讨论和交流:[GitHub Discussions](https://github.com/EasyTier/EasyTier/discussions) |  | ||||||
| - QQ 群: 949700262 |  | ||||||
| - Telegram:https://t.me/easytier |  | ||||||
|  |  | ||||||
| ## 赞助 | ## 赞助 | ||||||
|  |  | ||||||
| <img src="assets/image-8.png" width="300"> | 本项目的 CDN 加速和安全防护由腾讯云 EdgeOne 赞助。 | ||||||
| <img src="assets/image-9.png" width="300"> |  | ||||||
|  | <p align="center"> | ||||||
|  | <a href="https://edgeone.ai/?from=github" target="_blank"> | ||||||
|  | <img src="assets/edgeone.png" width="200"> | ||||||
|  | </a> | ||||||
|  | </p> | ||||||
|  |  | ||||||
|  | 特别感谢 [浪浪云](https://langlangy.cn/?i26c5a5) 和 [雨云](https://www.rainyun.com/NjM0NzQ1_) 赞助我们的公共服务器。 | ||||||
|  |  | ||||||
|  | <p align="center"> | ||||||
|  | <a href="https://langlangy.cn/?i26c5a5" target="_blank"> | ||||||
|  | <img src="assets/langlang.png" width="200"> | ||||||
|  | </a> | ||||||
|  | <a href="https://langlangy.cn/?i26c5a5" target="_blank"> | ||||||
|  | <img src="assets/raincloud.png" width="200"> | ||||||
|  | </a> | ||||||
|  | </p> | ||||||
|  |  | ||||||
|  | 如果您觉得 EasyTier 有帮助,请考虑赞助我们。软件开发和维护需要大量的时间和精力,您的赞助将帮助我们更好地维护和改进 EasyTier。 | ||||||
|  |  | ||||||
|  | <p align="center"> | ||||||
|  | <img src="assets/wechat.png" width="200"> | ||||||
|  | <img src="assets/alipay.png" width="200"> | ||||||
|  | </p> | ||||||
|   | |||||||
							
								
								
									
										116
									
								
								android.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,116 @@ | |||||||
|  | # Android build environment | ||||||
|  | { | ||||||
|  |   pkgs, | ||||||
|  |   nixpkgs, | ||||||
|  |   system, | ||||||
|  | }: | ||||||
|  |  | ||||||
|  | let | ||||||
|  |   androidEnv = pkgs.callPackage "${nixpkgs}/pkgs/development/mobile/androidenv" { | ||||||
|  |     inherit pkgs; | ||||||
|  |     licenseAccepted = true; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   includeAuto = pkgs.stdenv.hostPlatform.isx86_64 || pkgs.stdenv.hostPlatform.isDarwin; | ||||||
|  |   ndkVersion = "26.1.10909125"; | ||||||
|  |   ndkVersions = [ ndkVersion ]; | ||||||
|  |  | ||||||
|  |   sdkArgs = { | ||||||
|  |     includeNDK = true; | ||||||
|  |     includeSources = true; | ||||||
|  |     includeSystemImages = false; | ||||||
|  |     includeEmulator = false; | ||||||
|  |     inherit ndkVersions; | ||||||
|  |     useGoogleAPIs = true; | ||||||
|  |     useGoogleTVAddOns = true; | ||||||
|  |     buildToolsVersions = [ "34.0.0" ]; | ||||||
|  |     numLatestPlatformVersions = 10; | ||||||
|  |     includeExtras = [ | ||||||
|  |       "extras;google;gcm" | ||||||
|  |     ] | ||||||
|  |     ++ pkgs.lib.optionals includeAuto [ | ||||||
|  |       "extras;google;auto" | ||||||
|  |     ]; | ||||||
|  |     extraLicenses = [ | ||||||
|  |       "android-sdk-preview-license" | ||||||
|  |       "android-googletv-license" | ||||||
|  |       "android-sdk-arm-dbt-license" | ||||||
|  |       "google-gdk-license" | ||||||
|  |       "intel-android-extra-license" | ||||||
|  |       "intel-android-sysimage-license" | ||||||
|  |       "mips-android-sysimage-license" | ||||||
|  |     ]; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   androidComposition = androidEnv.composeAndroidPackages sdkArgs; | ||||||
|  |   androidSdk = androidComposition.androidsdk; | ||||||
|  |   platformTools = androidComposition.platform-tools; | ||||||
|  |   cmake = androidComposition.cmake; | ||||||
|  |   ndkHostTag = | ||||||
|  |     if pkgs.stdenv.isLinux then | ||||||
|  |       "linux-x86_64" | ||||||
|  |     else if pkgs.stdenv.isDarwin then | ||||||
|  |       "darwin-x86_64" | ||||||
|  |     else | ||||||
|  |       ""; | ||||||
|  |   ndkToolchain = "${androidSdk}/libexec/android-sdk/ndk/${ndkVersion}/toolchains/llvm/prebuilt/${ndkHostTag}"; | ||||||
|  | in | ||||||
|  | { | ||||||
|  |   inherit | ||||||
|  |     androidSdk | ||||||
|  |     platformTools | ||||||
|  |     cmake | ||||||
|  |     ndkToolchain | ||||||
|  |     ndkVersion | ||||||
|  |     ; | ||||||
|  |  | ||||||
|  |   # List of packages required for Android development | ||||||
|  |   packages = [ | ||||||
|  |     pkgs.jdk # openjdk 21 | ||||||
|  |     androidSdk | ||||||
|  |     platformTools | ||||||
|  |     cmake | ||||||
|  |     pkgs.glibc_multi.dev | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   # Provide Rust extensions/targets for use by the upper-level flake | ||||||
|  |   rust = { | ||||||
|  |     extensions = [ "rust-std" ]; | ||||||
|  |     targets = [ | ||||||
|  |       "aarch64-linux-android" | ||||||
|  |       "armv7-linux-androideabi" | ||||||
|  |       "i686-linux-android" | ||||||
|  |       "x86_64-linux-android" | ||||||
|  |     ]; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   # Android environment variables and shellHook | ||||||
|  |   envVars = { | ||||||
|  |     LANG = "C.UTF-8"; | ||||||
|  |     LC_ALL = "C.UTF-8"; | ||||||
|  |     JAVA_HOME = "${pkgs.jdk}/lib/openjdk"; | ||||||
|  |     ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk"; | ||||||
|  |     ANDROID_NDK_ROOT = "\${ANDROID_SDK_ROOT}/ndk-bundle"; | ||||||
|  |     NDK_HOME = "${androidSdk}/libexec/android-sdk/ndk/${ndkVersion}"; | ||||||
|  |     LIBCLANG_PATH = "${ndkToolchain}/lib"; | ||||||
|  |     KCP_SYS_EXTRA_HEADER_PATH = "${ndkToolchain}/lib/clang/19/include:${pkgs.glibc_multi.dev}/include"; | ||||||
|  |     ZSTD_SYS_STATIC = "1"; | ||||||
|  |     BINDGEN_EXTRA_CLANG_ARGS = "--sysroot=${ndkToolchain}/sysroot -I${ndkToolchain}/lib/clang/17/include "; | ||||||
|  |  | ||||||
|  |     shellHook = '' | ||||||
|  |       echo "Android environment activated" | ||||||
|  |       export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$(echo "$ANDROID_SDK_ROOT/build-tools/"*"/aapt2")" | ||||||
|  |       cmake_root="$(echo "$ANDROID_SDK_ROOT/cmake/"*/)" | ||||||
|  |       export PATH="$cmake_root/bin:$PATH" | ||||||
|  |  | ||||||
|  |       unset NIX_CFLAGS_COMPILE | ||||||
|  |       unset NIX_CFLAGS_COMPILE_FOR_BUILD | ||||||
|  |  | ||||||
|  |       cat <<EOF > easytier-gui/local.properties | ||||||
|  |       sdk.dir=$ANDROID_SDK_ROOT | ||||||
|  |       ndk.dir=$ANDROID_NDK_ROOT | ||||||
|  |       cmake.dir=$cmake_root | ||||||
|  |       EOF | ||||||
|  |     ''; | ||||||
|  |   }; | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								assets/alipay.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/config-page.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 92 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/edgeone.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 46 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/langlang.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/raincloud.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 37 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/running-page.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 138 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/wechat.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.0 KiB | 
							
								
								
									
										16
									
								
								easytier-contrib/easytier-android-jni/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | |||||||
|  | [package] | ||||||
|  | name = "easytier-android-jni" | ||||||
|  | version = "0.1.0" | ||||||
|  | edition = "2021" | ||||||
|  |  | ||||||
|  | [lib] | ||||||
|  | crate-type = ["cdylib"] | ||||||
|  |  | ||||||
|  | [dependencies] | ||||||
|  | jni = "0.21" | ||||||
|  | once_cell = "1.18.0" | ||||||
|  | log = "0.4" | ||||||
|  | android_logger = "0.13" | ||||||
|  | serde = { version = "1.0.220", features = ["derive"] } | ||||||
|  | serde_json = "1.0" | ||||||
|  | easytier = { path = "../../easytier" } | ||||||
							
								
								
									
										267
									
								
								easytier-contrib/easytier-android-jni/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,267 @@ | |||||||
|  | # EasyTier Android JNI | ||||||
|  |  | ||||||
|  | 这是 EasyTier 的 Android JNI 绑定库,允许 Android 应用程序调用 EasyTier 的网络功能。 | ||||||
|  |  | ||||||
|  | ## 功能特性 | ||||||
|  |  | ||||||
|  | - 🚀 完整的 EasyTier FFI 接口封装 | ||||||
|  | - 📱 原生 Android JNI 支持 | ||||||
|  | - 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64) | ||||||
|  | - 🛡️ 类型安全的 Java 接口 | ||||||
|  | - 📝 详细的错误处理和日志记录 | ||||||
|  |  | ||||||
|  | ## 支持的架构 | ||||||
|  |  | ||||||
|  | - `arm64-v8a` (aarch64-linux-android) | ||||||
|  | - `armeabi-v7a` (armv7-linux-androideabi) | ||||||
|  | - `x86` (i686-linux-android) | ||||||
|  | - `x86_64` (x86_64-linux-android) | ||||||
|  |  | ||||||
|  | ## 构建要求 | ||||||
|  |  | ||||||
|  | ### 系统要求 | ||||||
|  |  | ||||||
|  | - Rust 1.70+ | ||||||
|  | - Android NDK r21+ | ||||||
|  | - Linux/macOS 开发环境 | ||||||
|  |  | ||||||
|  | ### 环境设置 | ||||||
|  |  | ||||||
|  | 1. **安装 Rust** | ||||||
|  |    ```bash | ||||||
|  |    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh | ||||||
|  |    source ~/.cargo/env | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **安装 Android NDK** | ||||||
|  |    - 下载 Android NDK: https://developer.android.com/ndk/downloads | ||||||
|  |    - 解压到合适的目录 | ||||||
|  |    - 设置环境变量: | ||||||
|  |      ```bash | ||||||
|  |      export ANDROID_NDK_ROOT=/path/to/android-ndk | ||||||
|  |      ``` | ||||||
|  |  | ||||||
|  | 3. **添加 Android 目标** | ||||||
|  |    ```bash | ||||||
|  |    rustup target add aarch64-linux-android | ||||||
|  |    rustup target add armv7-linux-androideabi | ||||||
|  |    rustup target add i686-linux-android | ||||||
|  |    rustup target add x86_64-linux-android | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## 构建步骤 | ||||||
|  |  | ||||||
|  | 1. **克隆项目并进入目录** | ||||||
|  |    ```bash | ||||||
|  |    cd /path/to/EasyTier/easytier-contrib/easytier-android-jni | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **运行构建脚本** | ||||||
|  |    ```bash | ||||||
|  |    ./build.sh | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **构建完成后,库文件将生成在 `target/android/` 目录下** | ||||||
|  |    ``` | ||||||
|  |    target/android/ | ||||||
|  |    ├── arm64-v8a/ | ||||||
|  |    │   └── libeasytier_android_jni.so | ||||||
|  |    ├── armeabi-v7a/ | ||||||
|  |    │   └── libeasytier_android_jni.so | ||||||
|  |    ├── x86/ | ||||||
|  |    │   └── libeasytier_android_jni.so | ||||||
|  |    └── x86_64/ | ||||||
|  |        └── libeasytier_android_jni.so | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## Android 项目集成 | ||||||
|  |  | ||||||
|  | ### 1. 复制库文件 | ||||||
|  |  | ||||||
|  | 将生成的 `.so` 文件复制到您的 Android 项目中: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | your-android-project/ | ||||||
|  | └── src/main/ | ||||||
|  |     ├── jniLibs/ | ||||||
|  |     │   ├── arm64-v8a/ | ||||||
|  |     │   │   └── libeasytier_android_jni.so | ||||||
|  |     │   ├── armeabi-v7a/ | ||||||
|  |     │   │   └── libeasytier_android_jni.so | ||||||
|  |     │   ├── x86/ | ||||||
|  |     │   │   └── libeasytier_android_jni.so | ||||||
|  |     │   └── x86_64/ | ||||||
|  |     │       └── libeasytier_android_jni.so | ||||||
|  |     └── java/ | ||||||
|  |         └── com/easytier/jni/ | ||||||
|  |             └── EasyTierJNI.java | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 2. 复制 Java 接口 | ||||||
|  |  | ||||||
|  | 将 `java/com/easytier/jni/EasyTierJNI.java` 复制到您的 Android 项目的相应包路径下。 | ||||||
|  |  | ||||||
|  | ### 3. 添加权限 | ||||||
|  |  | ||||||
|  | 在 `AndroidManifest.xml` 中添加必要的权限: | ||||||
|  |  | ||||||
|  | ```xml | ||||||
|  | <uses-permission android:name="android.permission.INTERNET" /> | ||||||
|  | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | ||||||
|  | <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 使用示例 | ||||||
|  |  | ||||||
|  | ### 基本使用 | ||||||
|  |  | ||||||
|  | ```java | ||||||
|  | import com.easytier.jni.EasyTierJNI; | ||||||
|  | import java.util.Map; | ||||||
|  |  | ||||||
|  | public class EasyTierManager { | ||||||
|  |      | ||||||
|  |     // 初始化网络实例 | ||||||
|  |     public void startNetwork() { | ||||||
|  |         String config = """ | ||||||
|  |             inst_name = "my_instance" | ||||||
|  |             network = "my_network" | ||||||
|  |             """;  | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             // 解析配置 | ||||||
|  |             int result = EasyTierJNI.parseConfig(config); | ||||||
|  |             if (result != 0) { | ||||||
|  |                 String error = EasyTierJNI.getLastError(); | ||||||
|  |                 throw new RuntimeException("配置解析失败: " + error); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 启动网络实例 | ||||||
|  |             result = EasyTierJNI.runNetworkInstance(config); | ||||||
|  |             if (result != 0) { | ||||||
|  |                 String error = EasyTierJNI.getLastError(); | ||||||
|  |                 throw new RuntimeException("网络实例启动失败: " + error); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             System.out.println("EasyTier 网络实例启动成功"); | ||||||
|  |              | ||||||
|  |         } catch (RuntimeException e) { | ||||||
|  |             System.err.println("启动失败: " + e.getMessage()); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 获取网络信息 | ||||||
|  |     public void getNetworkInfo() { | ||||||
|  |         try { | ||||||
|  |             Map<String, String> infos = EasyTierJNI.collectNetworkInfosAsMap(10); | ||||||
|  |             for (Map.Entry<String, String> entry : infos.entrySet()) { | ||||||
|  |                 System.out.println(entry.getKey() + ": " + entry.getValue()); | ||||||
|  |             } | ||||||
|  |         } catch (RuntimeException e) { | ||||||
|  |             System.err.println("获取网络信息失败: " + e.getMessage()); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 停止所有实例 | ||||||
|  |     public void stopNetwork() { | ||||||
|  |         try { | ||||||
|  |             int result = EasyTierJNI.stopAllInstances(); | ||||||
|  |             if (result == 0) { | ||||||
|  |                 System.out.println("所有网络实例已停止"); | ||||||
|  |             } | ||||||
|  |         } catch (RuntimeException e) { | ||||||
|  |             System.err.println("停止网络失败: " + e.getMessage()); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### VPN 服务集成 | ||||||
|  |  | ||||||
|  | 如果您要在 Android VPN 服务中使用: | ||||||
|  |  | ||||||
|  | ```java | ||||||
|  | public class EasyTierVpnService extends VpnService { | ||||||
|  |      | ||||||
|  |     @Override | ||||||
|  |     public int onStartCommand(Intent intent, int flags, int startId) { | ||||||
|  |         // 建立 VPN 连接 | ||||||
|  |         ParcelFileDescriptor vpnInterface = establishVpnInterface(); | ||||||
|  |          | ||||||
|  |         if (vpnInterface != null) { | ||||||
|  |             int fd = vpnInterface.getFd(); | ||||||
|  |              | ||||||
|  |             // 设置 TUN 文件描述符 | ||||||
|  |             try { | ||||||
|  |                 EasyTierJNI.setTunFd("my_instance", fd); | ||||||
|  |             } catch (RuntimeException e) { | ||||||
|  |                 Log.e("EasyTier", "设置 TUN FD 失败", e); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         return START_STICKY; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     private ParcelFileDescriptor establishVpnInterface() { | ||||||
|  |         Builder builder = new Builder(); | ||||||
|  |         builder.setMtu(1500); | ||||||
|  |         builder.addAddress("10.0.0.2", 24); | ||||||
|  |         builder.addRoute("0.0.0.0", 0); | ||||||
|  |         builder.setSession("EasyTier VPN"); | ||||||
|  |          | ||||||
|  |         return builder.establish(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## API 参考 | ||||||
|  |  | ||||||
|  | ### EasyTierJNI 类方法 | ||||||
|  |  | ||||||
|  | | 方法 | 描述 | 参数 | 返回值 | | ||||||
|  | |------|------|------|--------| | ||||||
|  | | `parseConfig(String config)` | 解析 TOML 配置 | config: 配置字符串 | 0=成功, -1=失败 | | ||||||
|  | | `runNetworkInstance(String config)` | 启动网络实例 | config: 配置字符串 | 0=成功, -1=失败 | | ||||||
|  | | `setTunFd(String instanceName, int fd)` | 设置 TUN 文件描述符 | instanceName: 实例名, fd: 文件描述符 | 0=成功, -1=失败 | | ||||||
|  | | `retainNetworkInstance(String[] names)` | 保留指定实例 | names: 实例名数组 | 0=成功, -1=失败 | | ||||||
|  | | `collectNetworkInfos(int maxLength)` | 收集网络信息 | maxLength: 最大条目数 | 信息字符串数组 | | ||||||
|  | | `collectNetworkInfosAsMap(int maxLength)` | 收集网络信息为 Map | maxLength: 最大条目数 | Map<String, String> | | ||||||
|  | | `getLastError()` | 获取最后错误 | 无 | 错误消息字符串 | | ||||||
|  | | `stopAllInstances()` | 停止所有实例 | 无 | 0=成功, -1=失败 | | ||||||
|  | | `retainSingleInstance(String name)` | 保留单个实例 | name: 实例名 | 0=成功, -1=失败 | | ||||||
|  |  | ||||||
|  | ## 故障排除 | ||||||
|  |  | ||||||
|  | ### 常见问题 | ||||||
|  |  | ||||||
|  | 1. **构建失败: "Android NDK not found"** | ||||||
|  |    - 确保设置了 `ANDROID_NDK_ROOT` 环境变量 | ||||||
|  |    - 检查 NDK 路径是否正确 | ||||||
|  |  | ||||||
|  | 2. **运行时错误: "java.lang.UnsatisfiedLinkError"** | ||||||
|  |    - 确保 `.so` 文件放在正确的 `jniLibs` 目录下 | ||||||
|  |    - 检查目标架构是否匹配 | ||||||
|  |  | ||||||
|  | 3. **配置解析失败** | ||||||
|  |    - 检查 TOML 配置格式是否正确 | ||||||
|  |    - 使用 `getLastError()` 获取详细错误信息 | ||||||
|  |  | ||||||
|  | ### 调试技巧 | ||||||
|  |  | ||||||
|  | - 启用 Android 日志查看 JNI 层的日志输出 | ||||||
|  | - 使用 `adb logcat -s EasyTier-JNI` 查看相关日志 | ||||||
|  | - 检查 `getLastError()` 返回的错误信息 | ||||||
|  |  | ||||||
|  | ## 许可证 | ||||||
|  |  | ||||||
|  | 本项目遵循与 EasyTier 主项目相同的许可证。 | ||||||
|  |  | ||||||
|  | ## 贡献 | ||||||
|  |  | ||||||
|  | 欢迎提交 Issue 和 Pull Request 来改进这个项目。 | ||||||
|  |  | ||||||
|  | ## 相关链接 | ||||||
|  |  | ||||||
|  | - [EasyTier 主项目](https://github.com/EasyTier/EasyTier) | ||||||
|  | - [Android NDK 文档](https://developer.android.com/ndk) | ||||||
|  | - [Rust JNI 文档](https://docs.rs/jni/) | ||||||
							
								
								
									
										129
									
								
								easytier-contrib/easytier-android-jni/build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,129 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | # EasyTier Android JNI 构建脚本 | ||||||
|  | # 用于编译适用于 Android 平台的 JNI 库 | ||||||
|  | # 使用 cargo-ndk 工具简化 Android 编译过程 | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | # 颜色输出 | ||||||
|  | RED='\033[0;31m' | ||||||
|  | GREEN='\033[0;32m' | ||||||
|  | YELLOW='\033[1;33m' | ||||||
|  | NC='\033[0m' # No Color | ||||||
|  |  | ||||||
|  | REPO_ROOT=$(git rev-parse --show-toplevel) | ||||||
|  |  | ||||||
|  | echo -e "${GREEN}EasyTier Android JNI 构建脚本 (使用 cargo-ndk)${NC}" | ||||||
|  | echo "==============================================" | ||||||
|  |  | ||||||
|  | # 检查 Rust 是否安装 | ||||||
|  | if ! command -v rustc &> /dev/null; then | ||||||
|  |     echo -e "${RED}错误: 未找到 Rust 编译器,请先安装 Rust${NC}" | ||||||
|  |     exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # 检查 cargo 是否安装 | ||||||
|  | if ! command -v cargo &> /dev/null; then | ||||||
|  |     echo -e "${RED}错误: 未找到 Cargo,请先安装 Rust 工具链${NC}" | ||||||
|  |     exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # 检查 cargo-ndk 是否安装 | ||||||
|  | if ! cargo ndk --version &> /dev/null; then | ||||||
|  |     echo -e "${YELLOW}cargo-ndk 未安装,正在安装...${NC}" | ||||||
|  |     cargo install cargo-ndk | ||||||
|  |     if ! cargo ndk --version &> /dev/null; then | ||||||
|  |         echo -e "${RED}错误: cargo-ndk 安装失败${NC}" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | echo -e "${GREEN}cargo-ndk 版本: $(cargo ndk --version)${NC}" | ||||||
|  |  | ||||||
|  | # Android 目标架构映射 (cargo-ndk 使用的架构名称) | ||||||
|  | # ANDROID_TARGETS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64") | ||||||
|  | ANDROID_TARGETS=("arm64-v8a") | ||||||
|  |  | ||||||
|  | # Android 架构到 Rust target 的映射 | ||||||
|  | declare -A TARGET_MAP | ||||||
|  | TARGET_MAP["arm64-v8a"]="aarch64-linux-android" | ||||||
|  | TARGET_MAP["armeabi-v7a"]="armv7-linux-androideabi" | ||||||
|  | TARGET_MAP["x86"]="i686-linux-android" | ||||||
|  | TARGET_MAP["x86_64"]="x86_64-linux-android" | ||||||
|  |  | ||||||
|  | # 检查并安装所需的 Rust target | ||||||
|  | echo -e "${YELLOW}检查并安装 Android 目标架构...${NC}" | ||||||
|  | for android_target in "${ANDROID_TARGETS[@]}"; do | ||||||
|  |     rust_target="${TARGET_MAP[$android_target]}" | ||||||
|  |     if ! rustup target list --installed | grep -q "$rust_target"; then | ||||||
|  |         echo -e "${YELLOW}安装目标架构: $rust_target (for $android_target)${NC}" | ||||||
|  |         rustup target add "$rust_target" | ||||||
|  |     else | ||||||
|  |         echo -e "${GREEN}目标架构已安装: $rust_target (for $android_target)${NC}" | ||||||
|  |     fi | ||||||
|  | done | ||||||
|  |  | ||||||
|  | # 创建输出目录 | ||||||
|  | OUTPUT_DIR="./target/android" | ||||||
|  | mkdir -p "$OUTPUT_DIR" | ||||||
|  |  | ||||||
|  | # 构建函数 | ||||||
|  | build_for_target() { | ||||||
|  |     local android_target=$1 | ||||||
|  |     echo -e "${YELLOW}构建目标: $android_target${NC}" | ||||||
|  |      | ||||||
|  |     # 首先构建 easytier-ffi | ||||||
|  |     echo -e "${YELLOW}构建 easytier-ffi for $android_target${NC}" | ||||||
|  |     (cd $REPO_ROOT/easytier-contrib/easytier-ffi && cargo ndk -t $android_target build --release) | ||||||
|  |      | ||||||
|  |     # 构建 JNI 库 | ||||||
|  |     cargo ndk -t $android_target build --release | ||||||
|  |      | ||||||
|  |     # 复制库文件到输出目录 | ||||||
|  |     # cargo-ndk 使用 Rust target 名称作为目录名,而不是 Android 架构名称 | ||||||
|  |     rust_target="${TARGET_MAP[$android_target]}" | ||||||
|  |     mkdir -p "$OUTPUT_DIR/$android_target" | ||||||
|  |     cp "$REPO_ROOT/target/$rust_target/release/libeasytier_android_jni.so" "$OUTPUT_DIR/$android_target/" | ||||||
|  |     cp "$REPO_ROOT/target/$rust_target/release/libeasytier_ffi.so" "$OUTPUT_DIR/$android_target/" | ||||||
|  |     echo -e "${GREEN}库文件已复制到: $OUTPUT_DIR/$android_target/${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # 检查 Android NDK (cargo-ndk 会自动处理 NDK 路径) | ||||||
|  | if [ -z "$ANDROID_NDK_ROOT" ] && [ -z "$ANDROID_NDK_HOME" ] && [ -z "$NDK_HOME" ]; then | ||||||
|  |     echo -e "${YELLOW}警告: 未设置 Android NDK 环境变量${NC}" | ||||||
|  |     echo "cargo-ndk 将尝试自动检测 NDK 路径" | ||||||
|  |     echo "如果构建失败,请设置以下环境变量之一:" | ||||||
|  |     echo "  - ANDROID_NDK_ROOT" | ||||||
|  |     echo "  - ANDROID_NDK_HOME"  | ||||||
|  |     echo "  - NDK_HOME" | ||||||
|  | else | ||||||
|  |     if [ -n "$ANDROID_NDK_ROOT" ]; then | ||||||
|  |         echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_ROOT${NC}" | ||||||
|  |     elif [ -n "$ANDROID_NDK_HOME" ]; then | ||||||
|  |         echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_HOME${NC}" | ||||||
|  |     elif [ -n "$NDK_HOME" ]; then | ||||||
|  |         echo -e "${GREEN}使用 Android NDK: $NDK_HOME${NC}" | ||||||
|  |     fi | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # 构建所有目标 | ||||||
|  | echo -e "${YELLOW}开始构建所有目标架构...${NC}" | ||||||
|  | for target in "${ANDROID_TARGETS[@]}"; do | ||||||
|  |     build_for_target "$target" | ||||||
|  | done | ||||||
|  |  | ||||||
|  | echo -e "${GREEN}构建完成!${NC}" | ||||||
|  | echo -e "${GREEN}所有库文件已生成到: $OUTPUT_DIR${NC}" | ||||||
|  | echo "" | ||||||
|  | echo "目录结构:" | ||||||
|  | ls -la "$OUTPUT_DIR"/*/ | ||||||
|  |  | ||||||
|  | echo "" | ||||||
|  | echo -e "${YELLOW}使用说明:${NC}" | ||||||
|  | echo "1. 将生成的 .so 文件复制到您的 Android 项目的 src/main/jniLibs/ 目录下" | ||||||
|  | echo "2. 将 java/com/easytier/jni/EasyTierJNI.java 复制到您的 Android 项目中" | ||||||
|  | echo "3. 在您的 Android 代码中调用 EasyTierJNI 类的方法" | ||||||
|  | echo "" | ||||||
|  | echo -e "${GREEN}注意: 此脚本使用 cargo-ndk 工具,无需手动设置复杂的环境变量${NC}" | ||||||
|  | echo -e "${GREEN}cargo-ndk 会自动处理交叉编译所需的工具链配置${NC}" | ||||||
							
								
								
									
										56
									
								
								easytier-contrib/easytier-android-jni/example_config.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,56 @@ | |||||||
|  | # EasyTier Android JNI 示例配置文件 | ||||||
|  | # 这是一个基本的配置示例,展示如何配置 EasyTier 网络实例 | ||||||
|  |  | ||||||
|  | # 实例名称 (必需) | ||||||
|  | inst_name = "android_instance" | ||||||
|  |  | ||||||
|  | # 网络名称 (必需) | ||||||
|  | network = "my_easytier_network" | ||||||
|  |  | ||||||
|  | # 网络密钥 (可选,用于网络加密) | ||||||
|  | # network_secret = "your_secret_key_here" | ||||||
|  |  | ||||||
|  | # 监听地址 (可选) | ||||||
|  | # listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"] | ||||||
|  |  | ||||||
|  | # 对等节点地址 (可选) | ||||||
|  | # peers = ["tcp://peer1.example.com:11010", "udp://peer2.example.com:11010"] | ||||||
|  |  | ||||||
|  | # 虚拟 IP 地址 (可选) | ||||||
|  | # ipv4 = "10.144.144.1" | ||||||
|  |  | ||||||
|  | # 主机名 (可选) | ||||||
|  | # hostname = "android-device" | ||||||
|  |  | ||||||
|  | # 启用 IPv6 (可选) | ||||||
|  | # ipv6 = "fd00::1" | ||||||
|  |  | ||||||
|  | # 代理网络 (可选) | ||||||
|  | # proxy_networks = ["192.168.1.0/24"] | ||||||
|  |  | ||||||
|  | # 退出节点 (可选) | ||||||
|  | # exit_nodes = ["peer1"] | ||||||
|  |  | ||||||
|  | # 启用加密 (可选) | ||||||
|  | # enable_encryption = true | ||||||
|  |  | ||||||
|  | # 启用 IPv4 转发 (可选) | ||||||
|  | # enable_ipv4 = true | ||||||
|  |  | ||||||
|  | # 启用 IPv6 转发 (可选) | ||||||
|  | # enable_ipv6 = false | ||||||
|  |  | ||||||
|  | # MTU 设置 (可选) | ||||||
|  | # mtu = 1420 | ||||||
|  |  | ||||||
|  | # 日志级别 (可选: error, warn, info, debug, trace) | ||||||
|  | # log_level = "info" | ||||||
|  |  | ||||||
|  | # 禁用 P2P (可选) | ||||||
|  | # disable_p2p = false | ||||||
|  |  | ||||||
|  | # 使用多路径 (可选) | ||||||
|  | # use_multi_path = true | ||||||
|  |  | ||||||
|  | # 延迟优先 (可选) | ||||||
|  | # latency_first = false | ||||||
| @@ -0,0 +1,78 @@ | |||||||
|  | package com.easytier.jni | ||||||
|  |  | ||||||
|  | /** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */ | ||||||
|  | object EasyTierJNI { | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         // 加载本地库 | ||||||
|  |         System.loadLibrary("easytier_android_jni") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 设置 TUN 文件描述符 | ||||||
|  |      * @param instanceName 实例名称 | ||||||
|  |      * @param fd TUN 文件描述符 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当操作失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun setTunFd(instanceName: String, fd: Int): Int | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 解析配置字符串 | ||||||
|  |      * @param config TOML 格式的配置字符串 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当配置解析失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun parseConfig(config: String): Int | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 运行网络实例 | ||||||
|  |      * @param config TOML 格式的配置字符串 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当实例启动失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun runNetworkInstance(config: String): Int | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 保留指定的网络实例,停止其他实例 | ||||||
|  |      * @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当操作失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 收集网络信息 | ||||||
|  |      * @param maxLength 最大返回条目数 | ||||||
|  |      * @return 包含网络信息的字符串数组,每个元素格式为 "key=value" | ||||||
|  |      * @throws RuntimeException 当操作失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun collectNetworkInfos(maxLength: Int): String? | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 获取最后的错误消息 | ||||||
|  |      * @return 错误消息字符串,如果没有错误则返回 null | ||||||
|  |      */ | ||||||
|  |     @JvmStatic external fun getLastError(): String? | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 便利方法:停止所有网络实例 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当操作失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic | ||||||
|  |     fun stopAllInstances(): Int { | ||||||
|  |         return retainNetworkInstance(null) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 便利方法:停止指定实例外的所有实例 | ||||||
|  |      * @param instanceName 要保留的实例名称 | ||||||
|  |      * @return 0 表示成功,-1 表示失败 | ||||||
|  |      * @throws RuntimeException 当操作失败时抛出异常 | ||||||
|  |      */ | ||||||
|  |     @JvmStatic | ||||||
|  |     fun retainSingleInstance(instanceName: String): Int { | ||||||
|  |         return retainNetworkInstance(arrayOf(instanceName)) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,252 @@ | |||||||
|  | package com.easytier.jni | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.Handler | ||||||
|  | import android.os.Looper | ||||||
|  | import android.util.Log | ||||||
|  | import com.squareup.moshi.Moshi | ||||||
|  | import com.squareup.wire.WireJsonAdapterFactory | ||||||
|  | import common.Ipv4Inet | ||||||
|  | import web.NetworkInstanceRunningInfoMap | ||||||
|  |  | ||||||
|  | fun parseIpv4InetToString(inet: Ipv4Inet?): String? { | ||||||
|  |     val addr = inet?.address?.addr ?: return null | ||||||
|  |     val networkLength = inet.network_length | ||||||
|  |  | ||||||
|  |     // 将 int32 转换为 IPv4 字符串 | ||||||
|  |     val ip = | ||||||
|  |             String.format( | ||||||
|  |                     "%d.%d.%d.%d", | ||||||
|  |                     (addr shr 24) and 0xFF, | ||||||
|  |                     (addr shr 16) and 0xFF, | ||||||
|  |                     (addr shr 8) and 0xFF, | ||||||
|  |                     addr and 0xFF | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     return "$ip/$networkLength" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** EasyTier 管理类 负责管理 EasyTier 实例的生命周期、监控网络状态变化、控制 VpnService */ | ||||||
|  | class EasyTierManager( | ||||||
|  |         private val activity: Activity, | ||||||
|  |         private val instanceName: String, | ||||||
|  |         private val networkConfig: String | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         private const val TAG = "EasyTierManager" | ||||||
|  |         private const val MONITOR_INTERVAL = 3000L // 3秒监控间隔 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private val handler = Handler(Looper.getMainLooper()) | ||||||
|  |     private var isRunning = false | ||||||
|  |     private var currentIpv4: String? = null | ||||||
|  |     private var currentProxyCidrs: List<String> = emptyList() | ||||||
|  |     private var vpnServiceIntent: Intent? = null | ||||||
|  |  | ||||||
|  |     // JSON 解析器 | ||||||
|  |     private val moshi = Moshi.Builder().add(WireJsonAdapterFactory()).build() | ||||||
|  |     private val adapter = moshi.adapter(NetworkInstanceRunningInfoMap::class.java) | ||||||
|  |  | ||||||
|  |     // 监控任务 | ||||||
|  |     private val monitorRunnable = | ||||||
|  |             object : Runnable { | ||||||
|  |                 override fun run() { | ||||||
|  |                     if (isRunning) { | ||||||
|  |                         monitorNetworkStatus() | ||||||
|  |                         handler.postDelayed(this, MONITOR_INTERVAL) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |     /** 启动 EasyTier 实例和监控 */ | ||||||
|  |     fun start() { | ||||||
|  |         if (isRunning) { | ||||||
|  |             Log.w(TAG, "EasyTier 实例已经在运行中") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             // 启动 EasyTier 实例 | ||||||
|  |             val result = EasyTierJNI.runNetworkInstance(networkConfig) | ||||||
|  |             if (result == 0) { | ||||||
|  |                 isRunning = true | ||||||
|  |                 Log.i(TAG, "EasyTier 实例启动成功: $instanceName") | ||||||
|  |  | ||||||
|  |                 // 开始监控网络状态 | ||||||
|  |                 handler.post(monitorRunnable) | ||||||
|  |             } else { | ||||||
|  |                 Log.e(TAG, "EasyTier 实例启动失败: $result") | ||||||
|  |                 val error = EasyTierJNI.getLastError() | ||||||
|  |                 Log.e(TAG, "错误信息: $error") | ||||||
|  |             } | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "启动 EasyTier 实例时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 停止 EasyTier 实例和监控 */ | ||||||
|  |     fun stop() { | ||||||
|  |         if (!isRunning) { | ||||||
|  |             Log.w(TAG, "EasyTier 实例未在运行") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isRunning = false | ||||||
|  |  | ||||||
|  |         // 停止监控任务 | ||||||
|  |         handler.removeCallbacks(monitorRunnable) | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             // 停止 VpnService | ||||||
|  |             stopVpnService() | ||||||
|  |  | ||||||
|  |             // 停止 EasyTier 实例 | ||||||
|  |             EasyTierJNI.stopAllInstances() | ||||||
|  |             Log.i(TAG, "EasyTier 实例已停止: $instanceName") | ||||||
|  |  | ||||||
|  |             // 重置状态 | ||||||
|  |             currentIpv4 = null | ||||||
|  |             currentProxyCidrs = emptyList() | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "停止 EasyTier 实例时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 监控网络状态 */ | ||||||
|  |     private fun monitorNetworkStatus() { | ||||||
|  |         try { | ||||||
|  |             val infosJson = EasyTierJNI.collectNetworkInfos(10) | ||||||
|  |             if (infosJson.isNullOrEmpty()) { | ||||||
|  |                 Log.d(TAG, "未获取到网络信息") | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val networkInfoMap = parseNetworkInfo(infosJson) | ||||||
|  |             val networkInfo = networkInfoMap?.map?.get(instanceName) | ||||||
|  |  | ||||||
|  |             if (networkInfo == null) { | ||||||
|  |                 Log.d(TAG, "未找到实例 $instanceName 的网络信息") | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Log.d(TAG, "网络信息: $networkInfo") | ||||||
|  |  | ||||||
|  |             // 检查实例是否正在运行 | ||||||
|  |             if (!networkInfo.running) { | ||||||
|  |                 Log.w(TAG, "EasyTier 实例未运行: ${networkInfo.error_msg}") | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val newIpv4Inet = networkInfo.my_node_info?.virtual_ipv4 | ||||||
|  |  | ||||||
|  |             if (newIpv4Inet == null) { | ||||||
|  |                 Log.w(TAG, "EasyTier No Ipv4: $networkInfo") | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // 获取当前节点的 IPv4 地址 | ||||||
|  |             val newIpv4 = parseIpv4InetToString(newIpv4Inet) | ||||||
|  |  | ||||||
|  |             // 获取所有节点的 proxy_cidrs | ||||||
|  |             val newProxyCidrs = mutableListOf<String>() | ||||||
|  |             networkInfo.routes?.forEach { route -> | ||||||
|  |                 route.proxy_cidrs?.let { cidrs -> newProxyCidrs.addAll(cidrs) } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // 检查是否有变化 | ||||||
|  |             val ipv4Changed = newIpv4 != currentIpv4 | ||||||
|  |             val proxyCidrsChanged = newProxyCidrs != currentProxyCidrs | ||||||
|  |  | ||||||
|  |             if (ipv4Changed || proxyCidrsChanged) { | ||||||
|  |                 Log.i(TAG, "网络状态发生变化:") | ||||||
|  |                 Log.i(TAG, "  IPv4: $currentIpv4 -> $newIpv4") | ||||||
|  |                 Log.i(TAG, "  Proxy CIDRs: $currentProxyCidrs -> $newProxyCidrs") | ||||||
|  |  | ||||||
|  |                 // 更新状态 | ||||||
|  |                 currentIpv4 = newIpv4 | ||||||
|  |                 currentProxyCidrs = newProxyCidrs.toList() | ||||||
|  |  | ||||||
|  |                 // 重启 VpnService | ||||||
|  |                 if (newIpv4 != null) { | ||||||
|  |                     restartVpnService(newIpv4, newProxyCidrs) | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 Log.d(TAG, "网络状态无变化 - IPv4: $currentIpv4, Proxy CIDRs: ${currentProxyCidrs.size} 个") | ||||||
|  |             } | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "监控网络状态时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 解析网络信息 JSON */ | ||||||
|  |     private fun parseNetworkInfo(jsonString: String): NetworkInstanceRunningInfoMap? { | ||||||
|  |         return try { | ||||||
|  |             adapter.fromJson(jsonString) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "解析网络信息失败", e) | ||||||
|  |             null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 重启 VpnService */ | ||||||
|  |     private fun restartVpnService(ipv4: String, proxyCidrs: List<String>) { | ||||||
|  |         try { | ||||||
|  |             // 先停止现有的 VpnService | ||||||
|  |             stopVpnService() | ||||||
|  |  | ||||||
|  |             // 启动新的 VpnService | ||||||
|  |             startVpnService(ipv4, proxyCidrs) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "重启 VpnService 时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 启动 VpnService */ | ||||||
|  |     private fun startVpnService(ipv4: String, proxyCidrs: List<String>) { | ||||||
|  |         try { | ||||||
|  |             val intent = Intent(activity, EasyTierVpnService::class.java) | ||||||
|  |             intent.putExtra("ipv4_address", ipv4) | ||||||
|  |             intent.putStringArrayListExtra("proxy_cidrs", ArrayList(proxyCidrs)) | ||||||
|  |             intent.putExtra("instance_name", instanceName) | ||||||
|  |  | ||||||
|  |             activity.startService(intent) | ||||||
|  |             vpnServiceIntent = intent | ||||||
|  |  | ||||||
|  |             Log.i(TAG, "VpnService 已启动 - IPv4: $ipv4, Proxy CIDRs: $proxyCidrs") | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "启动 VpnService 时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 停止 VpnService */ | ||||||
|  |     private fun stopVpnService() { | ||||||
|  |         try { | ||||||
|  |             vpnServiceIntent?.let { intent -> | ||||||
|  |                 activity.stopService(intent) | ||||||
|  |                 Log.i(TAG, "VpnService 已停止") | ||||||
|  |             } | ||||||
|  |             vpnServiceIntent = null | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(TAG, "停止 VpnService 时发生异常", e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 获取当前状态信息 */ | ||||||
|  |     fun getStatus(): EasyTierStatus { | ||||||
|  |         return EasyTierStatus( | ||||||
|  |                 isRunning = isRunning, | ||||||
|  |                 instanceName = instanceName, | ||||||
|  |                 currentIpv4 = currentIpv4, | ||||||
|  |                 currentProxyCidrs = currentProxyCidrs.toList() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 状态数据类 */ | ||||||
|  |     data class EasyTierStatus( | ||||||
|  |             val isRunning: Boolean, | ||||||
|  |             val instanceName: String, | ||||||
|  |             val currentIpv4: String?, | ||||||
|  |             val currentProxyCidrs: List<String> | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,143 @@ | |||||||
|  | package com.easytier.jni | ||||||
|  |  | ||||||
|  | import android.content.Intent | ||||||
|  | import android.net.VpnService | ||||||
|  | import android.os.ParcelFileDescriptor | ||||||
|  | import android.util.Log | ||||||
|  | import kotlin.concurrent.thread | ||||||
|  |  | ||||||
|  | class EasyTierVpnService : VpnService() { | ||||||
|  |  | ||||||
|  |     private var vpnInterface: ParcelFileDescriptor? = null | ||||||
|  |     private var isRunning = false | ||||||
|  |     private var instanceName: String? = null | ||||||
|  |  | ||||||
|  |     companion object { | ||||||
|  |         private const val TAG = "EasyTierVpnService" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onCreate() { | ||||||
|  |         super.onCreate() | ||||||
|  |         Log.d(TAG, "VPN Service created") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||||
|  |         // 获取传入的参数 | ||||||
|  |         val ipv4Address = intent?.getStringExtra("ipv4_address") | ||||||
|  |         val proxyCidrs = intent?.getStringArrayListExtra("proxy_cidrs") ?: arrayListOf() | ||||||
|  |         instanceName = intent?.getStringExtra("instance_name") | ||||||
|  |  | ||||||
|  |         if (ipv4Address == null || instanceName == null) { | ||||||
|  |             Log.e(TAG, "缺少必要参数: ipv4Address=$ipv4Address, instanceName=$instanceName") | ||||||
|  |             stopSelf() | ||||||
|  |             return START_NOT_STICKY | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Log.i( | ||||||
|  |                 TAG, | ||||||
|  |                 "启动 VPN Service - IPv4: $ipv4Address, Proxy CIDRs: $proxyCidrs, Instance: $instanceName" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         thread { | ||||||
|  |             try { | ||||||
|  |                 setupVpnInterface(ipv4Address, proxyCidrs) | ||||||
|  |             } catch (t: Throwable) { | ||||||
|  |                 Log.e(TAG, "VPN 设置失败", t) | ||||||
|  |                 stopSelf() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return START_STICKY | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun setupVpnInterface(ipv4Address: String, proxyCidrs: List<String>) { | ||||||
|  |         try { | ||||||
|  |             // 解析 IPv4 地址和网络长度 | ||||||
|  |             val (ip, networkLength) = parseIpv4Address(ipv4Address) | ||||||
|  |  | ||||||
|  |             // 1. 准备 VpnService.Builder | ||||||
|  |             val builder = Builder() | ||||||
|  |             builder.setSession("EasyTier VPN") | ||||||
|  |                     .addAddress(ip, networkLength) | ||||||
|  |                     .addDnsServer("223.5.5.5") | ||||||
|  |                     .addDnsServer("114.114.114.114") | ||||||
|  |                     .addDisallowedApplication("com.easytier.easytiervpn") | ||||||
|  |  | ||||||
|  |             // 2. 添加路由表 - 为每个 proxy CIDR 添加路由 | ||||||
|  |             proxyCidrs.forEach { cidr -> | ||||||
|  |                 try { | ||||||
|  |                     val (routeIp, routeLength) = parseCidr(cidr) | ||||||
|  |                     builder.addRoute(routeIp, routeLength) | ||||||
|  |                     Log.d(TAG, "添加路由: $routeIp/$routeLength") | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.w(TAG, "解析 CIDR 失败: $cidr", e) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // 3. 构建虚拟网络接口 | ||||||
|  |             vpnInterface = builder.establish() | ||||||
|  |  | ||||||
|  |             if (vpnInterface == null) { | ||||||
|  |                 Log.e(TAG, "创建 VPN 接口失败") | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Log.i(TAG, "VPN 接口创建成功") | ||||||
|  |  | ||||||
|  |             // 4. 将 TUN 文件描述符传递给 EasyTier | ||||||
|  |             instanceName?.let { name -> | ||||||
|  |                 val fd = vpnInterface!!.fd | ||||||
|  |                 val result = EasyTierJNI.setTunFd(name, fd) | ||||||
|  |                 if (result == 0) { | ||||||
|  |                     Log.i(TAG, "TUN 文件描述符设置成功: $fd") | ||||||
|  |                 } else { | ||||||
|  |                     Log.e(TAG, "TUN 文件描述符设置失败: $result") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             isRunning = true | ||||||
|  |  | ||||||
|  |             // 5. 保持服务运行 | ||||||
|  |             while (isRunning && vpnInterface != null) { | ||||||
|  |                 Thread.sleep(1000) | ||||||
|  |             } | ||||||
|  |         } catch (t: Throwable) { | ||||||
|  |             Log.e(TAG, "VPN 接口设置过程中发生错误", t) | ||||||
|  |         } finally { | ||||||
|  |             cleanup() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 解析 IPv4 地址,返回 IP 和网络长度 */ | ||||||
|  |     private fun parseIpv4Address(ipv4Address: String): Pair<String, Int> { | ||||||
|  |         return if (ipv4Address.contains("/")) { | ||||||
|  |             val parts = ipv4Address.split("/") | ||||||
|  |             Pair(parts[0], parts[1].toInt()) | ||||||
|  |         } else { | ||||||
|  |             // 默认使用 /24 网络 | ||||||
|  |             Pair(ipv4Address, 24) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 解析 CIDR,返回 IP 和网络长度 */ | ||||||
|  |     private fun parseCidr(cidr: String): Pair<String, Int> { | ||||||
|  |         val parts = cidr.split("/") | ||||||
|  |         if (parts.size != 2) { | ||||||
|  |             throw IllegalArgumentException("无效的 CIDR 格式: $cidr") | ||||||
|  |         } | ||||||
|  |         return Pair(parts[0], parts[1].toInt()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun cleanup() { | ||||||
|  |         isRunning = false | ||||||
|  |         vpnInterface?.close() | ||||||
|  |         vpnInterface = null | ||||||
|  |         Log.i(TAG, "VPN 接口已清理") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onDestroy() { | ||||||
|  |         super.onDestroy() | ||||||
|  |         Log.d(TAG, "VPN Service destroyed") | ||||||
|  |         cleanup() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | # 使用说明 | ||||||
|  |  | ||||||
|  | 1. 需要将 proto 文件放入 app/src/main/proto | ||||||
|  | 2. android/gradle/libs.versions.toml 中加入依赖 | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | # Wire 核心运行时 | ||||||
|  | android-wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version = "5.3.11" } | ||||||
|  | moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } | ||||||
|  | android-wire-moshi-adapter = { group = "com.squareup.wire", name = "wire-moshi-adapter", version = "5.3.11" } | ||||||
|  | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 3. build.gradle.kts 中加入 | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | plugins { | ||||||
|  |     ... | ||||||
|  |     alias(libs.plugins.wire) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dependencies { | ||||||
|  |     ... | ||||||
|  |     implementation(libs.android.wire.runtime) | ||||||
|  |     implementation(libs.android.wire.moshi.adapter) | ||||||
|  |     implementation(libs.moshi) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ... | ||||||
|  |  | ||||||
|  | wire { | ||||||
|  |     kotlin { | ||||||
|  |         rpcRole = "none" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 4. 调用 easytier-contrib/easytier-android-jni/build.sh 生成 jni 和 ffi 的 so 文件。 | ||||||
|  | 并将生成的 so 文件放到 android/app/src/main/jniLibs/arm64-v8a 目录下。 | ||||||
|  |  | ||||||
|  | 5. 使用 EasyTierManager 可以拉起 EasyTier 实例并启动 Android VpnService 组件。 | ||||||
							
								
								
									
										319
									
								
								easytier-contrib/easytier-android-jni/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,319 @@ | |||||||
|  | use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap}; | ||||||
|  | use jni::objects::{JClass, JObjectArray, JString}; | ||||||
|  | use jni::sys::{jint, jstring}; | ||||||
|  | use jni::JNIEnv; | ||||||
|  | use once_cell::sync::Lazy; | ||||||
|  | use std::ffi::{CStr, CString}; | ||||||
|  | use std::ptr; | ||||||
|  |  | ||||||
|  | // 定义 KeyValuePair 结构体 | ||||||
|  | #[repr(C)] | ||||||
|  | #[derive(Clone, Copy)] | ||||||
|  | pub struct KeyValuePair { | ||||||
|  |     pub key: *const std::ffi::c_char, | ||||||
|  |     pub value: *const std::ffi::c_char, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 声明外部 C 函数 | ||||||
|  | extern "C" { | ||||||
|  |     fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int; | ||||||
|  |     fn get_error_msg(out: *mut *const std::ffi::c_char); | ||||||
|  |     fn free_string(s: *const std::ffi::c_char); | ||||||
|  |     fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int; | ||||||
|  |     fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int; | ||||||
|  |     fn retain_network_instance( | ||||||
|  |         inst_names: *const *const std::ffi::c_char, | ||||||
|  |         length: usize, | ||||||
|  |     ) -> std::ffi::c_int; | ||||||
|  |     fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 初始化 Android 日志 | ||||||
|  | static LOGGER_INIT: Lazy<()> = Lazy::new(|| { | ||||||
|  |     android_logger::init_once( | ||||||
|  |         android_logger::Config::default() | ||||||
|  |             .with_max_level(log::LevelFilter::Debug) | ||||||
|  |             .with_tag("EasyTier-JNI"), | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // 辅助函数:从 Java String 转换为 CString | ||||||
|  | fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> { | ||||||
|  |     let java_str = env | ||||||
|  |         .get_string(jstr) | ||||||
|  |         .map_err(|e| format!("Failed to get string: {:?}", e))?; | ||||||
|  |     let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?; | ||||||
|  |     CString::new(rust_str).map_err(|_| "String contains null byte".to_string()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 辅助函数:获取错误消息 | ||||||
|  | fn get_last_error() -> Option<String> { | ||||||
|  |     unsafe { | ||||||
|  |         let mut error_ptr: *const std::ffi::c_char = ptr::null(); | ||||||
|  |         get_error_msg(&mut error_ptr); | ||||||
|  |         if error_ptr.is_null() { | ||||||
|  |             None | ||||||
|  |         } else { | ||||||
|  |             let error_cstr = CStr::from_ptr(error_ptr); | ||||||
|  |             let error_str = error_cstr.to_string_lossy().into_owned(); | ||||||
|  |             free_string(error_ptr); | ||||||
|  |             Some(error_str) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 辅助函数:抛出 Java 异常 | ||||||
|  | fn throw_exception(env: &mut JNIEnv, message: &str) { | ||||||
|  |     let _ = env.throw_new("java/lang/RuntimeException", message); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 设置 TUN 文件描述符 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd( | ||||||
|  |     mut env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  |     inst_name: JString, | ||||||
|  |     fd: jint, | ||||||
|  | ) -> jint { | ||||||
|  |     Lazy::force(&LOGGER_INIT); | ||||||
|  |  | ||||||
|  |     let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) { | ||||||
|  |         Ok(cstr) => cstr, | ||||||
|  |         Err(e) => { | ||||||
|  |             throw_exception(&mut env, &format!("Invalid instance name: {}", e)); | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     unsafe { | ||||||
|  |         let result = set_tun_fd(inst_name_cstr.as_ptr(), fd); | ||||||
|  |         if result != 0 { | ||||||
|  |             if let Some(error) = get_last_error() { | ||||||
|  |                 throw_exception(&mut env, &error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         result | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 解析配置 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig( | ||||||
|  |     mut env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  |     config: JString, | ||||||
|  | ) -> jint { | ||||||
|  |     Lazy::force(&LOGGER_INIT); | ||||||
|  |  | ||||||
|  |     let config_cstr = match jstring_to_cstring(&mut env, &config) { | ||||||
|  |         Ok(cstr) => cstr, | ||||||
|  |         Err(e) => { | ||||||
|  |             throw_exception(&mut env, &format!("Invalid config string: {}", e)); | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     unsafe { | ||||||
|  |         let result = parse_config(config_cstr.as_ptr()); | ||||||
|  |         if result != 0 { | ||||||
|  |             if let Some(error) = get_last_error() { | ||||||
|  |                 throw_exception(&mut env, &error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         result | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 运行网络实例 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance( | ||||||
|  |     mut env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  |     config: JString, | ||||||
|  | ) -> jint { | ||||||
|  |     Lazy::force(&LOGGER_INIT); | ||||||
|  |  | ||||||
|  |     let config_cstr = match jstring_to_cstring(&mut env, &config) { | ||||||
|  |         Ok(cstr) => cstr, | ||||||
|  |         Err(e) => { | ||||||
|  |             throw_exception(&mut env, &format!("Invalid config string: {}", e)); | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     unsafe { | ||||||
|  |         let result = run_network_instance(config_cstr.as_ptr()); | ||||||
|  |         if result != 0 { | ||||||
|  |             if let Some(error) = get_last_error() { | ||||||
|  |                 throw_exception(&mut env, &error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         result | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 保持网络实例 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance( | ||||||
|  |     mut env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  |     instance_names: JObjectArray, | ||||||
|  | ) -> jint { | ||||||
|  |     Lazy::force(&LOGGER_INIT); | ||||||
|  |  | ||||||
|  |     // 处理 null 数组的情况 | ||||||
|  |     if instance_names.is_null() { | ||||||
|  |         unsafe { | ||||||
|  |             let result = retain_network_instance(ptr::null(), 0); | ||||||
|  |             if result != 0 { | ||||||
|  |                 if let Some(error) = get_last_error() { | ||||||
|  |                     throw_exception(&mut env, &error); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return result; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 获取数组长度 | ||||||
|  |     let array_length = match env.get_array_length(&instance_names) { | ||||||
|  |         Ok(len) => len as usize, | ||||||
|  |         Err(e) => { | ||||||
|  |             throw_exception(&mut env, &format!("Failed to get array length: {:?}", e)); | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // 如果数组为空,停止所有实例 | ||||||
|  |     if array_length == 0 { | ||||||
|  |         unsafe { | ||||||
|  |             let result = retain_network_instance(ptr::null(), 0); | ||||||
|  |             if result != 0 { | ||||||
|  |                 if let Some(error) = get_last_error() { | ||||||
|  |                     throw_exception(&mut env, &error); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return result; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 转换 Java 字符串数组为 C 字符串数组 | ||||||
|  |     let mut c_strings = Vec::with_capacity(array_length); | ||||||
|  |     let mut c_string_ptrs = Vec::with_capacity(array_length); | ||||||
|  |  | ||||||
|  |     for i in 0..array_length { | ||||||
|  |         let java_string = match env.get_object_array_element(&instance_names, i as i32) { | ||||||
|  |             Ok(obj) => obj, | ||||||
|  |             Err(e) => { | ||||||
|  |                 throw_exception( | ||||||
|  |                     &mut env, | ||||||
|  |                     &format!("Failed to get array element {}: {:?}", i, e), | ||||||
|  |                 ); | ||||||
|  |                 return -1; | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if java_string.is_null() { | ||||||
|  |             continue; // 跳过 null 元素 | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let jstring = JString::from(java_string); | ||||||
|  |         let c_string = match jstring_to_cstring(&mut env, &jstring) { | ||||||
|  |             Ok(cstr) => cstr, | ||||||
|  |             Err(e) => { | ||||||
|  |                 throw_exception( | ||||||
|  |                     &mut env, | ||||||
|  |                     &format!("Invalid instance name at index {}: {}", i, e), | ||||||
|  |                 ); | ||||||
|  |                 return -1; | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         c_string_ptrs.push(c_string.as_ptr()); | ||||||
|  |         c_strings.push(c_string); // 保持 CString 的所有权 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     unsafe { | ||||||
|  |         let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len()); | ||||||
|  |         if result != 0 { | ||||||
|  |             if let Some(error) = get_last_error() { | ||||||
|  |                 throw_exception(&mut env, &error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         result | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 收集网络信息 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos( | ||||||
|  |     mut env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  | ) -> jstring { | ||||||
|  |     Lazy::force(&LOGGER_INIT); | ||||||
|  |  | ||||||
|  |     const MAX_INFOS: usize = 100; | ||||||
|  |     let mut infos = vec![ | ||||||
|  |         KeyValuePair { | ||||||
|  |             key: ptr::null(), | ||||||
|  |             value: ptr::null(), | ||||||
|  |         }; | ||||||
|  |         MAX_INFOS | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     unsafe { | ||||||
|  |         let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS); | ||||||
|  |         if count < 0 { | ||||||
|  |             if let Some(error) = get_last_error() { | ||||||
|  |                 throw_exception(&mut env, &error); | ||||||
|  |             } | ||||||
|  |             return ptr::null_mut(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let mut ret = NetworkInstanceRunningInfoMap::default(); | ||||||
|  |  | ||||||
|  |         // 使用 serde_json 构建 JSON | ||||||
|  |         for info in infos.iter().take(count as usize) { | ||||||
|  |             let key_ptr = info.key; | ||||||
|  |             let val_ptr = info.value; | ||||||
|  |             if key_ptr.is_null() || val_ptr.is_null() { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let key = CStr::from_ptr(key_ptr).to_string_lossy(); | ||||||
|  |             let val = CStr::from_ptr(val_ptr).to_string_lossy(); | ||||||
|  |             let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) { | ||||||
|  |                 Ok(v) => v, | ||||||
|  |                 Err(_) => { | ||||||
|  |                     throw_exception(&mut env, "Failed to parse JSON"); | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |             ret.map.insert(key.to_string(), value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string()); | ||||||
|  |  | ||||||
|  |         match env.new_string(&json_str) { | ||||||
|  |             Ok(jstr) => jstr.into_raw(), | ||||||
|  |             Err(_) => { | ||||||
|  |                 throw_exception(&mut env, "Failed to create JSON string"); | ||||||
|  |                 ptr::null_mut() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 获取最后的错误信息 | ||||||
|  | #[no_mangle] | ||||||
|  | pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError( | ||||||
|  |     env: JNIEnv, | ||||||
|  |     _class: JClass, | ||||||
|  | ) -> jstring { | ||||||
|  |     match get_last_error() { | ||||||
|  |         Some(error) => match env.new_string(&error) { | ||||||
|  |             Ok(jstr) => jstr.into_raw(), | ||||||
|  |             Err(_) => ptr::null_mut(), | ||||||
|  |         }, | ||||||
|  |         None => ptr::null_mut(), | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -29,8 +29,39 @@ fn set_error_msg(msg: &str) { | |||||||
|     msg_buf[..len].copy_from_slice(bytes); |     msg_buf[..len].copy_from_slice(bytes); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Set the tun fd | ||||||
| #[no_mangle] | #[no_mangle] | ||||||
| pub extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) { | pub unsafe extern "C" fn set_tun_fd( | ||||||
|  |     inst_name: *const std::ffi::c_char, | ||||||
|  |     fd: std::ffi::c_int, | ||||||
|  | ) -> std::ffi::c_int { | ||||||
|  |     let inst_name = unsafe { | ||||||
|  |         assert!(!inst_name.is_null()); | ||||||
|  |         std::ffi::CStr::from_ptr(inst_name) | ||||||
|  |             .to_string_lossy() | ||||||
|  |             .into_owned() | ||||||
|  |     }; | ||||||
|  |     if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) { | ||||||
|  |         return -1; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let inst_id = *INSTANCE_NAME_ID_MAP | ||||||
|  |         .get(&inst_name) | ||||||
|  |         .as_ref() | ||||||
|  |         .unwrap() | ||||||
|  |         .value(); | ||||||
|  |  | ||||||
|  |     match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) { | ||||||
|  |         Ok(_) => 0, | ||||||
|  |         Err(_) => -1, | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Get the last error message | ||||||
|  | #[no_mangle] | ||||||
|  | pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) { | ||||||
|     let msg_buf = ERROR_MSG.lock().unwrap(); |     let msg_buf = ERROR_MSG.lock().unwrap(); | ||||||
|     if msg_buf.is_empty() { |     if msg_buf.is_empty() { | ||||||
|         unsafe { |         unsafe { | ||||||
| @@ -54,8 +85,10 @@ pub extern "C" fn free_string(s: *const std::ffi::c_char) { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Parse the config | ||||||
| #[no_mangle] | #[no_mangle] | ||||||
| pub extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { | pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { | ||||||
|     let cfg_str = unsafe { |     let cfg_str = unsafe { | ||||||
|         assert!(!cfg_str.is_null()); |         assert!(!cfg_str.is_null()); | ||||||
|         std::ffi::CStr::from_ptr(cfg_str) |         std::ffi::CStr::from_ptr(cfg_str) | ||||||
| @@ -71,8 +104,10 @@ pub extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_ | |||||||
|     0 |     0 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Run the network instance | ||||||
| #[no_mangle] | #[no_mangle] | ||||||
| pub extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { | pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { | ||||||
|     let cfg_str = unsafe { |     let cfg_str = unsafe { | ||||||
|         assert!(!cfg_str.is_null()); |         assert!(!cfg_str.is_null()); | ||||||
|         std::ffi::CStr::from_ptr(cfg_str) |         std::ffi::CStr::from_ptr(cfg_str) | ||||||
| @@ -107,8 +142,10 @@ pub extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std: | |||||||
|     0 |     0 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Retain the network instance | ||||||
| #[no_mangle] | #[no_mangle] | ||||||
| pub extern "C" fn retain_network_instance( | pub unsafe extern "C" fn retain_network_instance( | ||||||
|     inst_names: *const *const std::ffi::c_char, |     inst_names: *const *const std::ffi::c_char, | ||||||
|     length: usize, |     length: usize, | ||||||
| ) -> std::ffi::c_int { | ) -> std::ffi::c_int { | ||||||
| @@ -144,13 +181,15 @@ pub extern "C" fn retain_network_instance( | |||||||
|         return -1; |         return -1; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let _ = INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k)); |     INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k)); | ||||||
|  |  | ||||||
|     0 |     0 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// # Safety | ||||||
|  | /// Collect the network infos | ||||||
| #[no_mangle] | #[no_mangle] | ||||||
| pub extern "C" fn collect_network_infos( | pub unsafe extern "C" fn collect_network_infos( | ||||||
|     infos: *mut KeyValuePair, |     infos: *mut KeyValuePair, | ||||||
|     max_length: usize, |     max_length: usize, | ||||||
| ) -> std::ffi::c_int { | ) -> std::ffi::c_int { | ||||||
| @@ -163,7 +202,7 @@ pub extern "C" fn collect_network_infos( | |||||||
|         std::slice::from_raw_parts_mut(infos, max_length) |         std::slice::from_raw_parts_mut(infos, max_length) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let collected_infos = match INSTANCE_MANAGER.collect_network_infos() { |     let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() { | ||||||
|         Ok(infos) => infos, |         Ok(infos) => infos, | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             set_error_msg(&format!("failed to collect network infos: {}", e)); |             set_error_msg(&format!("failed to collect network infos: {}", e)); | ||||||
| @@ -209,7 +248,9 @@ mod tests { | |||||||
|             network = "test_network" |             network = "test_network" | ||||||
|         "#; |         "#; | ||||||
|         let cstr = std::ffi::CString::new(cfg_str).unwrap(); |         let cstr = std::ffi::CString::new(cfg_str).unwrap(); | ||||||
|         assert_eq!(parse_config(cstr.as_ptr()), 0); |         unsafe { | ||||||
|  |             assert_eq!(parse_config(cstr.as_ptr()), 0); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     #[test] |     #[test] | ||||||
| @@ -219,6 +260,8 @@ mod tests { | |||||||
|             network = "test_network" |             network = "test_network" | ||||||
|         "#; |         "#; | ||||||
|         let cstr = std::ffi::CString::new(cfg_str).unwrap(); |         let cstr = std::ffi::CString::new(cfg_str).unwrap(); | ||||||
|         assert_eq!(run_network_instance(cstr.as_ptr()), 0); |         unsafe { | ||||||
|  |             assert_eq!(run_network_instance(cstr.as_ptr()), 0); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,14 +1,43 @@ | |||||||
| #!/data/adb/magisk/busybox sh | #!/data/adb/magisk/busybox sh | ||||||
| MODDIR=${0%/*} | MODDIR=${0%/*} | ||||||
|  | MODULE_PROP="${MODDIR}/module.prop" | ||||||
|  |  | ||||||
| # 查找 easytier-core 进程的 PID | ET_STATUS="" | ||||||
| PID=$(pgrep easytier-core) | REDIR_STATUS="" | ||||||
|  | # 更新module.prop文件中的description | ||||||
|  | update_module_description() { | ||||||
|  |     local status_message=$1 | ||||||
|  |     sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP} | ||||||
|  | } | ||||||
|  |  | ||||||
| # 检查是否找到了进程 |  | ||||||
| if [ -z "$PID" ]; then | if [ -f "${MODDIR}/disable" ]; then | ||||||
|     echo "easytier-core 进程未找到" |     ET_STATUS="已关闭" | ||||||
| else | elif pgrep -f 'easytier-core' >/dev/null; then | ||||||
|     # 结束进程 |     if [ -f "${MODDIR}/config/command_args"]; then | ||||||
|     kill $PID |         ET_STATUS="主程序已开启(启动参数模式)" | ||||||
|     echo "已结束 easytier-core 进程 (PID: $PID)" |     else | ||||||
|  |         ET_STATUS="主程序已开启(配置文件模式)" | ||||||
|  |     fi | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | #ET_STATUS不存在说明开启模块未正常运行,不修改状态 | ||||||
|  | if [ -n "$ET_STATUS" ]; then | ||||||
|  |     if [ -f "${MODDIR}/enable_IP_rule" ]; then | ||||||
|  |         rm -f "${MODDIR}/enable_IP_rule" | ||||||
|  |         ${MODDIR}/hotspot_iprule.sh del | ||||||
|  |         REDIR_STATUS="转发已禁用" | ||||||
|  |         echo "热点子网转发已禁用" | ||||||
|  |         echo "[ET-NAT] IP rule disabled." >> "${MODDIR}/log.log" | ||||||
|  |     else | ||||||
|  |         touch "${MODDIR}/enable_IP_rule" | ||||||
|  |         ${MODDIR}/hotspot_iprule.sh del | ||||||
|  |         ${MODDIR}/hotspot_iprule.sh add_once | ||||||
|  |         REDIR_STATUS="转发已激活" | ||||||
|  |         echo "热点子网转发已激活,热点开启后将自动将热点加入转发网络(要求已配置本地网络cidr=参数)。转发规则将随着热点开关而自动开关。该状态将保持到转发被禁用为止。" | ||||||
|  |         echo "[ET-NAT] IP rule enabled." >> "${MODDIR}/log.log" | ||||||
|  |     fi | ||||||
|  |     update_module_description "${ET_STATUS} | ${REDIR_STATUS}" | ||||||
|  | else | ||||||
|  |     echo "主程序未正常启动,请先检查配置文件" | ||||||
| fi | fi | ||||||
|   | |||||||
| @@ -0,0 +1 @@ | |||||||
|  | --config-server udp://127.0.0.1:22020/admin --machine-id easytier-magisk | ||||||
| @@ -3,5 +3,7 @@ ui_print '当前架构为' + $ARCH | |||||||
| ui_print '当前系统版本为' + $API | ui_print '当前系统版本为' + $API | ||||||
| ui_print '安装目录为:  /data/adb/modules/easytier_magisk' | ui_print '安装目录为:  /data/adb/modules/easytier_magisk' | ||||||
| ui_print '配置文件位置:  /data/adb/modules/easytier_magisk/config/config.toml' | ui_print '配置文件位置:  /data/adb/modules/easytier_magisk/config/config.toml' | ||||||
| ui_print '修改后配置文件后在magisk app点击操作按钮即可生效' | ui_print '如果需要自定义启动参数,可将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中内容,使用自定义启动参数时会忽略配置文件' | ||||||
| ui_print '记得重启' | ui_print '修改配置文件后在magisk app禁用应用再启动即可生效' | ||||||
|  | ui_print '点击操作按钮可启动/关闭热点子网转发,配合easytier的子网代理功能实现手机热点访问easytier网络' | ||||||
|  | ui_print '记得重启' | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ CONFIG_FILE="${MODDIR}/config/config.toml" | |||||||
| LOG_FILE="${MODDIR}/log.log" | LOG_FILE="${MODDIR}/log.log" | ||||||
| MODULE_PROP="${MODDIR}/module.prop" | MODULE_PROP="${MODDIR}/module.prop" | ||||||
| EASYTIER="${MODDIR}/easytier-core" | EASYTIER="${MODDIR}/easytier-core" | ||||||
|  | REDIR_STATUS="" | ||||||
|  |  | ||||||
| # 更新module.prop文件中的description | # 更新module.prop文件中的description | ||||||
| update_module_description() { | update_module_description() { | ||||||
| @@ -12,6 +13,12 @@ update_module_description() { | |||||||
|     sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP} |     sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | if [ -f "${MODDIR}/enable_IP_rule" ]; then | ||||||
|  |     REDIR_STATUS="转发已激活" | ||||||
|  | else | ||||||
|  |     REDIR_STATUS="转发已禁用" | ||||||
|  | fi | ||||||
|  |  | ||||||
| if [ ! -e /dev/net/tun ]; then | if [ ! -e /dev/net/tun ]; then | ||||||
|     if [ ! -d /dev/net ]; then |     if [ ! -d /dev/net ]; then | ||||||
|         mkdir -p /dev/net |         mkdir -p /dev/net | ||||||
| @@ -22,7 +29,7 @@ fi | |||||||
|  |  | ||||||
| while true; do | while true; do | ||||||
|     if ls $MODDIR | grep -q "disable"; then |     if ls $MODDIR | grep -q "disable"; then | ||||||
|         update_module_description "关闭中" |         update_module_description "关闭中 | ${REDIR_STATUS}" | ||||||
|         if pgrep -f 'easytier-core' >/dev/null; then |         if pgrep -f 'easytier-core' >/dev/null; then | ||||||
|             echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..." |             echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..." | ||||||
|             pkill easytier-core # 关闭进程 |             pkill easytier-core # 关闭进程 | ||||||
| @@ -35,10 +42,20 @@ while true; do | |||||||
|                 continue |                 continue | ||||||
|             fi |             fi | ||||||
|  |  | ||||||
|             TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} > ${LOG_FILE} & |             # 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数 | ||||||
|             sleep 5s # 等待easytier-core启动完成 |             if [ -f "${MODDIR}/config/command_args" ]; then | ||||||
|             update_module_description "已开启(不一定运行成功)" |                 TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} & | ||||||
|  |                 sleep 5s # 等待easytier-core启动完成 | ||||||
|  |                 update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}" | ||||||
|  |             else | ||||||
|  |                 TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} & | ||||||
|  |                 sleep 5s # 等待easytier-core启动完成 | ||||||
|  |                 update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}" | ||||||
|  |             fi | ||||||
|             ip rule add from all lookup main |             ip rule add from all lookup main | ||||||
|  |             if ! pgrep -f 'easytier-core' >/dev/null; then | ||||||
|  |                 update_module_descriptio "主程序启动失败,请检查配置文件" | ||||||
|  |             fi | ||||||
|         else |         else | ||||||
|             echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在" |             echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在" | ||||||
|         fi |         fi | ||||||
|   | |||||||
							
								
								
									
										104
									
								
								easytier-contrib/easytier-magisk/hotspot_iprule.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,104 @@ | |||||||
|  | #!/system/bin/sh | ||||||
|  | MODDIR=${0%/*} | ||||||
|  | CONFIG_FILE="${MODDIR}/config/config.toml" | ||||||
|  | LOG_FILE="${MODDIR}/log.log" | ||||||
|  | ACTION="$1"  # 参数:add add_once del | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # 获取接口/IP | ||||||
|  | get_et_iface() { | ||||||
|  |     awk ' | ||||||
|  |         BEGIN { IGNORECASE = 1 } | ||||||
|  |         /^[[:space:]]*dev_name[[:space:]]*=/ { | ||||||
|  |             val = $0 | ||||||
|  |             sub(/^[^=]*=[[:space:]]*/, "", val) | ||||||
|  |             gsub(/[" \t]/, "", val) | ||||||
|  |             print val | ||||||
|  |             exit | ||||||
|  |         } | ||||||
|  |     ' "$CONFIG_FILE" | ||||||
|  | } | ||||||
|  | get_tun_iface() { | ||||||
|  |     ip link | awk -F': ' '/ tun[[:alnum:]]+/ {print $2; exit}' | ||||||
|  | } | ||||||
|  | get_hot_iface() { | ||||||
|  |     ip link | awk -F': ' '/(^| )(swlan[[:alnum:]_]*|softap[[:alnum:]_]*|p2p-wlan[[:alnum:]_]*|ap[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1 | ||||||
|  | } | ||||||
|  | get_usb_iface() { | ||||||
|  |     ip link | awk -F': ' '/(^| )(usb[[:alnum:]_]*|rndis[[:alnum:]_]*|eth[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1 | ||||||
|  | } | ||||||
|  | get_hot_cidr() { | ||||||
|  |     ip -4 addr show dev "$1" | awk '/inet /{print $2; exit}' | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | set_nat_rules() { | ||||||
|  |     ET_IFACE=$(get_et_iface) | ||||||
|  |     [ -z "$ET_IFACE" ] && ET_IFACE="$(get_tun_iface)" | ||||||
|  |     HOT_IFACE=$(get_hot_iface) | ||||||
|  |     USB_IFACE=$(get_usb_iface) | ||||||
|  |     HOT_CIDR=$(get_hot_cidr "$HOT_IFACE") | ||||||
|  |     USB_CIDR=$(get_hot_cidr "$USB_IFACE") | ||||||
|  |  | ||||||
|  |     # 如果热点关闭就删除自定义链 | ||||||
|  |    [ -n "$ET_IFACE" ] && { [ -n "$HOT_CIDR" ] || [ -n "$USB_CIDR" ]; } || return 1 | ||||||
|  |  | ||||||
|  |     # 创建自定义链(如不存在) | ||||||
|  |     iptables -t nat -N ET_NAT 2>/dev/null | ||||||
|  |     iptables -N ET_FWD 2>/dev/null | ||||||
|  |  | ||||||
|  |     # 确保主链首条跳转到自定义链 | ||||||
|  |     iptables -t nat -C POSTROUTING -j ET_NAT 2>/dev/null || \ | ||||||
|  |         iptables -t nat -I POSTROUTING 1 -j ET_NAT | ||||||
|  |     iptables -C FORWARD -j ET_FWD 2>/dev/null || \ | ||||||
|  |         iptables -I FORWARD 1 -j ET_FWD | ||||||
|  |  | ||||||
|  |     # 添加规则 | ||||||
|  |     if [ -n "$HOT_CIDR" ]; then | ||||||
|  |         iptables -t nat -A ET_NAT -s "$HOT_CIDR" -o "$ET_IFACE" -j MASQUERADE | ||||||
|  |         iptables -A ET_FWD -i "$HOT_IFACE" -o "$ET_IFACE" \ | ||||||
|  |             -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT | ||||||
|  |         iptables -A ET_FWD -i "$ET_IFACE" -o "$HOT_IFACE" \ | ||||||
|  |             -m state --state ESTABLISHED,RELATED -j ACCEPT | ||||||
|  |         echo "[ET-NAT] Rules applied: $HOT_IFACE $HOT_CIDR ↔ $ET_IFACE" >> "$LOG_FILE" | ||||||
|  |     fi | ||||||
|  |     if [ -n "$USB_CIDR" ]; then | ||||||
|  |         iptables -t nat -A ET_NAT -s "$USB_CIDR" -o "$ET_IFACE" -j MASQUERADE | ||||||
|  |         iptables -A ET_FWD -i "$USB_IFACE" -o "$ET_IFACE" \ | ||||||
|  |             -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT | ||||||
|  |         iptables -A ET_FWD -i "$ET_IFACE" -o "$USB_IFACE" \ | ||||||
|  |             -m state --state ESTABLISHED,RELATED -j ACCEPT | ||||||
|  |         echo "[ET-NAT] Rules applied: $USB_IFACE $USB_CIDR ↔ $ET_IFACE" >> "$LOG_FILE" | ||||||
|  |     fi | ||||||
|  | } | ||||||
|  |  | ||||||
|  | flush_rules() { | ||||||
|  |     iptables -t nat -F ET_NAT 2>/dev/null | ||||||
|  |     iptables -F ET_FWD 2>/dev/null | ||||||
|  |     echo "[ET-NAT] Custom chains flushed." >> "$LOG_FILE" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | case "$ACTION" in | ||||||
|  |     add) | ||||||
|  |         set_nat_rules | ||||||
|  |         echo "[ET-NAT] Guard started." >> "$LOG_FILE" | ||||||
|  |         ip monitor link addr | while read -r _; do | ||||||
|  |             if [ -f "${MODDIR}/enable_IP_rule" ]; then | ||||||
|  |                 flush_rules | ||||||
|  |                 set_nat_rules | ||||||
|  |             fi | ||||||
|  |         done | ||||||
|  |         ;; | ||||||
|  |     add_once) | ||||||
|  |         flush_rules | ||||||
|  |         set_nat_rules | ||||||
|  |         echo "[ET-NAT] One-time rules applied." >> "$LOG_FILE" | ||||||
|  |         ;; | ||||||
|  |     del) | ||||||
|  |         flush_rules | ||||||
|  |         ;; | ||||||
|  |     *) | ||||||
|  |         echo "Usage: $0 [add|del]" | ||||||
|  |         exit 1 | ||||||
|  |         ;; | ||||||
|  | esac | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| id=easytier_magisk | id=easytier_magisk | ||||||
| name=EasyTier_Magisk | name=EasyTier_Magisk | ||||||
| version=v2.3.2 | version=v2.4.5 | ||||||
| versionCode=1 | versionCode=1 | ||||||
| author=EasyTier | author=EasyTier | ||||||
| description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier) | description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier) | ||||||
|   | |||||||
| @@ -18,10 +18,7 @@ sed -i 's/$(description=)$[^"]*/\1[状态]关闭中/' "$MODDIR/module.prop" | |||||||
| sleep 3s | sleep 3s | ||||||
|  |  | ||||||
| "${MODDIR}/easytier_core.sh" & | "${MODDIR}/easytier_core.sh" & | ||||||
|  | "${MODDIR}/hotspot_iprule.sh" add & | ||||||
|  |  | ||||||
| # 检查是否启用模块 | # easytier_core.sh 和 hotspot_iprule.sh 都有内部循环做守护, | ||||||
| while [ ! -f ${MODDIR}/disable ]; do  | # 所以这里不需要再做守护了 | ||||||
|     sleep 2 |  | ||||||
| done |  | ||||||
|  |  | ||||||
| pkill easytier-core |  | ||||||
|   | |||||||
							
								
								
									
										9
									
								
								easytier-contrib/easytier-ohrs/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | dist/ | ||||||
|  | target/ | ||||||
|  | .DS_Store | ||||||
|  | .idea/ | ||||||
|  | package/libs | ||||||
|  |  | ||||||
|  | *.har | ||||||
|  |  | ||||||
|  | Cargo.lock | ||||||
							
								
								
									
										5825
									
								
								easytier-contrib/easytier-ohrs/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										46
									
								
								easytier-contrib/easytier-ohrs/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,46 @@ | |||||||
|  | [package] | ||||||
|  | name = "easytier-ohrs" | ||||||
|  | version = "0.1.0" | ||||||
|  | edition = "2024" | ||||||
|  |  | ||||||
|  | [lib] | ||||||
|  | crate-type=["cdylib"] | ||||||
|  |  | ||||||
|  | [dependencies] | ||||||
|  | ohos-hilog-binding = {version = "*", features = ["redirect"]} | ||||||
|  | easytier = { git = "https://github.com/EasyTier/EasyTier.git" } | ||||||
|  | napi-derive-ohos = "1.1" | ||||||
|  | napi-ohos = { version = "1.1", default-features = false, features = [ | ||||||
|  |     "serde-json", | ||||||
|  |     "latin1", | ||||||
|  |     "chrono_date", | ||||||
|  |     "object_indexmap", | ||||||
|  |     "tokio", | ||||||
|  |     "async", | ||||||
|  |     "tokio_rt", | ||||||
|  |     "tokio_macros", | ||||||
|  |     "tokio_io_util", | ||||||
|  |     "deferred_trace", | ||||||
|  |     "napi8", | ||||||
|  |     "node_version_detect", | ||||||
|  |     "web_stream", | ||||||
|  | ] } | ||||||
|  | once_cell = "1.21.3" | ||||||
|  | serde_json = "1.0.125" | ||||||
|  | tracing-subscriber = "0.3.19" | ||||||
|  | tracing-core = "0.1.33" | ||||||
|  | tracing = "0.1.41" | ||||||
|  | uuid = { version = "1.17.0", features = ["v4"] } | ||||||
|  |  | ||||||
|  | [build-dependencies] | ||||||
|  | napi-build-ohos = "1.1" | ||||||
|  | [profile.dev] | ||||||
|  | panic = "unwind" | ||||||
|  | debug = true | ||||||
|  |  | ||||||
|  | [profile.release] | ||||||
|  | panic = "abort" | ||||||
|  | lto = true | ||||||
|  | codegen-units = 1 | ||||||
|  | opt-level = 3 | ||||||
|  | strip = true | ||||||
							
								
								
									
										65
									
								
								easytier-contrib/easytier-ohrs/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,65 @@ | |||||||
|  | # OpenHarmonyOS 项目构建说明 | ||||||
|  |  | ||||||
|  | 本项目需要 OpenHarmonyOS SDK 和多个基础库支持才能成功编译。请按照以下步骤准备构建环境。 | ||||||
|  | 如存在任何编译问题,请前往[Easytier for OHOS](https://github.com/FrankHan052176/EasyTier) | ||||||
|  |  | ||||||
|  | ## 前置要求 | ||||||
|  |  | ||||||
|  | ### 1. 安装 OpenHarmonyOS SDK | ||||||
|  |  | ||||||
|  | **SDK 下载链接**:   | ||||||
|  | [OpenHarmony 每日构建版本](https://ci.openharmony.cn/workbench/cicd/dailybuild/dailylist) | ||||||
|  |  | ||||||
|  | **版本要求**:   | ||||||
|  | 请选择版本号 **小于 OpenHarmony_5.1.0.58** 的 ohos-sdk-full 版本 | ||||||
|  |  | ||||||
|  | 下载后请解压到适当位置(如 `/usr/local/ohos-sdk`),并记下安装路径。 | ||||||
|  |  | ||||||
|  | ### 2. 编译依赖库 | ||||||
|  | 在编译本项目前,需要先自行编译以下四个基础库: | ||||||
|  |  | ||||||
|  | - glib | ||||||
|  | - libffi | ||||||
|  | - pcre2 | ||||||
|  | - zlib | ||||||
|  |  | ||||||
|  | 这些库需要使用 OpenHarmonyOS 的工具链进行交叉编译。 | ||||||
|  |  | ||||||
|  | ## 环境配置 | ||||||
|  |  | ||||||
|  | ### 1. 设置环境变量 | ||||||
|  | 创建并运行以下脚本设置环境变量(请根据您的实际 SDK 安装路径修改): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | #!/bin/bash | ||||||
|  | # 请修改为您的实际 SDK 路径 | ||||||
|  | export OHOS_SDK_PATH="/usr/local/ohos-sdk/linux" | ||||||
|  | export OHOS_TOOLCHAIN_DIR="${OHOS_SDK_PATH}/native/llvm" | ||||||
|  | export TARGET_ARCH="aarch64-linux-ohos" | ||||||
|  | export OHOS_SYSROOT="${OHOS_SDK_PATH}/native/sysroot" | ||||||
|  | export CC="${OHOS_TOOLCHAIN_DIR}/bin/aarch64-unknown-linux-ohos-clang" | ||||||
|  | export CXX="${OHOS_TOOLCHAIN_DIR}/bin/aarch64-unknown-linux-ohos-clang++" | ||||||
|  | export AS="${OHOS_TOOLCHAIN_DIR}/bin/llvm-as" | ||||||
|  | export AR="${OHOS_TOOLCHAIN_DIR}/bin/llvm-ar" | ||||||
|  | export LD="${OHOS_TOOLCHAIN_DIR}/bin/ld.lld" | ||||||
|  | export RANLIB="${OHOS_TOOLCHAIN_DIR}/bin/llvm-ranlib" | ||||||
|  | export STRIP="${OHOS_TOOLCHAIN_DIR}/bin/llvm-strip" | ||||||
|  | export OBJDUMP="${OHOS_TOOLCHAIN_DIR}/bin/llvm-objdump" | ||||||
|  | export OBJCOPY="${OHOS_TOOLCHAIN_DIR}/bin/llvm-objcopy" | ||||||
|  | export NM="${OHOS_TOOLCHAIN_DIR}/bin/llvm-nm" | ||||||
|  | export CFLAGS="-fPIC -D__MUSL__=1 -march=armv8-a --target=${TARGET_ARCH} -Wno-error --sysroot=${OHOS_SYSROOT} -I${OHOS_SYSROOT}/usr/include/${TARGET_ARCH}" | ||||||
|  | export CXXFLAGS="${CFLAGS}" | ||||||
|  | export LDFLAGS="--sysroot=${OHOS_SYSROOT} -L${OHOS_SYSROOT}/usr/lib/${TARGET_ARCH} -fuse-ld=${LD}" | ||||||
|  | export PKG_CONFIG_PATH="${OHOS_SYSROOT}/usr/lib/pkgconfig:${OHOS_SYSROOT}/usr/local/lib/pkgconfig" | ||||||
|  | export PKG_CONFIG_LIBDIR="${OHOS_SYSROOT}/usr/lib:${OHOS_SYSROOT}/usr/local/lib" | ||||||
|  | export PKG_CONFIG_SYSROOT_DIR="${OHOS_SYSROOT}" | ||||||
|  | export HOST_TRIPLET="${TARGET_ARCH}" | ||||||
|  | export BUILD_TRIPLET="$(dpkg-architecture -qDEB_BUILD_GNU_TYPE)" | ||||||
|  | export PATH="${OHOS_TOOLCHAIN_DIR}/bin:${PATH}" | ||||||
|  |  | ||||||
|  | echo "OpenHarmonyOS 环境变量已设置:" | ||||||
|  | echo "OHOS_SDK_PATH: ${OHOS_SDK_PATH}" | ||||||
|  | echo "OHOS_TOOLCHAIN_DIR: ${OHOS_TOOLCHAIN_DIR}" | ||||||
|  | echo "OHOS_SYSROOT: ${OHOS_SYSROOT}" | ||||||
|  | echo "PKG_CONFIG_PATH: ${PKG_CONFIG_PATH}" | ||||||
|  | echo "PATH: ${PATH}" | ||||||
							
								
								
									
										3
									
								
								easytier-contrib/easytier-ohrs/build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | fn main() { | ||||||
|  |     napi_build_ohos::setup(); | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								easytier-contrib/easytier-ohrs/env.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | |||||||
|  | #!/bin/bash | ||||||
|  | # 请修改为您的实际 SDK 路径 | ||||||
|  | export OHOS_TOOLCHAIN_DIR="${OHOS_NDK_HOME}/native/llvm" | ||||||
|  | export TARGET_ARCH="aarch64-linux-ohos" | ||||||
|  | export OHOS_SYSROOT="${OHOS_NDK_HOME}/native/sysroot" | ||||||
|  | export CC="${OHOS_TOOLCHAIN_DIR}/bin/aarch64-unknown-linux-ohos-clang" | ||||||
|  | export CXX="${OHOS_TOOLCHAIN_DIR}/bin/aarch64-unknown-linux-ohos-clang++" | ||||||
|  | export AS="${OHOS_TOOLCHAIN_DIR}/bin/llvm-as" | ||||||
|  | export AR="${OHOS_TOOLCHAIN_DIR}/bin/llvm-ar" | ||||||
|  | export LD="${OHOS_TOOLCHAIN_DIR}/bin/ld.lld" | ||||||
|  | export RANLIB="${OHOS_TOOLCHAIN_DIR}/bin/llvm-ranlib" | ||||||
|  | export STRIP="${OHOS_TOOLCHAIN_DIR}/bin/llvm-strip" | ||||||
|  | export OBJDUMP="${OHOS_TOOLCHAIN_DIR}/bin/llvm-objdump" | ||||||
|  | export OBJCOPY="${OHOS_TOOLCHAIN_DIR}/bin/llvm-objcopy" | ||||||
|  | export NM="${OHOS_TOOLCHAIN_DIR}/bin/llvm-nm" | ||||||
|  | export CFLAGS="-fPIC -D__MUSL__=1 -march=armv8-a --target=${TARGET_ARCH} -Wno-error --sysroot=${OHOS_SYSROOT} -I${OHOS_SYSROOT}/usr/include/${TARGET_ARCH}" | ||||||
|  | export CXXFLAGS="${CFLAGS}" | ||||||
|  | export LDFLAGS="--sysroot=${OHOS_SYSROOT} -L${OHOS_SYSROOT}/usr/lib/${TARGET_ARCH} -fuse-ld=${LD}" | ||||||
|  | export PKG_CONFIG_PATH="${OHOS_SYSROOT}/usr/lib/pkgconfig:${OHOS_SYSROOT}/usr/local/lib/pkgconfig" | ||||||
|  | export PKG_CONFIG_LIBDIR="${OHOS_SYSROOT}/usr/lib:${OHOS_SYSROOT}/usr/local/lib" | ||||||
|  | export PKG_CONFIG_SYSROOT_DIR="${OHOS_SYSROOT}" | ||||||
|  | export HOST_TRIPLET="${TARGET_ARCH}" | ||||||
|  | export BUILD_TRIPLET="$(dpkg-architecture -qDEB_BUILD_GNU_TYPE)" | ||||||
|  | export PATH="${OHOS_TOOLCHAIN_DIR}/bin:${PATH}" | ||||||
|  |  | ||||||
|  | echo "OpenHarmonyOS 环境变量已设置:" | ||||||
|  | echo "OHOS_SDK_PATH: ${OHOS_NDK_HOME}" | ||||||
|  | echo "OHOS_TOOLCHAIN_DIR: ${OHOS_TOOLCHAIN_DIR}" | ||||||
|  | echo "OHOS_SYSROOT: ${OHOS_SYSROOT}" | ||||||
|  | echo "PKG_CONFIG_PATH: ${PKG_CONFIG_PATH}" | ||||||
|  | echo "PATH: ${PATH}" | ||||||
							
								
								
									
										2
									
								
								easytier-contrib/easytier-ohrs/package/CHANGELOG.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | |||||||
|  | # 0.0.1 | ||||||
|  | - init package | ||||||
							
								
								
									
										165
									
								
								easytier-contrib/easytier-ohrs/package/LICENSE
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,165 @@ | |||||||
|  |                    GNU LESSER GENERAL PUBLIC LICENSE | ||||||
|  |                        Version 3, 29 June 2007 | ||||||
|  |  | ||||||
|  |  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | ||||||
|  |  Everyone is permitted to copy and distribute verbatim copies | ||||||
|  |  of this license document, but changing it is not allowed. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   This version of the GNU Lesser General Public License incorporates | ||||||
|  | the terms and conditions of version 3 of the GNU General Public | ||||||
|  | License, supplemented by the additional permissions listed below. | ||||||
|  |  | ||||||
|  |   0. Additional Definitions. | ||||||
|  |  | ||||||
|  |   As used herein, "this License" refers to version 3 of the GNU Lesser | ||||||
|  | General Public License, and the "GNU GPL" refers to version 3 of the GNU | ||||||
|  | General Public License. | ||||||
|  |  | ||||||
|  |   "The Library" refers to a covered work governed by this License, | ||||||
|  | other than an Application or a Combined Work as defined below. | ||||||
|  |  | ||||||
|  |   An "Application" is any work that makes use of an interface provided | ||||||
|  | by the Library, but which is not otherwise based on the Library. | ||||||
|  | Defining a subclass of a class defined by the Library is deemed a mode | ||||||
|  | of using an interface provided by the Library. | ||||||
|  |  | ||||||
|  |   A "Combined Work" is a work produced by combining or linking an | ||||||
|  | Application with the Library.  The particular version of the Library | ||||||
|  | with which the Combined Work was made is also called the "Linked | ||||||
|  | Version". | ||||||
|  |  | ||||||
|  |   The "Minimal Corresponding Source" for a Combined Work means the | ||||||
|  | Corresponding Source for the Combined Work, excluding any source code | ||||||
|  | for portions of the Combined Work that, considered in isolation, are | ||||||
|  | based on the Application, and not on the Linked Version. | ||||||
|  |  | ||||||
|  |   The "Corresponding Application Code" for a Combined Work means the | ||||||
|  | object code and/or source code for the Application, including any data | ||||||
|  | and utility programs needed for reproducing the Combined Work from the | ||||||
|  | Application, but excluding the System Libraries of the Combined Work. | ||||||
|  |  | ||||||
|  |   1. Exception to Section 3 of the GNU GPL. | ||||||
|  |  | ||||||
|  |   You may convey a covered work under sections 3 and 4 of this License | ||||||
|  | without being bound by section 3 of the GNU GPL. | ||||||
|  |  | ||||||
|  |   2. Conveying Modified Versions. | ||||||
|  |  | ||||||
|  |   If you modify a copy of the Library, and, in your modifications, a | ||||||
|  | facility refers to a function or data to be supplied by an Application | ||||||
|  | that uses the facility (other than as an argument passed when the | ||||||
|  | facility is invoked), then you may convey a copy of the modified | ||||||
|  | version: | ||||||
|  |  | ||||||
|  |    a) under this License, provided that you make a good faith effort to | ||||||
|  |    ensure that, in the event an Application does not supply the | ||||||
|  |    function or data, the facility still operates, and performs | ||||||
|  |    whatever part of its purpose remains meaningful, or | ||||||
|  |  | ||||||
|  |    b) under the GNU GPL, with none of the additional permissions of | ||||||
|  |    this License applicable to that copy. | ||||||
|  |  | ||||||
|  |   3. Object Code Incorporating Material from Library Header Files. | ||||||
|  |  | ||||||
|  |   The object code form of an Application may incorporate material from | ||||||
|  | a header file that is part of the Library.  You may convey such object | ||||||
|  | code under terms of your choice, provided that, if the incorporated | ||||||
|  | material is not limited to numerical parameters, data structure | ||||||
|  | layouts and accessors, or small macros, inline functions and templates | ||||||
|  | (ten or fewer lines in length), you do both of the following: | ||||||
|  |  | ||||||
|  |    a) Give prominent notice with each copy of the object code that the | ||||||
|  |    Library is used in it and that the Library and its use are | ||||||
|  |    covered by this License. | ||||||
|  |  | ||||||
|  |    b) Accompany the object code with a copy of the GNU GPL and this license | ||||||
|  |    document. | ||||||
|  |  | ||||||
|  |   4. Combined Works. | ||||||
|  |  | ||||||
|  |   You may convey a Combined Work under terms of your choice that, | ||||||
|  | taken together, effectively do not restrict modification of the | ||||||
|  | portions of the Library contained in the Combined Work and reverse | ||||||
|  | engineering for debugging such modifications, if you also do each of | ||||||
|  | the following: | ||||||
|  |  | ||||||
|  |    a) Give prominent notice with each copy of the Combined Work that | ||||||
|  |    the Library is used in it and that the Library and its use are | ||||||
|  |    covered by this License. | ||||||
|  |  | ||||||
|  |    b) Accompany the Combined Work with a copy of the GNU GPL and this license | ||||||
|  |    document. | ||||||
|  |  | ||||||
|  |    c) For a Combined Work that displays copyright notices during | ||||||
|  |    execution, include the copyright notice for the Library among | ||||||
|  |    these notices, as well as a reference directing the user to the | ||||||
|  |    copies of the GNU GPL and this license document. | ||||||
|  |  | ||||||
|  |    d) Do one of the following: | ||||||
|  |  | ||||||
|  |        0) Convey the Minimal Corresponding Source under the terms of this | ||||||
|  |        License, and the Corresponding Application Code in a form | ||||||
|  |        suitable for, and under terms that permit, the user to | ||||||
|  |        recombine or relink the Application with a modified version of | ||||||
|  |        the Linked Version to produce a modified Combined Work, in the | ||||||
|  |        manner specified by section 6 of the GNU GPL for conveying | ||||||
|  |        Corresponding Source. | ||||||
|  |  | ||||||
|  |        1) Use a suitable shared library mechanism for linking with the | ||||||
|  |        Library.  A suitable mechanism is one that (a) uses at run time | ||||||
|  |        a copy of the Library already present on the user's computer | ||||||
|  |        system, and (b) will operate properly with a modified version | ||||||
|  |        of the Library that is interface-compatible with the Linked | ||||||
|  |        Version. | ||||||
|  |  | ||||||
|  |    e) Provide Installation Information, but only if you would otherwise | ||||||
|  |    be required to provide such information under section 6 of the | ||||||
|  |    GNU GPL, and only to the extent that such information is | ||||||
|  |    necessary to install and execute a modified version of the | ||||||
|  |    Combined Work produced by recombining or relinking the | ||||||
|  |    Application with a modified version of the Linked Version. (If | ||||||
|  |    you use option 4d0, the Installation Information must accompany | ||||||
|  |    the Minimal Corresponding Source and Corresponding Application | ||||||
|  |    Code. If you use option 4d1, you must provide the Installation | ||||||
|  |    Information in the manner specified by section 6 of the GNU GPL | ||||||
|  |    for conveying Corresponding Source.) | ||||||
|  |  | ||||||
|  |   5. Combined Libraries. | ||||||
|  |  | ||||||
|  |   You may place library facilities that are a work based on the | ||||||
|  | Library side by side in a single library together with other library | ||||||
|  | facilities that are not Applications and are not covered by this | ||||||
|  | License, and convey such a combined library under terms of your | ||||||
|  | choice, if you do both of the following: | ||||||
|  |  | ||||||
|  |    a) Accompany the combined library with a copy of the same work based | ||||||
|  |    on the Library, uncombined with any other library facilities, | ||||||
|  |    conveyed under the terms of this License. | ||||||
|  |  | ||||||
|  |    b) Give prominent notice with the combined library that part of it | ||||||
|  |    is a work based on the Library, and explaining where to find the | ||||||
|  |    accompanying uncombined form of the same work. | ||||||
|  |  | ||||||
|  |   6. Revised Versions of the GNU Lesser General Public License. | ||||||
|  |  | ||||||
|  |   The Free Software Foundation may publish revised and/or new versions | ||||||
|  | of the GNU Lesser General Public License from time to time. Such new | ||||||
|  | versions will be similar in spirit to the present version, but may | ||||||
|  | differ in detail to address new problems or concerns. | ||||||
|  |  | ||||||
|  |   Each version is given a distinguishing version number. If the | ||||||
|  | Library as you received it specifies that a certain numbered version | ||||||
|  | of the GNU Lesser General Public License "or any later version" | ||||||
|  | applies to it, you have the option of following the terms and | ||||||
|  | conditions either of that published version or of any later version | ||||||
|  | published by the Free Software Foundation. If the Library as you | ||||||
|  | received it does not specify a version number of the GNU Lesser | ||||||
|  | General Public License, you may choose any version of the GNU Lesser | ||||||
|  | General Public License ever published by the Free Software Foundation. | ||||||
|  |  | ||||||
|  |   If the Library as you received it specifies that a proxy can decide | ||||||
|  | whether future versions of the GNU Lesser General Public License shall | ||||||
|  | apply, that proxy's public statement of acceptance of any version is | ||||||
|  | permanent authorization for you to choose that version for the | ||||||
|  | Library. | ||||||
							
								
								
									
										21
									
								
								easytier-contrib/easytier-ohrs/package/README.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | # `easytier-ohrs` | ||||||
|  |  | ||||||
|  | ## Install | ||||||
|  |  | ||||||
|  | use `ohpm` to install package. | ||||||
|  |  | ||||||
|  | ```shell | ||||||
|  | ohpm install easytier-ohrs | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## API | ||||||
|  |  | ||||||
|  | ```ts | ||||||
|  | // todo | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Usage | ||||||
|  |  | ||||||
|  | ```ts | ||||||
|  | // todo | ||||||
|  | ``` | ||||||
							
								
								
									
										4
									
								
								easytier-contrib/easytier-ohrs/package/index.ets
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | |||||||
|  | import * as api from "libeasytier_ohrs.so"; | ||||||
|  |  | ||||||
|  | export * from 'libeasytier_ohrs.so'; | ||||||
|  | export default api; | ||||||
							
								
								
									
										10
									
								
								easytier-contrib/easytier-ohrs/package/oh-package.json5
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | |||||||
|  | { | ||||||
|  |   "license": "LGPL-3.0", | ||||||
|  |   "author": "easytier", | ||||||
|  |   "name": "easytier-ohrs", | ||||||
|  |   "description": "", | ||||||
|  |   "main": "index.ets", | ||||||
|  |   "version": "0.0.1", | ||||||
|  |   "types": "libs/index.d.ts", | ||||||
|  |   "dependencies": {} | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								easytier-contrib/easytier-ohrs/package/src/main/module.json5
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | |||||||
|  | { | ||||||
|  |   "module": { | ||||||
|  |     "name": "easytier-ohrs", | ||||||
|  |     "type": "har", | ||||||
|  |     "deviceTypes": ["default", "tablet", "2in1"] | ||||||
|  |   }, | ||||||
|  | } | ||||||
							
								
								
									
										136
									
								
								easytier-contrib/easytier-ohrs/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,136 @@ | |||||||
|  | mod native_log; | ||||||
|  |  | ||||||
|  | use easytier::common::config::{ConfigLoader, TomlConfigLoader}; | ||||||
|  | use easytier::instance_manager::NetworkInstanceManager; | ||||||
|  | use easytier::launcher::ConfigSource; | ||||||
|  | use napi_derive_ohos::napi; | ||||||
|  | use ohos_hilog_binding::{hilog_debug, hilog_error}; | ||||||
|  | use std::format; | ||||||
|  | use uuid::Uuid; | ||||||
|  |  | ||||||
|  | static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> = | ||||||
|  |     once_cell::sync::Lazy::new(NetworkInstanceManager::new); | ||||||
|  |  | ||||||
|  | #[napi(object)] | ||||||
|  | pub struct KeyValuePair { | ||||||
|  |     pub key: String, | ||||||
|  |     pub value: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn set_tun_fd(inst_id: String, fd: i32) -> bool { | ||||||
|  |     match Uuid::try_parse(&inst_id) { | ||||||
|  |         Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) { | ||||||
|  |             Ok(_) => { | ||||||
|  |                 hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id); | ||||||
|  |                 true | ||||||
|  |             } | ||||||
|  |             Err(e) => { | ||||||
|  |                 hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e); | ||||||
|  |                 false | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         Err(e) => { | ||||||
|  |             hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e); | ||||||
|  |             false | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn parse_config(cfg_str: String) -> bool { | ||||||
|  |     match TomlConfigLoader::new_from_str(&cfg_str) { | ||||||
|  |         Ok(_) => true, | ||||||
|  |         Err(e) => { | ||||||
|  |             hilog_error!("[Rust] parse config failed {}", e); | ||||||
|  |             false | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn run_network_instance(cfg_str: String) -> bool { | ||||||
|  |     let cfg = match TomlConfigLoader::new_from_str(&cfg_str) { | ||||||
|  |         Ok(cfg) => cfg, | ||||||
|  |         Err(e) => { | ||||||
|  |             hilog_error!("[Rust] parse config failed {}", e); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 { | ||||||
|  |         hilog_error!("[Rust] there is a running instance!"); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let inst_id = cfg.get_id(); | ||||||
|  |     if INSTANCE_MANAGER | ||||||
|  |         .list_network_instance_ids() | ||||||
|  |         .contains(&inst_id) | ||||||
|  |     { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     INSTANCE_MANAGER | ||||||
|  |         .run_network_instance(cfg, ConfigSource::FFI) | ||||||
|  |         .unwrap(); | ||||||
|  |     true | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn stop_network_instance(inst_names: Vec<String>) { | ||||||
|  |     INSTANCE_MANAGER | ||||||
|  |         .delete_network_instance( | ||||||
|  |             inst_names | ||||||
|  |                 .into_iter() | ||||||
|  |                 .filter_map(|s| Uuid::parse_str(&s).ok()) | ||||||
|  |                 .collect(), | ||||||
|  |         ) | ||||||
|  |         .unwrap(); | ||||||
|  |     hilog_debug!("[Rust] stop_network_instance"); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn collect_network_infos() -> Vec<KeyValuePair> { | ||||||
|  |     let mut result = Vec::new(); | ||||||
|  |     match INSTANCE_MANAGER.collect_network_infos_sync() { | ||||||
|  |         Ok(map) => { | ||||||
|  |             for (uuid, info) in map.iter() { | ||||||
|  |                 // convert value to json string | ||||||
|  |                 let value = match serde_json::to_string(&info) { | ||||||
|  |                     Ok(value) => value, | ||||||
|  |                     Err(e) => { | ||||||
|  |                         hilog_error!("[Rust] failed to serialize instance {} info: {}", uuid, e); | ||||||
|  |                         continue; | ||||||
|  |                     } | ||||||
|  |                 }; | ||||||
|  |                 result.push(KeyValuePair { | ||||||
|  |                     key: uuid.clone().to_string(), | ||||||
|  |                     value: value.clone(), | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         Err(_) => {} | ||||||
|  |     } | ||||||
|  |     result | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn collect_running_network() -> Vec<String> { | ||||||
|  |     INSTANCE_MANAGER | ||||||
|  |         .list_network_instance_ids() | ||||||
|  |         .clone() | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|id| id.to_string()) | ||||||
|  |         .collect() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn is_running_network(inst_id: String) -> bool { | ||||||
|  |     match Uuid::try_parse(&inst_id) { | ||||||
|  |         Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid), | ||||||
|  |         Err(e) => { | ||||||
|  |             hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e); | ||||||
|  |             false | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										96
									
								
								easytier-contrib/easytier-ohrs/src/native_log.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,96 @@ | |||||||
|  | use napi_derive_ohos::napi; | ||||||
|  | use ohos_hilog_binding::{ | ||||||
|  |     LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options, | ||||||
|  | }; | ||||||
|  | use std::collections::HashMap; | ||||||
|  | use std::panic; | ||||||
|  | use tracing::{Event, Subscriber}; | ||||||
|  | use tracing_core::Level; | ||||||
|  | use tracing_subscriber::layer::{Context, Layer}; | ||||||
|  | use tracing_subscriber::prelude::*; | ||||||
|  |  | ||||||
|  | static INITIALIZED: std::sync::Once = std::sync::Once::new(); | ||||||
|  | fn panic_hook(info: &panic::PanicHookInfo) { | ||||||
|  |     hilog_error!("RUST PANIC: {}", info); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn init_panic_hook() { | ||||||
|  |     INITIALIZED.call_once(|| { | ||||||
|  |         panic::set_hook(Box::new(panic_hook)); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn hilog_global_options(domain: u32, tag: String) { | ||||||
|  |     ohos_hilog_binding::forward_stdio_to_hilog(); | ||||||
|  |     set_global_options(LogOptions { | ||||||
|  |         domain, | ||||||
|  |         tag: Box::leak(tag.clone().into_boxed_str()), | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[napi] | ||||||
|  | pub fn init_tracing_subscriber() { | ||||||
|  |     tracing_subscriber::registry() | ||||||
|  |         .with(CallbackLayer { | ||||||
|  |             callback: Box::new(tracing_callback), | ||||||
|  |         }) | ||||||
|  |         .init(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn tracing_callback(event: &Event, fields: HashMap<String, String>) { | ||||||
|  |     let metadata = event.metadata(); | ||||||
|  |     #[cfg(target_env = "ohos")] | ||||||
|  |     { | ||||||
|  |         let loc = metadata.target().split("::").last().unwrap(); | ||||||
|  |         match *metadata.level() { | ||||||
|  |             Level::TRACE => { | ||||||
|  |                 hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()); | ||||||
|  |             } | ||||||
|  |             Level::DEBUG => { | ||||||
|  |                 hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()); | ||||||
|  |             } | ||||||
|  |             Level::INFO => { | ||||||
|  |                 hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()); | ||||||
|  |             } | ||||||
|  |             Level::WARN => { | ||||||
|  |                 hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()); | ||||||
|  |             } | ||||||
|  |             Level::ERROR => { | ||||||
|  |                 hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct CallbackLayer { | ||||||
|  |     callback: Box<dyn Fn(&Event, HashMap<String, String>) + Send + Sync>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<S: Subscriber> Layer<S> for CallbackLayer { | ||||||
|  |     fn on_event(&self, event: &Event, _ctx: Context<S>) { | ||||||
|  |         // 使用 fmt::format::FmtSpan 提取字段值 | ||||||
|  |         let mut fields = HashMap::new(); | ||||||
|  |         let mut visitor = FieldCollector(&mut fields); | ||||||
|  |         event.record(&mut visitor); | ||||||
|  |         (self.callback)(event, fields); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct FieldCollector<'a>(&'a mut HashMap<String, String>); | ||||||
|  |  | ||||||
|  | impl<'a> tracing::field::Visit for FieldCollector<'a> { | ||||||
|  |     fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { | ||||||
|  |         self.0.insert(field.name().to_string(), value.to_string()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn record_str(&mut self, field: &tracing::field::Field, value: &str) { | ||||||
|  |         self.0.insert(field.name().to_string(), value.to_string()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { | ||||||
|  |         self.0 | ||||||
|  |             .insert(field.name().to_string(), format!("{:?}", value)); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								easytier-contrib/easytier-uptime/.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,17 @@ | |||||||
|  | # Development Environment Configuration | ||||||
|  | SERVER_HOST=127.0.0.1 | ||||||
|  | SERVER_PORT=8080 | ||||||
|  | DATABASE_PATH=uptime.db | ||||||
|  | DATABASE_MAX_CONNECTIONS=5 | ||||||
|  | HEALTH_CHECK_INTERVAL=60 | ||||||
|  | HEALTH_CHECK_TIMEOUT=15 | ||||||
|  | HEALTH_CHECK_RETRIES=2 | ||||||
|  | RUST_LOG=debug | ||||||
|  | LOG_LEVEL=debug | ||||||
|  | CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 | ||||||
|  | CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS | ||||||
|  | CORS_ALLOWED_HEADERS=content-type,authorization | ||||||
|  | NODE_ENV=development | ||||||
|  | API_BASE_URL=/api | ||||||
|  | ENABLE_COMPRESSION=true | ||||||
|  | ENABLE_CORS=true | ||||||
							
								
								
									
										17
									
								
								easytier-contrib/easytier-uptime/.env.development
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,17 @@ | |||||||
|  | # Development Environment Configuration | ||||||
|  | SERVER_HOST=127.0.0.1 | ||||||
|  | SERVER_PORT=8080 | ||||||
|  | DATABASE_PATH=uptime.db | ||||||
|  | DATABASE_MAX_CONNECTIONS=5 | ||||||
|  | HEALTH_CHECK_INTERVAL=60 | ||||||
|  | HEALTH_CHECK_TIMEOUT=15 | ||||||
|  | HEALTH_CHECK_RETRIES=2 | ||||||
|  | RUST_LOG=debug | ||||||
|  | LOG_LEVEL=debug | ||||||
|  | CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 | ||||||
|  | CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS | ||||||
|  | CORS_ALLOWED_HEADERS=content-type,authorization | ||||||
|  | NODE_ENV=development | ||||||
|  | API_BASE_URL=/api | ||||||
|  | ENABLE_COMPRESSION=true | ||||||
|  | ENABLE_CORS=true | ||||||
							
								
								
									
										29
									
								
								easytier-contrib/easytier-uptime/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | |||||||
|  | # Server Configuration | ||||||
|  | SERVER_HOST=127.0.0.1 | ||||||
|  | SERVER_PORT=8080 | ||||||
|  |  | ||||||
|  | # Database Configuration | ||||||
|  | DATABASE_PATH=uptime.db | ||||||
|  | DATABASE_MAX_CONNECTIONS=10 | ||||||
|  |  | ||||||
|  | # Health Check Configuration | ||||||
|  | HEALTH_CHECK_INTERVAL=30 | ||||||
|  | HEALTH_CHECK_TIMEOUT=10 | ||||||
|  | HEALTH_CHECK_RETRIES=3 | ||||||
|  |  | ||||||
|  | # Logging Configuration | ||||||
|  | RUST_LOG=info | ||||||
|  | LOG_LEVEL=info | ||||||
|  |  | ||||||
|  | # CORS Configuration | ||||||
|  | CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 | ||||||
|  | CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS | ||||||
|  | CORS_ALLOWED_HEADERS=content-type,authorization | ||||||
|  |  | ||||||
|  | # Production Configuration | ||||||
|  | NODE_ENV=development | ||||||
|  | API_BASE_URL=/api | ||||||
|  |  | ||||||
|  | # Security Configuration | ||||||
|  | ENABLE_COMPRESSION=true | ||||||
|  | ENABLE_CORS=true | ||||||
							
								
								
									
										21
									
								
								easytier-contrib/easytier-uptime/.env.production
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | # Production Environment Configuration | ||||||
|  | SERVER_HOST=0.0.0.0 | ||||||
|  | SERVER_PORT=8080 | ||||||
|  | DATABASE_PATH=/var/lib/easytier-uptime/uptime.db | ||||||
|  | DATABASE_MAX_CONNECTIONS=20 | ||||||
|  | HEALTH_CHECK_INTERVAL=30 | ||||||
|  | HEALTH_CHECK_TIMEOUT=10 | ||||||
|  | HEALTH_CHECK_RETRIES=3 | ||||||
|  | RUST_LOG=info | ||||||
|  | LOG_LEVEL=info | ||||||
|  | CORS_ALLOWED_ORIGINS=https://yourdomain.com | ||||||
|  | CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS | ||||||
|  | CORS_ALLOWED_HEADERS=content-type,authorization | ||||||
|  | NODE_ENV=production | ||||||
|  | API_BASE_URL=/api | ||||||
|  | ENABLE_COMPRESSION=true | ||||||
|  | ENABLE_CORS=true | ||||||
|  |  | ||||||
|  | # Security | ||||||
|  | SECRET_KEY=your-secret-key-here | ||||||
|  | JWT_SECRET=your-jwt-secret-here | ||||||
							
								
								
									
										3
									
								
								easytier-contrib/easytier-uptime/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | *.db | ||||||
|  | *.db-shm | ||||||
|  | *.db-wal | ||||||
							
								
								
									
										64
									
								
								easytier-contrib/easytier-uptime/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,64 @@ | |||||||
|  | [package] | ||||||
|  | name = "easytier-uptime" | ||||||
|  | version = "0.1.0" | ||||||
|  | edition = "2021" | ||||||
|  |  | ||||||
|  | [dependencies] | ||||||
|  | tokio = { version = "1.0", features = ["full"] } | ||||||
|  | tracing = "0.1" | ||||||
|  | tracing-subscriber = { version = "0.3", features = ["env-filter"] } | ||||||
|  | anyhow = "1.0" | ||||||
|  | serde = { version = "1.0", features = ["derive"] } | ||||||
|  | serde_json = "1.0" | ||||||
|  | chrono = { version = "0.4", features = ["serde"] } | ||||||
|  | uuid = { version = "1.0", features = ["v4", "serde"] } | ||||||
|  |  | ||||||
|  | # Axum web framework | ||||||
|  | axum = { version = "0.8.4", features = ["macros"] } | ||||||
|  | axum-extra = { version = "0.10", features = ["query"] } | ||||||
|  | tower-http = { version = "0.6", features = ["cors", "compression-full"] } | ||||||
|  | tower = "0.5" | ||||||
|  |  | ||||||
|  | # SeaORM dependencies | ||||||
|  | sea-orm = { version = "1.1", features = [ | ||||||
|  |     "sqlx-sqlite", | ||||||
|  |     "runtime-tokio-rustls", | ||||||
|  |     "macros", | ||||||
|  |     "with-chrono", | ||||||
|  |     "with-uuid", | ||||||
|  |     "with-json" | ||||||
|  | ] } | ||||||
|  | sea-orm-migration = { version = "1.1" } | ||||||
|  | sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] } | ||||||
|  |  | ||||||
|  | # Validation | ||||||
|  | validator = { version = "0.18", features = ["derive"] } | ||||||
|  | thiserror = "1.0" | ||||||
|  | jsonwebtoken = "9.0" | ||||||
|  |  | ||||||
|  | # Configuration and serialization | ||||||
|  | serde_yaml = "0.9" | ||||||
|  | toml = "0.8" | ||||||
|  |  | ||||||
|  | # Network and async | ||||||
|  | async-trait = "0.1" | ||||||
|  | futures = "0.3" | ||||||
|  | tokio-util = { version = "0.7", features = ["full"] } | ||||||
|  |  | ||||||
|  | # Filesystem operations | ||||||
|  | tempfile = "3.8" | ||||||
|  |  | ||||||
|  | # Additional utilities | ||||||
|  | dashmap = "6.1.0" | ||||||
|  | clap = { version = "4.0", features = ["derive"] } | ||||||
|  | parking_lot = "0.12" | ||||||
|  | once_cell = "1.19" | ||||||
|  |  | ||||||
|  | # EasyTier core | ||||||
|  | easytier = { path = "../../easytier" } | ||||||
|  |  | ||||||
|  | # Testing | ||||||
|  | [dev-dependencies] | ||||||
|  | mockall = "0.12" | ||||||
|  | tokio-test = "0.4" | ||||||
|  | reqwest = "0.12" | ||||||
							
								
								
									
										272
									
								
								easytier-contrib/easytier-uptime/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,272 @@ | |||||||
|  | # EasyTier Uptime Monitor | ||||||
|  |  | ||||||
|  | 一个用于监控 EasyTier 实例健康状态和运行时间的系统。 | ||||||
|  |  | ||||||
|  | ## 功能特性 | ||||||
|  |  | ||||||
|  | - 🏥 **健康监控**: 实时监控 EasyTier 节点的健康状态 | ||||||
|  | - 📊 **数据统计**: 提供详细的运行时间和响应时间统计 | ||||||
|  | - 🔧 **实例管理**: 管理多个 EasyTier 实例 | ||||||
|  | - 🌐 **Web界面**: 直观的 Web 管理界面 | ||||||
|  | - 🚨 **告警系统**: 支持健康状态异常告警 | ||||||
|  | - 📈 **图表展示**: 可视化展示监控数据 | ||||||
|  |  | ||||||
|  | ## 系统架构 | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐ | ||||||
|  | │   Frontend      │    │   Backend       │    │   Database      │ | ||||||
|  | │   (Vue.js)      │◄──►│   (Rust/Axum)   │◄──►│   (SQLite)      │ | ||||||
|  | │                 │    │                 │    │                 │ | ||||||
|  | │ ┌─────────────┐ │    │ ┌─────────────┐ │    │ ┌─────────────┐ │ | ||||||
|  | │ │ Dashboard   │ │    │ │ API Routes  │ │    │ │ Nodes       │ │ | ||||||
|  | │ │ Health View │ │    │ │ Health      │ │    │ │ Health      │ │ | ||||||
|  | │ │ Node Mgmt   │ │    │ │ Instances   │ │    │ │ Instances   │ │ | ||||||
|  | │ │ Charts      │ │    │ │ Scheduler   │ │    │ │ Stats       │ │ | ||||||
|  | │ └─────────────┘ │    │ └─────────────┘ │    │ └─────────────┘ │ | ||||||
|  | └─────────────────┘    └─────────────────┘    └─────────────────┘ | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 快速开始 | ||||||
|  |  | ||||||
|  | ### 环境要求 | ||||||
|  |  | ||||||
|  | - **Rust**: 1.70+ | ||||||
|  | - **Node.js**: 16+ | ||||||
|  | - **npm**: 8+ | ||||||
|  |  | ||||||
|  | ### 开发环境 | ||||||
|  |  | ||||||
|  | 1. **克隆项目** | ||||||
|  |    ```bash | ||||||
|  |    git clone <repository-url> | ||||||
|  |    cd easytier-uptime | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **启动开发环境** | ||||||
|  |    ```bash | ||||||
|  |    ./start-dev.sh | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **访问应用** | ||||||
|  |    - 前端界面: http://localhost:3000 | ||||||
|  |    - 后端API: http://localhost:8080 | ||||||
|  |    - 健康检查: http://localhost:8080/health | ||||||
|  |  | ||||||
|  | ### 生产环境 | ||||||
|  |  | ||||||
|  | 1. **启动生产环境** | ||||||
|  |    ```bash | ||||||
|  |    ./start-prod.sh | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **停止生产环境** | ||||||
|  |    ```bash | ||||||
|  |    ./stop-prod.sh | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## 配置说明 | ||||||
|  |  | ||||||
|  | ### 环境变量 | ||||||
|  |  | ||||||
|  | #### 后端配置 (.env) | ||||||
|  |  | ||||||
|  | | 变量名 | 默认值 | 说明 | | ||||||
|  | |--------|--------|------| | ||||||
|  | | `SERVER_HOST` | `127.0.0.1` | 服务器监听地址 | | ||||||
|  | | `SERVER_PORT` | `8080` | 服务器端口 | | ||||||
|  | | `DATABASE_PATH` | `uptime.db` | 数据库文件路径 | | ||||||
|  | | `DATABASE_MAX_CONNECTIONS` | `10` | 数据库最大连接数 | | ||||||
|  | | `HEALTH_CHECK_INTERVAL` | `30` | 健康检查间隔(秒) | | ||||||
|  | | `HEALTH_CHECK_TIMEOUT` | `10` | 健康检查超时(秒) | | ||||||
|  | | `HEALTH_CHECK_RETRIES` | `3` | 健康检查重试次数 | | ||||||
|  | | `RUST_LOG` | `info` | 日志级别 | | ||||||
|  | | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | 允许的跨域来源 | | ||||||
|  | | `ENABLE_CORS` | `true` | 是否启用CORS | | ||||||
|  | | `ENABLE_COMPRESSION` | `true` | 是否启用压缩 | | ||||||
|  |  | ||||||
|  | #### 前端配置 (frontend/.env) | ||||||
|  |  | ||||||
|  | | 变量名 | 默认值 | 说明 | | ||||||
|  | |--------|--------|------| | ||||||
|  | | `VITE_APP_TITLE` | `EasyTier Uptime Monitor` | 应用标题 | | ||||||
|  | | `VITE_API_BASE_URL` | `/api` | API基础URL | | ||||||
|  | | `VITE_APP_ENV` | `development` | 应用环境 | | ||||||
|  | | `VITE_ENABLE_DEV_TOOLS` | `true` | 是否启用开发工具 | | ||||||
|  | | `VITE_API_TIMEOUT` | `10000` | API超时时间(毫秒) | | ||||||
|  |  | ||||||
|  | ## API 文档 | ||||||
|  |  | ||||||
|  | ### 健康检查 | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | GET /health | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 节点管理 | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | # 获取节点列表 | ||||||
|  | GET /api/nodes | ||||||
|  |  | ||||||
|  | # 创建节点 | ||||||
|  | POST /api/nodes | ||||||
|  |  | ||||||
|  | # 获取节点详情 | ||||||
|  | GET /api/nodes/{id} | ||||||
|  |  | ||||||
|  | # 更新节点 | ||||||
|  | PUT /api/nodes/{id} | ||||||
|  |  | ||||||
|  | # 删除节点 | ||||||
|  | DELETE /api/nodes/{id} | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 健康记录 | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | # 获取节点健康历史 | ||||||
|  | GET /api/nodes/{id}/health | ||||||
|  |  | ||||||
|  | # 获取节点健康统计 | ||||||
|  | GET /api/nodes/{id}/health/stats | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 实例管理 | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | # 获取实例列表 | ||||||
|  | GET /api/instances | ||||||
|  |  | ||||||
|  | # 创建实例 | ||||||
|  | POST /api/instances | ||||||
|  |  | ||||||
|  | # 停止实例 | ||||||
|  | DELETE /api/instances/{id} | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 测试 | ||||||
|  |  | ||||||
|  | ### 运行集成测试 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | ./test-integration.sh | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 运行单元测试 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | cargo test | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 测试覆盖率 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | cargo tarpaulin | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 部署 | ||||||
|  |  | ||||||
|  | ### Docker 部署 | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 构建镜像 | ||||||
|  | docker build -t easytier-uptime . | ||||||
|  |  | ||||||
|  | # 运行容器 | ||||||
|  | docker run -d -p 8080:8080 easytier-uptime | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 手动部署 | ||||||
|  |  | ||||||
|  | 1. **构建后端** | ||||||
|  |    ```bash | ||||||
|  |    cargo build --release | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **构建前端** | ||||||
|  |    ```bash | ||||||
|  |    cd frontend | ||||||
|  |    npm install | ||||||
|  |    npm run build | ||||||
|  |    cd .. | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **配置环境** | ||||||
|  |    ```bash | ||||||
|  |    cp .env.production .env | ||||||
|  |    # 编辑 .env 文件 | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 4. **启动服务** | ||||||
|  |    ```bash | ||||||
|  |    ./start-prod.sh | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## 监控和日志 | ||||||
|  |  | ||||||
|  | ### 日志文件 | ||||||
|  |  | ||||||
|  | - **后端日志**: `logs/backend.log` | ||||||
|  | - **前端日志**: `logs/frontend.log` | ||||||
|  | - **测试日志**: `test-results/` | ||||||
|  |  | ||||||
|  | ### 健康检查 | ||||||
|  |  | ||||||
|  | 系统提供以下健康检查端点: | ||||||
|  |  | ||||||
|  | - `/health` - 基本健康检查 | ||||||
|  | - `/api/health/stats` - 健康统计信息 | ||||||
|  | - `/api/health/scheduler/status` - 调度器状态 | ||||||
|  |  | ||||||
|  | ## 故障排除 | ||||||
|  |  | ||||||
|  | ### 常见问题 | ||||||
|  |  | ||||||
|  | 1. **后端启动失败** | ||||||
|  |    - 检查端口是否被占用 | ||||||
|  |    - 确认数据库文件权限 | ||||||
|  |    - 查看日志文件 `logs/backend.log` | ||||||
|  |  | ||||||
|  | 2. **前端连接失败** | ||||||
|  |    - 检查后端服务是否运行 | ||||||
|  |    - 确认API地址配置 | ||||||
|  |    - 检查CORS配置 | ||||||
|  |  | ||||||
|  | 3. **健康检查失败** | ||||||
|  |    - 确认目标节点可访问 | ||||||
|  |    - 检查防火墙设置 | ||||||
|  |    - 验证健康检查配置 | ||||||
|  |  | ||||||
|  | ### 性能优化 | ||||||
|  |  | ||||||
|  | 1. **数据库优化** | ||||||
|  |    - 定期清理过期数据 | ||||||
|  |    - 配置适当的连接池大小 | ||||||
|  |    - 使用索引优化查询 | ||||||
|  |  | ||||||
|  | 2. **前端优化** | ||||||
|  |    - 启用代码分割 | ||||||
|  |    - 配置缓存策略 | ||||||
|  |    - 优化图片和资源 | ||||||
|  |  | ||||||
|  | 3. **网络优化** | ||||||
|  |    - 启用压缩 | ||||||
|  |    - 配置CDN | ||||||
|  |    - 优化API响应时间 | ||||||
|  |  | ||||||
|  | ## 贡献指南 | ||||||
|  |  | ||||||
|  | 1. Fork 项目 | ||||||
|  | 2. 创建特性分支 | ||||||
|  | 3. 提交更改 | ||||||
|  | 4. 推送到分支 | ||||||
|  | 5. 创建 Pull Request | ||||||
|  |  | ||||||
|  | ## 许可证 | ||||||
|  |  | ||||||
|  | MIT License | ||||||
|  |  | ||||||
|  | ## 支持 | ||||||
|  |  | ||||||
|  | 如有问题或建议,请提交 Issue 或联系开发团队。 | ||||||
							
								
								
									
										24
									
								
								easytier-contrib/easytier-uptime/frontend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | |||||||
|  | # Logs | ||||||
|  | logs | ||||||
|  | *.log | ||||||
|  | npm-debug.log* | ||||||
|  | yarn-debug.log* | ||||||
|  | yarn-error.log* | ||||||
|  | pnpm-debug.log* | ||||||
|  | lerna-debug.log* | ||||||
|  |  | ||||||
|  | node_modules | ||||||
|  | dist | ||||||
|  | dist-ssr | ||||||
|  | *.local | ||||||
|  |  | ||||||
|  | # Editor directories and files | ||||||
|  | .vscode/* | ||||||
|  | !.vscode/extensions.json | ||||||
|  | .idea | ||||||
|  | .DS_Store | ||||||
|  | *.suo | ||||||
|  | *.ntvs* | ||||||
|  | *.njsproj | ||||||
|  | *.sln | ||||||
|  | *.sw? | ||||||
							
								
								
									
										5
									
								
								easytier-contrib/easytier-uptime/frontend/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | # Vue 3 + Vite | ||||||
|  |  | ||||||
|  | This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. | ||||||
|  |  | ||||||
|  | Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). | ||||||
							
								
								
									
										13
									
								
								easytier-contrib/easytier-uptime/frontend/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | |||||||
|  | <!doctype html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="UTF-8" /> | ||||||
|  |     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
|  |     <title>Vite + Vue</title> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <div id="app"></div> | ||||||
|  |     <script type="module" src="/src/main.js"></script> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										2557
									
								
								easytier-contrib/easytier-uptime/frontend/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										26
									
								
								easytier-contrib/easytier-uptime/frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,26 @@ | |||||||
|  | { | ||||||
|  |   "name": "easytier-uptime-frontend", | ||||||
|  |   "private": true, | ||||||
|  |   "version": "0.0.0", | ||||||
|  |   "type": "module", | ||||||
|  |   "scripts": { | ||||||
|  |     "dev": "vite", | ||||||
|  |     "build": "vite build", | ||||||
|  |     "preview": "vite preview" | ||||||
|  |   }, | ||||||
|  |   "dependencies": { | ||||||
|  |     "@element-plus/icons-vue": "^2.3.1", | ||||||
|  |     "axios": "^1.7.9", | ||||||
|  |     "dayjs": "^1.11.13", | ||||||
|  |     "easytier-uptime-frontend": "link:", | ||||||
|  |     "element-plus": "^2.8.8", | ||||||
|  |     "vue": "^3.5.18", | ||||||
|  |     "vue-router": "^4.4.5" | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "@vitejs/plugin-vue": "^6.0.1", | ||||||
|  |     "unplugin-auto-import": "^0.18.6", | ||||||
|  |     "unplugin-vue-components": "^0.27.4", | ||||||
|  |     "vite": "^7.1.2" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | ||||||
| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										340
									
								
								easytier-contrib/easytier-uptime/frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,340 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, onMounted, computed } from 'vue' | ||||||
|  | import { useRouter, useRoute } from 'vue-router' | ||||||
|  | import { healthApi } from './api' | ||||||
|  | import { | ||||||
|  |   Monitor, | ||||||
|  |   Plus, | ||||||
|  |   CircleCheck, | ||||||
|  |   CircleClose, | ||||||
|  |   Loading, | ||||||
|  |   Link | ||||||
|  | } from '@element-plus/icons-vue' | ||||||
|  |  | ||||||
|  | const router = useRouter() | ||||||
|  | const route = useRoute() | ||||||
|  | const healthStatus = ref(null) | ||||||
|  | const loading = ref(false) | ||||||
|  |  | ||||||
|  | // 安全地打开外部链接 | ||||||
|  | const openExternalLink = (url) => { | ||||||
|  |   try { | ||||||
|  |     if (typeof window !== 'undefined' && window.open) { | ||||||
|  |       window.open(url, '_blank') | ||||||
|  |     } else { | ||||||
|  |       // 备用方案:创建一个临时链接元素 | ||||||
|  |       const link = document.createElement('a') | ||||||
|  |       link.href = url | ||||||
|  |       link.target = '_blank' | ||||||
|  |       link.rel = 'noopener noreferrer' | ||||||
|  |       document.body.appendChild(link) | ||||||
|  |       link.click() | ||||||
|  |       document.body.removeChild(link) | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Failed to open external link:', error) | ||||||
|  |     // 最后的备用方案:直接跳转 | ||||||
|  |     if (typeof window !== 'undefined') { | ||||||
|  |       window.location.href = url | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 检查后端健康状态 | ||||||
|  | const checkHealth = async () => { | ||||||
|  |   try { | ||||||
|  |     loading.value = true | ||||||
|  |     const response = await healthApi.check() | ||||||
|  |     healthStatus.value = response.success | ||||||
|  |   } catch (error) { | ||||||
|  |     healthStatus.value = false | ||||||
|  |     console.error('Health check failed:', error) | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 导航菜单项 | ||||||
|  | const menuItems = [ | ||||||
|  |   { | ||||||
|  |     path: '/', | ||||||
|  |     name: 'dashboard', | ||||||
|  |     title: '节点监控', | ||||||
|  |     icon: 'Monitor' | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: '/submit', | ||||||
|  |     name: 'submit', | ||||||
|  |     title: '提交节点', | ||||||
|  |     icon: 'Plus' | ||||||
|  |   } | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | // 根据当前路由计算默认激活的菜单项 | ||||||
|  | const activeMenuIndex = computed(() => { | ||||||
|  |   const p = route.path | ||||||
|  |   if (p.startsWith('/submit')) return 'submit' | ||||||
|  |   return 'dashboard' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 处理菜单选择,避免返回 Promise 导致异步补丁问题 | ||||||
|  | const handleMenuSelect = (key) => { | ||||||
|  |   const item = menuItems.find((i) => i.name === key) | ||||||
|  |   if (item && item.path) { | ||||||
|  |     router.push(item.path) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | onMounted(() => { | ||||||
|  |   checkHealth() | ||||||
|  |   // 定期检查健康状态 | ||||||
|  |   setInterval(checkHealth, 60000) // 每分钟检查一次 | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div id="app"> | ||||||
|  |     <!-- 顶部导航栏 --> | ||||||
|  |     <el-header class="app-header"> | ||||||
|  |       <div class="header-content"> | ||||||
|  |         <div class="logo-section"> | ||||||
|  |           <el-icon size="32" color="#409EFF"> | ||||||
|  |             <Monitor /> | ||||||
|  |           </el-icon> | ||||||
|  |           <h1 class="app-title">EasyTier Uptime</h1> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <el-menu :default-active="activeMenuIndex" mode="horizontal" class="nav-menu" | ||||||
|  |           @select="handleMenuSelect"> | ||||||
|  |           <el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name"> | ||||||
|  |             <el-icon> | ||||||
|  |               <component :is="item.icon" /> | ||||||
|  |             </el-icon> | ||||||
|  |             <span>{{ item.title }}</span> | ||||||
|  |           </el-menu-item> | ||||||
|  |         </el-menu> | ||||||
|  |  | ||||||
|  |         <div class="header-actions"> | ||||||
|  |           <!-- 健康状态指示器 --> | ||||||
|  |           <el-tooltip :content="healthStatus === null ? '检查中...' : healthStatus ? '服务正常' : '服务异常'" placement="bottom"> | ||||||
|  |             <div class="health-indicator"> | ||||||
|  |               <el-icon :color="healthStatus === null ? '#909399' : healthStatus ? '#67C23A' : '#F56C6C'" | ||||||
|  |                 :class="{ 'loading': loading }"> | ||||||
|  |                 <CircleCheck v-if="healthStatus === true" /> | ||||||
|  |                 <CircleClose v-else-if="healthStatus === false" /> | ||||||
|  |                 <Loading v-else /> | ||||||
|  |               </el-icon> | ||||||
|  |             </div> | ||||||
|  |           </el-tooltip> | ||||||
|  |  | ||||||
|  |           <!-- 管理员入口 --> | ||||||
|  |           <el-button type="warning" link @click="() => router.push('/admin/login')"> | ||||||
|  |             管理员 | ||||||
|  |           </el-button> | ||||||
|  |  | ||||||
|  |           <!-- GitHub链接 --> | ||||||
|  |           <el-button type="primary" link @click="() => openExternalLink('https://github.com/EasyTier/EasyTier')"> | ||||||
|  |             <el-icon> | ||||||
|  |               <Link /> | ||||||
|  |             </el-icon> | ||||||
|  |             GitHub | ||||||
|  |           </el-button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </el-header> | ||||||
|  |  | ||||||
|  |     <!-- 主要内容区域 --> | ||||||
|  |     <el-main class="app-main"> | ||||||
|  |       <router-view v-slot="{ Component }"> | ||||||
|  |         <transition name="fade" mode="out-in"> | ||||||
|  |           <component :is="Component" /> | ||||||
|  |         </transition> | ||||||
|  |       </router-view> | ||||||
|  |     </el-main> | ||||||
|  |  | ||||||
|  |     <!-- 底部信息 --> | ||||||
|  |     <el-footer class="app-footer"> | ||||||
|  |       <div class="footer-content"> | ||||||
|  |         <p> | ||||||
|  |           © 2024 EasyTier Community | | ||||||
|  |           <el-button type="primary" link size="small" | ||||||
|  |             @click="() => openExternalLink('https://github.com/EasyTier/EasyTier')"> | ||||||
|  |             开源项目 | ||||||
|  |           </el-button> | ||||||
|  |           | | ||||||
|  |           <el-button type="primary" link size="small" | ||||||
|  |             @click="() => openExternalLink('https://github.com/EasyTier/EasyTier/blob/main/README.md')"> | ||||||
|  |             使用文档 | ||||||
|  |           </el-button> | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |     </el-footer> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  | /* 全局样式重置 */ | ||||||
|  | * { | ||||||
|  |   margin: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |   font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; | ||||||
|  |   background-color: #f5f7fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #app { | ||||||
|  |   min-height: 100vh; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 顶部导航栏 */ | ||||||
|  | .app-header { | ||||||
|  |   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |   box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); | ||||||
|  |   padding: 0; | ||||||
|  |   height: 60px; | ||||||
|  |   line-height: 60px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header-content { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   height: 100%; | ||||||
|  |   max-width: 1200px; | ||||||
|  |   margin: 0 auto; | ||||||
|  |   padding: 0 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .logo-section { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-title { | ||||||
|  |   color: white; | ||||||
|  |   font-size: 20px; | ||||||
|  |   font-weight: 600; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-menu { | ||||||
|  |   background: transparent; | ||||||
|  |   border: none; | ||||||
|  |   flex: 1; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-menu .el-menu-item { | ||||||
|  |   color: rgba(255, 255, 255, 0.8); | ||||||
|  |   border-bottom: 2px solid transparent; | ||||||
|  |   transition: all 0.3s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-menu .el-menu-item:hover, | ||||||
|  | .nav-menu .el-menu-item.is-active { | ||||||
|  |   color: white; | ||||||
|  |   background: rgba(255, 255, 255, 0.1); | ||||||
|  |   border-bottom-color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header-actions { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 15px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-indicator { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-indicator .loading { | ||||||
|  |   animation: spin 1s linear infinite; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes spin { | ||||||
|  |   from { | ||||||
|  |     transform: rotate(0deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   to { | ||||||
|  |     transform: rotate(360deg); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 主要内容区域 */ | ||||||
|  | .app-main { | ||||||
|  |   flex: 1; | ||||||
|  |   padding: 0; | ||||||
|  |   background-color: #f5f7fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 页面切换动画 */ | ||||||
|  | .fade-enter-active, | ||||||
|  | .fade-leave-active { | ||||||
|  |   transition: opacity 0.3s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fade-enter-from, | ||||||
|  | .fade-leave-to { | ||||||
|  |   opacity: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 底部信息 */ | ||||||
|  | .app-footer { | ||||||
|  |   background: white; | ||||||
|  |   border-top: 1px solid #e4e7ed; | ||||||
|  |   text-align: center; | ||||||
|  |   height: 50px; | ||||||
|  |   line-height: 50px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .footer-content p { | ||||||
|  |   color: #909399; | ||||||
|  |   font-size: 14px; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 响应式设计 */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .header-content { | ||||||
|  |     padding: 0 10px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .app-title { | ||||||
|  |     font-size: 16px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .nav-menu { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .header-actions { | ||||||
|  |     gap: 10px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Element Plus 组件样式覆盖 */ | ||||||
|  | .el-card { | ||||||
|  |   border-radius: 8px; | ||||||
|  |   box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .el-button { | ||||||
|  |   border-radius: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .el-input { | ||||||
|  |   border-radius: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .el-select { | ||||||
|  |   border-radius: 6px; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										195
									
								
								easytier-contrib/easytier-uptime/frontend/src/api/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,195 @@ | |||||||
|  | import axios from 'axios' | ||||||
|  |  | ||||||
|  | // 创建axios实例 | ||||||
|  | const api = axios.create({ | ||||||
|  |   baseURL: import.meta.env.VITE_API_BASE_URL || '', | ||||||
|  |   timeout: 10000, | ||||||
|  |   headers: { | ||||||
|  |     'Content-Type': 'application/json' | ||||||
|  |   }, | ||||||
|  |   // 保证数组参数使用 repeated keys 风格序列化:tags=a&tags=b | ||||||
|  |   paramsSerializer: params => { | ||||||
|  |     const usp = new URLSearchParams() | ||||||
|  |     Object.entries(params || {}).forEach(([key, value]) => { | ||||||
|  |       if (Array.isArray(value)) { | ||||||
|  |         value.forEach(v => usp.append(key, v)) | ||||||
|  |       } else if (value !== undefined && value !== null && value !== '') { | ||||||
|  |         usp.append(key, value) | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     return usp.toString() | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 请求拦截器 | ||||||
|  | api.interceptors.request.use( | ||||||
|  |   config => { | ||||||
|  |     // 只在管理员相关的API请求中添加token | ||||||
|  |     if (config.url && config.url.includes('/api/admin/')) { | ||||||
|  |       const token = localStorage.getItem('admin_token') | ||||||
|  |       if (token) { | ||||||
|  |         config.headers.Authorization = `Bearer ${token}` | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return config | ||||||
|  |   }, | ||||||
|  |   error => { | ||||||
|  |     return Promise.reject(error) | ||||||
|  |   } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 响应拦截器 | ||||||
|  | api.interceptors.response.use( | ||||||
|  |   response => { | ||||||
|  |     // 直接返回完整的response对象,让各个API方法自己处理数据格式 | ||||||
|  |     return response | ||||||
|  |   }, | ||||||
|  |   error => { | ||||||
|  |     console.error('API Error Details:', { | ||||||
|  |       message: error.message, | ||||||
|  |       status: error.response?.status, | ||||||
|  |       statusText: error.response?.statusText, | ||||||
|  |       data: error.response?.data, | ||||||
|  |       config: { | ||||||
|  |         url: error.config?.url, | ||||||
|  |         method: error.config?.method, | ||||||
|  |         headers: error.config?.headers | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     return Promise.reject(error) | ||||||
|  |   } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 节点相关API | ||||||
|  | export const nodeApi = { | ||||||
|  |   // 获取节点列表(支持传入 AbortController.signal 用于取消) | ||||||
|  |   async getNodes(params = {}, options = {}) { | ||||||
|  |     const response = await api.get('/api/nodes', { params, signal: options.signal }) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 获取所有标签 | ||||||
|  |   async getAllTags() { | ||||||
|  |     const response = await api.get('/api/tags') | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 创建节点 | ||||||
|  |   async createNode(data) { | ||||||
|  |     const response = await api.post('/api/nodes', data) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 获取单个节点 | ||||||
|  |   async getNode(id) { | ||||||
|  |     const response = await api.get(`/api/nodes/${id}`) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 更新节点 | ||||||
|  |   async updateNode(id, data) { | ||||||
|  |     const response = await api.put(`/api/nodes/${id}`, data) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 删除节点 | ||||||
|  |   async deleteNode(id) { | ||||||
|  |     const response = await api.delete(`/api/nodes/${id}`) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 获取节点健康记录 | ||||||
|  |   async getNodeHealth(id, params = {}) { | ||||||
|  |     const response = await api.get(`/api/nodes/${id}/health`, { params }) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 获取节点健康统计 | ||||||
|  |   async getNodeHealthStats(id, params = {}) { | ||||||
|  |     const response = await api.get(`/api/nodes/${id}/health/stats`, { params }) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 测试节点连接 | ||||||
|  |   async testConnection(data) { | ||||||
|  |     const response = await api.post('/api/test_connection', data) | ||||||
|  |     return response.data | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 健康检查API | ||||||
|  | export const healthApi = { | ||||||
|  |   async check() { | ||||||
|  |     const response = await api.get('/health') | ||||||
|  |     return response.data | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 管理员API | ||||||
|  | export const adminApi = { | ||||||
|  |   // 管理员登录 | ||||||
|  |   async login(password) { | ||||||
|  |     const response = await api.post('/api/admin/login', { password }) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 验证token有效性 | ||||||
|  |   async verifyToken() { | ||||||
|  |     const response = await api.get('/api/admin/verify') | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 获取所有节点(包括未审批的) | ||||||
|  |   async getNodes(params = {}) { | ||||||
|  |     const response = await api.get('/api/admin/nodes', { params }) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 审批节点 | ||||||
|  |   async approveNode(id) { | ||||||
|  |     const response = await api.put(`/api/admin/nodes/${id}/approve`) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 撤销审批节点 | ||||||
|  |   async revokeApproval(id) { | ||||||
|  |     const response = await api.put(`/api/admin/nodes/${id}/revoke`) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 删除节点 | ||||||
|  |   async deleteNode(id) { | ||||||
|  |     const response = await api.delete(`/api/admin/nodes/${id}`) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 更新节点 | ||||||
|  |   async updateNode(id, data) { | ||||||
|  |     const response = await api.put(`/api/admin/nodes/${id}`, data) | ||||||
|  |     return response.data | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // 兼容方法:获取所有节点(参数转换) | ||||||
|  |   async getAllNodes(params = {}) { | ||||||
|  |     const mapped = { | ||||||
|  |       page: params.page, | ||||||
|  |       per_page: params.page_size ?? params.per_page, | ||||||
|  |       is_approved: params.approved ?? params.is_approved, | ||||||
|  |       is_active: params.online ?? params.is_active, | ||||||
|  |       protocol: params.protocol, | ||||||
|  |       search: params.search, | ||||||
|  |       tag: params.tag | ||||||
|  |     } | ||||||
|  |     // 移除未定义的字段 | ||||||
|  |     Object.keys(mapped).forEach(k => { | ||||||
|  |       if (mapped[k] === undefined || mapped[k] === null || mapped[k] === '') { | ||||||
|  |         delete mapped[k] | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     // 直接复用现有接口 | ||||||
|  |     const response = await api.get('/api/admin/nodes', { params: mapped }) | ||||||
|  |     return response.data | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default api | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg> | ||||||
| After Width: | Height: | Size: 496 B | 
| @@ -0,0 +1,405 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="health-timeline" :class="{ 'compact': compact }"> | ||||||
|  |     <div class="timeline-header"> | ||||||
|  |       <span class="timeline-title">最近24小时健康状态</span> | ||||||
|  |       <div class="timeline-legend"> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot perfect"></span> | ||||||
|  |           <span class="legend-text">100%</span> | ||||||
|  |         </span> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot excellent"></span> | ||||||
|  |           <span class="legend-text">90-99%</span> | ||||||
|  |         </span> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot good"></span> | ||||||
|  |           <span class="legend-text">80-89%</span> | ||||||
|  |         </span> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot fair"></span> | ||||||
|  |           <span class="legend-text">60-79%</span> | ||||||
|  |         </span> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot poor"></span> | ||||||
|  |           <span class="legend-text">1-59%</span> | ||||||
|  |         </span> | ||||||
|  |         <span class="legend-item"> | ||||||
|  |           <span class="legend-dot unknown"></span> | ||||||
|  |           <span class="legend-text">未知</span> | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="timeline-container" v-loading="loading"> | ||||||
|  |       <div class="timeline-grid"> | ||||||
|  |         <!-- 时间刻度 --> | ||||||
|  |         <div class="time-labels"> | ||||||
|  |           <span v-for="(hour, idx) in timeLabels" :key="idx" class="time-label"> | ||||||
|  |             {{ hour }} | ||||||
|  |           </span> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <!-- 健康状态条 --> | ||||||
|  |         <div class="health-bars"> | ||||||
|  |           <div v-for="(segment, index) in healthSegments" :key="index" class="health-segment" :class="segment.status" | ||||||
|  |             :style="{ width: segment.width + '%', backgroundColor: segment.color }" :title="getSegmentTooltip(segment)"> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <!-- 统计信息 --> | ||||||
|  |       <div class="health-summary"> | ||||||
|  |         <div class="summary-item"> | ||||||
|  |           <span class="summary-value">{{ uptimePercentage }}%</span> | ||||||
|  |           <span class="summary-label">在线率</span> | ||||||
|  |         </div> | ||||||
|  |         <div class="summary-item"> | ||||||
|  |           <span class="summary-value">{{ avgResponseTime }}ms</span> | ||||||
|  |           <span class="summary-label">平均响应</span> | ||||||
|  |         </div> | ||||||
|  |         <div class="summary-item"> | ||||||
|  |           <span class="summary-value">{{ totalChecks }}</span> | ||||||
|  |           <span class="summary-label">检查次数</span> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, computed, onMounted, watch } from 'vue' | ||||||
|  | import { nodeApi } from '../api' | ||||||
|  | import dayjs from 'dayjs' | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   nodeInfo: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true | ||||||
|  |   }, | ||||||
|  |   compact: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const loading = ref(false) | ||||||
|  | const avg_response_time = ref(0) | ||||||
|  |  | ||||||
|  | // 时间标签(24小时,每4小时一个标签) | ||||||
|  | const timeLabels = computed(() => { | ||||||
|  |   const nodeInfo = props.nodeInfo | ||||||
|  |   const granularity = nodeInfo.ring_granularity | ||||||
|  |   const total_ring = nodeInfo.health_record_total_counter_ring | ||||||
|  |   const totalDuration = granularity * total_ring.length | ||||||
|  |   const now = dayjs(nodeInfo.last_check_time) | ||||||
|  |   const startTime = now.subtract(totalDuration, 'second') | ||||||
|  |  | ||||||
|  |   const labelCount = 6 | ||||||
|  |   const labelIntervalDuration = totalDuration / (labelCount - 1) | ||||||
|  |  | ||||||
|  |   let labels = [] | ||||||
|  |   for (let i = 0; i < labelCount; i++) { | ||||||
|  |     const time = startTime.add(i * labelIntervalDuration, 'second') | ||||||
|  |     labels.push(time.format('HH:mm')) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return labels | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const total_checks = computed(() => { | ||||||
|  |   let total = 0 | ||||||
|  |   for (let i = 0; i < props.nodeInfo.health_record_total_counter_ring.length; i++) { | ||||||
|  |     total += props.nodeInfo.health_record_total_counter_ring[i] | ||||||
|  |   } | ||||||
|  |   return total | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const healthy_checks = computed(() => { | ||||||
|  |   let total = 0 | ||||||
|  |   for (let i = 0; i < props.nodeInfo.health_record_healthy_counter_ring.length; i++) { | ||||||
|  |     total += props.nodeInfo.health_record_healthy_counter_ring[i] | ||||||
|  |   } | ||||||
|  |   return total | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const uptime_percentage = computed(() => { | ||||||
|  |   return (healthy_checks.value / total_checks.value) * 100 | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 根据成功率获取颜色 | ||||||
|  | const getColorBySuccessRate = (rate) => { | ||||||
|  |   if (rate === 1) { | ||||||
|  |     return '#67c23a' // 100% 绿色 | ||||||
|  |   } else if (rate >= 0.9) { | ||||||
|  |     return '#85ce61' // 90-99% 浅绿色 | ||||||
|  |   } else if (rate >= 0.8) { | ||||||
|  |     return '#e6a23c' // 80-89% 橙色 | ||||||
|  |   } else if (rate >= 0.6) { | ||||||
|  |     return '#f78989' // 60-79% 浅红色 | ||||||
|  |   } else if (rate > 0) { | ||||||
|  |     return '#f56c6c' // 1-59% 红色 | ||||||
|  |   } else { | ||||||
|  |     return '#c0c4cc' // 0% 或未知 灰色 | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 健康状态分段 | ||||||
|  | const healthSegments = computed(() => { | ||||||
|  |   const nodeInfo = props.nodeInfo | ||||||
|  |   const total_ring = nodeInfo.health_record_total_counter_ring | ||||||
|  |   const healthy_ring = nodeInfo.health_record_healthy_counter_ring | ||||||
|  |   const granularity = nodeInfo.ring_granularity | ||||||
|  |   const totalDuration = granularity * total_ring.length | ||||||
|  |  | ||||||
|  |   const segments = [] | ||||||
|  |   const now = dayjs(nodeInfo.last_check_time) | ||||||
|  |   const startTime = now.subtract(totalDuration, 'second') | ||||||
|  |  | ||||||
|  |   for (let i = total_ring.length - 1; i >= 0; i--) { | ||||||
|  |     const total_counter = total_ring[i] | ||||||
|  |     const healthy_counter = healthy_ring[i] | ||||||
|  |     const currentTime = startTime.subtract((i + 1) * granularity, 'second') | ||||||
|  |     const currentEndTime = currentTime.add(granularity, 'second') | ||||||
|  |  | ||||||
|  |     let successRate = 0 | ||||||
|  |     let currentStatus = 'unknown' | ||||||
|  |  | ||||||
|  |     if (total_counter !== 0) { | ||||||
|  |       successRate = healthy_counter / total_counter | ||||||
|  |       if (successRate === 1) { | ||||||
|  |         currentStatus = 'perfect' | ||||||
|  |       } else if (successRate >= 0.9) { | ||||||
|  |         currentStatus = 'excellent' | ||||||
|  |       } else if (successRate >= 0.8) { | ||||||
|  |         currentStatus = 'good' | ||||||
|  |       } else if (successRate >= 0.6) { | ||||||
|  |         currentStatus = 'fair' | ||||||
|  |       } else if (successRate > 0) { | ||||||
|  |         currentStatus = 'poor' | ||||||
|  |       } else { | ||||||
|  |         currentStatus = 'failed' | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     segments.push({ | ||||||
|  |       status: currentStatus, | ||||||
|  |       successRate: successRate, | ||||||
|  |       color: getColorBySuccessRate(successRate), | ||||||
|  |       width: (granularity / totalDuration) * 100, | ||||||
|  |       duration: granularity / 60.0, | ||||||
|  |       startTime: currentTime.format('HH:mm'), | ||||||
|  |       endTime: currentEndTime.format('HH:mm'), | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return segments | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 统计数据 | ||||||
|  | const uptimePercentage = computed(() => { | ||||||
|  |   return uptime_percentage.value.toFixed(1) || '0.0' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const avgResponseTime = computed(() => { | ||||||
|  |   return (props.nodeInfo.last_response_time / 1000).toFixed(1) || '0.0' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const totalChecks = computed(() => { | ||||||
|  |   return total_checks.value || 0 | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 获取分段提示信息 | ||||||
|  | const getSegmentTooltip = (segment) => { | ||||||
|  |   const statusText = { | ||||||
|  |     perfect: '完美', | ||||||
|  |     excellent: '优秀', | ||||||
|  |     good: '良好', | ||||||
|  |     fair: '一般', | ||||||
|  |     poor: '较差', | ||||||
|  |     failed: '失败', | ||||||
|  |     unknown: '未知' | ||||||
|  |   }[segment.status] || '未知' | ||||||
|  |  | ||||||
|  |   const successRateText = segment.successRate > 0 ? `${(segment.successRate * 100).toFixed(1)}%` : '0%' | ||||||
|  |  | ||||||
|  |   return `${segment.startTime} - ${segment.endTime}: ${statusText} (${successRateText}) - ${Math.round(segment.duration)}分钟` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .health-timeline { | ||||||
|  |   background: #f8f9fa; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   padding: 12px; | ||||||
|  |   margin-top: 8px; | ||||||
|  |   border: 1px solid #e4e7ed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .timeline-header { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  |   margin-bottom: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .timeline-title { | ||||||
|  |   font-size: 13px; | ||||||
|  |   font-weight: 500; | ||||||
|  |   color: #606266; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .timeline-legend { | ||||||
|  |   display: flex; | ||||||
|  |   gap: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-item { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot { | ||||||
|  |   width: 8px; | ||||||
|  |   height: 8px; | ||||||
|  |   border-radius: 50%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.perfect { | ||||||
|  |   background-color: #67c23a; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.excellent { | ||||||
|  |   background-color: #85ce61; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.good { | ||||||
|  |   background-color: #e6a23c; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.fair { | ||||||
|  |   background-color: #f78989; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.poor { | ||||||
|  |   background-color: #f56c6c; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-dot.unknown { | ||||||
|  |   background-color: #c0c4cc; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .legend-text { | ||||||
|  |   font-size: 11px; | ||||||
|  |   color: #909399; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .timeline-container { | ||||||
|  |   position: relative; | ||||||
|  |   min-height: 60px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .timeline-grid { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .time-labels { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .time-label { | ||||||
|  |   font-size: 10px; | ||||||
|  |   color: #c0c4cc; | ||||||
|  |   font-family: monospace; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-bars { | ||||||
|  |   display: flex; | ||||||
|  |   height: 12px; | ||||||
|  |   border-radius: 6px; | ||||||
|  |   overflow: hidden; | ||||||
|  |   background-color: #f0f0f0; | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-segment { | ||||||
|  |   height: 100%; | ||||||
|  |   transition: all 0.3s ease; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 颜色现在通过动态样式设置,不再需要这些CSS类 */ | ||||||
|  |  | ||||||
|  | .health-segment:hover { | ||||||
|  |   opacity: 0.8; | ||||||
|  |   transform: scaleY(1.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .response-time-chart { | ||||||
|  |   height: 30px; | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .response-chart { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-summary { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-around; | ||||||
|  |   padding-top: 8px; | ||||||
|  |   border-top: 1px solid #e4e7ed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .summary-item { | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .summary-value { | ||||||
|  |   display: block; | ||||||
|  |   font-size: 14px; | ||||||
|  |   font-weight: 600; | ||||||
|  |   color: #409eff; | ||||||
|  |   line-height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .summary-label { | ||||||
|  |   font-size: 10px; | ||||||
|  |   color: #909399; | ||||||
|  |   margin-top: 2px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 紧凑模式 */ | ||||||
|  | .health-timeline.compact { | ||||||
|  |   padding: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .timeline-header { | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .timeline-title { | ||||||
|  |   font-size: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .health-bars { | ||||||
|  |   height: 8px; | ||||||
|  |   margin-bottom: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .health-summary { | ||||||
|  |   padding-top: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .summary-value { | ||||||
|  |   font-size: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-timeline.compact .summary-label { | ||||||
|  |   font-size: 9px; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,563 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="left" | ||||||
|  |       @submit.prevent="handleSubmit"> | ||||||
|  |       <el-form-item label="节点名称" prop="name" required> | ||||||
|  |         <el-input v-model="form.name" placeholder="请输入节点名称,如:北京-联通-01" maxlength="100" show-word-limit clearable> | ||||||
|  |           <template #prefix> | ||||||
|  |             <el-icon> | ||||||
|  |               <Monitor /> | ||||||
|  |             </el-icon> | ||||||
|  |           </template> | ||||||
|  |         </el-input> | ||||||
|  |         <div class="form-tip">建议使用地区-运营商-编号的格式命名</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-row :gutter="20"> | ||||||
|  |         <el-col :span="16"> | ||||||
|  |           <el-form-item label="主机地址" prop="host" required> | ||||||
|  |             <el-input v-model="form.host" placeholder="请输入IP地址或域名" clearable> | ||||||
|  |               <template #prefix> | ||||||
|  |                 <el-icon> | ||||||
|  |                   <Location /> | ||||||
|  |                 </el-icon> | ||||||
|  |               </template> | ||||||
|  |             </el-input> | ||||||
|  |           </el-form-item> | ||||||
|  |         </el-col> | ||||||
|  |         <el-col :span="8"> | ||||||
|  |           <el-form-item label="端口" prop="port" required> | ||||||
|  |             <el-input-number v-model="form.port" :min="1" :max="65535" placeholder="端口号" style="width: 100%" /> | ||||||
|  |           </el-form-item> | ||||||
|  |         </el-col> | ||||||
|  |       </el-row> | ||||||
|  |  | ||||||
|  |       <el-form-item label="协议类型" prop="protocol" required> | ||||||
|  |         <el-radio-group v-model="form.protocol"> | ||||||
|  |           <el-radio value="tcp">TCP</el-radio> | ||||||
|  |           <el-radio value="udp">UDP</el-radio> | ||||||
|  |           <el-radio value="ws">WebSocket</el-radio> | ||||||
|  |           <el-radio value="wss">WebSocket Secure</el-radio> | ||||||
|  |         </el-radio-group> | ||||||
|  |         <div class="form-tip">选择节点支持的连接协议</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-form-item label="允许中转" prop="allow_relay" required> | ||||||
|  |         <el-radio-group v-model="form.allow_relay"> | ||||||
|  |           <el-radio :value="true">允许中转数据</el-radio> | ||||||
|  |           <el-radio :value="false">仅用于打洞</el-radio> | ||||||
|  |         </el-radio-group> | ||||||
|  |         <div class="form-tip">选择节点是否允许中转其他用户的数据流量</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-form-item label="网络名称" prop="network_name" required> | ||||||
|  |         <el-input v-model="form.network_name" placeholder="请输入EasyTier网络名称" maxlength="100" clearable> | ||||||
|  |           <template #prefix> | ||||||
|  |             <el-icon> | ||||||
|  |               <Connection /> | ||||||
|  |             </el-icon> | ||||||
|  |           </template> | ||||||
|  |         </el-input> | ||||||
|  |         <div class="form-tip">与 EasyTier 的 network name 一致,用于后端探活</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-form-item label="网络密码" prop="network_secret" required> | ||||||
|  |         <el-input v-model="form.network_secret" type="password" placeholder="请输入网络密码" maxlength="100" clearable | ||||||
|  |           show-password> | ||||||
|  |           <template #prefix> | ||||||
|  |             <el-icon> | ||||||
|  |               <Lock /> | ||||||
|  |             </el-icon> | ||||||
|  |           </template> | ||||||
|  |         </el-input> | ||||||
|  |         <div class="form-tip">与 EasyTier 的 network secret 一致</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-form-item label="最大网络数" prop="max_connections" required> | ||||||
|  |         <el-input-number v-model="form.max_connections" :min="1" :max="10000" placeholder="最大网络数量" | ||||||
|  |           style="width: 200px" /> | ||||||
|  |         <div class="form-tip">节点能够承载的最大网络数量</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <el-form-item label="节点描述" prop="description"> | ||||||
|  |         <el-input v-model="form.description" type="textarea" :rows="4" placeholder="请描述您的节点特点,如:地理位置、网络质量、使用限制等" | ||||||
|  |           maxlength="500" show-word-limit /> | ||||||
|  |         <div class="form-tip">详细描述有助于用户选择合适的节点</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <!-- 新增:标签管理(仅在管理员编辑时显示) --> | ||||||
|  |       <el-form-item v-if="props.showTags" label="标签" prop="tags"> | ||||||
|  |         <el-select v-model="form.tags" multiple filterable allow-create default-first-option :multiple-limit="10" | ||||||
|  |           placeholder="输入后按回车添加,如:北京、联通、IPv6、高带宽"> | ||||||
|  |           <el-option v-for="opt in (form.tags || [])" :key="opt" :label="opt" :value="opt" /> | ||||||
|  |         </el-select> | ||||||
|  |         <div class="form-tip">用于分类与检索,建议 1-6 个标签,每个不超过 32 字符</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <!-- 联系方式 --> | ||||||
|  |       <el-form-item label="联系方式" prop="contact_info"> | ||||||
|  |         <div class="contact-section"> | ||||||
|  |           <el-form-item label="微信" prop="wechat"> | ||||||
|  |             <el-input v-model="form.wechat" placeholder="请输入微信号" maxlength="50" clearable> | ||||||
|  |               <template #prefix> | ||||||
|  |                 <el-icon> | ||||||
|  |                   <ChatDotRound /> | ||||||
|  |                 </el-icon> | ||||||
|  |               </template> | ||||||
|  |             </el-input> | ||||||
|  |           </el-form-item> | ||||||
|  |  | ||||||
|  |           <el-form-item label="QQ" prop="qq_number"> | ||||||
|  |             <el-input v-model="form.qq_number" placeholder="请输入QQ号" maxlength="20" clearable> | ||||||
|  |               <template #prefix> | ||||||
|  |                 <el-icon> | ||||||
|  |                   <User /> | ||||||
|  |                 </el-icon> | ||||||
|  |               </template> | ||||||
|  |             </el-input> | ||||||
|  |           </el-form-item> | ||||||
|  |  | ||||||
|  |           <el-form-item label="邮箱" prop="mail"> | ||||||
|  |             <el-input v-model="form.mail" placeholder="请输入邮箱地址" maxlength="100" clearable> | ||||||
|  |               <template #prefix> | ||||||
|  |                 <el-icon> | ||||||
|  |                   <Message /> | ||||||
|  |                 </el-icon> | ||||||
|  |               </template> | ||||||
|  |             </el-input> | ||||||
|  |           </el-form-item> | ||||||
|  |  | ||||||
|  |           <div class="form-tip">请至少填写一种联系方式,便于节点问题时联系您(仅管理员可见)</div> | ||||||
|  |         </div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <!-- 连接测试 --> | ||||||
|  |       <el-form-item label="连接测试"> | ||||||
|  |         <div class="test-section"> | ||||||
|  |           <el-button type="warning" @click="testConnection" :loading="testing" :disabled="!canTest"> | ||||||
|  |             <el-icon> | ||||||
|  |               <Connection /> | ||||||
|  |             </el-icon> | ||||||
|  |             测试连接 | ||||||
|  |           </el-button> | ||||||
|  |           <div v-if="testResult" class="test-result"> | ||||||
|  |             <el-tag :type="testResult.success ? 'success' : 'danger'" size="large"> | ||||||
|  |               {{ testResult.success ? '连接成功' : '连接失败' }} | ||||||
|  |             </el-tag> | ||||||
|  |             <span v-if="testResult.message" class="test-message"> | ||||||
|  |               {{ testResult.message }} | ||||||
|  |             </span> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="form-tip">建议在提交前测试连接以确保节点可用</div> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <!-- 使用条款 --> | ||||||
|  |       <el-form-item prop="agreed" v-if="props.showAgreement"> | ||||||
|  |         <el-checkbox v-model="form.agreed"> | ||||||
|  |           我已阅读并同意 | ||||||
|  |           <el-button type="primary" link @click="showTerms = true"> | ||||||
|  |             《节点共享协议》 | ||||||
|  |           </el-button> | ||||||
|  |         </el-checkbox> | ||||||
|  |       </el-form-item> | ||||||
|  |  | ||||||
|  |       <!-- 提交按钮 --> | ||||||
|  |       <el-form-item> | ||||||
|  |         <div class="submit-section"> | ||||||
|  |           <el-button type="primary" size="large" @click="handleSubmit" :loading="submitting" | ||||||
|  |             :disabled="!form.agreed && props.showAgreement"> | ||||||
|  |             <el-icon> | ||||||
|  |               <Upload /> | ||||||
|  |             </el-icon> | ||||||
|  |             提交节点 | ||||||
|  |           </el-button> | ||||||
|  |           <el-button size="large" @click="resetFields"> | ||||||
|  |             <el-icon> | ||||||
|  |               <RefreshLeft /> | ||||||
|  |             </el-icon> | ||||||
|  |             重置表单 | ||||||
|  |           </el-button> | ||||||
|  |         </div> | ||||||
|  |       </el-form-item> | ||||||
|  |     </el-form> <!-- 使用条款对话框 --> | ||||||
|  |  | ||||||
|  |     <el-dialog v-model="showTerms" title="节点共享协议" width="600px"> | ||||||
|  |       <div class="terms-content"> | ||||||
|  |         <h3>1. 节点共享原则</h3> | ||||||
|  |         <p>• 节点提供者应确保节点的稳定性和可用性</p> | ||||||
|  |         <p>• 不得利用共享节点进行违法违规活动</p> | ||||||
|  |         <p>• 尊重其他用户的使用权益</p> | ||||||
|  |  | ||||||
|  |         <h3>2. 服务质量要求</h3> | ||||||
|  |         <p>• 节点应保持7x24小时稳定运行</p> | ||||||
|  |         <p>• 网络延迟应控制在合理范围内</p> | ||||||
|  |         <p>• 及时处理连接问题和故障</p> | ||||||
|  |  | ||||||
|  |         <h3>3. 数据安全</h3> | ||||||
|  |         <p>• 不得记录或泄露用户传输数据</p> | ||||||
|  |         <p>• 保护用户隐私和数据安全</p> | ||||||
|  |         <p>• 遵守相关法律法规</p> | ||||||
|  |  | ||||||
|  |         <h3>4. 免责声明</h3> | ||||||
|  |         <p>• 平台不对节点服务质量承担责任</p> | ||||||
|  |         <p>• 用户使用节点服务的风险自担</p> | ||||||
|  |         <p>• 平台有权移除不符合要求的节点</p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <template #footer> | ||||||
|  |         <el-button @click="showTerms = false">关闭</el-button> | ||||||
|  |         <el-button type="primary" @click="acceptTerms">同意并关闭</el-button> | ||||||
|  |       </template> | ||||||
|  |     </el-dialog> | ||||||
|  |  | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, reactive, computed, watch } from 'vue' | ||||||
|  | import { | ||||||
|  |   Monitor, | ||||||
|  |   Location, | ||||||
|  |   PriceTag, | ||||||
|  |   Connection, | ||||||
|  |   Upload, | ||||||
|  |   Edit, | ||||||
|  |   RefreshLeft, | ||||||
|  |   ChatDotRound, | ||||||
|  |   User, | ||||||
|  |   Message | ||||||
|  | } from '@element-plus/icons-vue' | ||||||
|  | import { ElMessage } from 'element-plus' | ||||||
|  | import { nodeApi } from '../api' | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   modelValue: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({ | ||||||
|  |       name: '', | ||||||
|  |       host: '', | ||||||
|  |       port: 11010, | ||||||
|  |       protocol: 'tcp', | ||||||
|  |       allow_relay: true, | ||||||
|  |       network_name: '', | ||||||
|  |       network_secret: '', | ||||||
|  |       max_connections: 100, | ||||||
|  |       description: '', | ||||||
|  |       wechat: '', | ||||||
|  |       qq_number: '', | ||||||
|  |       mail: '', | ||||||
|  |       tags: [], | ||||||
|  |       agreed: false | ||||||
|  |     }) | ||||||
|  |   }, | ||||||
|  |   submitting: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false | ||||||
|  |   }, | ||||||
|  |   submitText: { | ||||||
|  |     type: String, | ||||||
|  |     default: '提交节点' | ||||||
|  |   }, | ||||||
|  |   submitIcon: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'Upload' | ||||||
|  |   }, | ||||||
|  |   showConnectionTest: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true | ||||||
|  |   }, | ||||||
|  |   showAgreement: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true | ||||||
|  |   }, | ||||||
|  |   showCancel: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false | ||||||
|  |   }, | ||||||
|  |   // 新增:是否显示标签管理 | ||||||
|  |   showTags: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['update:modelValue', 'submit', 'reset', 'cancel', 'show-terms']) | ||||||
|  |  | ||||||
|  | const formRef = ref() | ||||||
|  | const testing = ref(false) | ||||||
|  | const testResult = ref(null) | ||||||
|  | const showTerms = ref(false) | ||||||
|  |  | ||||||
|  | // 表单数据 | ||||||
|  | const form = reactive({ ...props.modelValue }) | ||||||
|  |  | ||||||
|  | // 监听props变化,更新表单数据 | ||||||
|  | watch(() => props.modelValue, (newValue) => { | ||||||
|  |   Object.assign(form, newValue) | ||||||
|  | }, { deep: true }) | ||||||
|  |  | ||||||
|  | // 监听表单变化,向上传递 | ||||||
|  | watch(form, (newValue) => { | ||||||
|  |   emit('update:modelValue', { ...newValue }) | ||||||
|  | }, { deep: true }) | ||||||
|  |  | ||||||
|  | // 表单验证规则 | ||||||
|  | const rules = { | ||||||
|  |   name: [ | ||||||
|  |     { required: true, message: '请输入节点名称', trigger: 'blur' }, | ||||||
|  |     { min: 1, max: 100, message: '节点名称长度应在1-100个字符之间', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   host: [ | ||||||
|  |     { required: true, message: '请输入主机地址', trigger: 'blur' }, | ||||||
|  |     { min: 1, max: 255, message: '主机地址长度应在1-255个字符之间', trigger: 'blur' }, | ||||||
|  |     { | ||||||
|  |       pattern: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/, | ||||||
|  |       message: '请输入有效的IP地址或域名', | ||||||
|  |       trigger: 'blur' | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   port: [ | ||||||
|  |     { required: true, message: '请输入端口号', trigger: 'blur' }, | ||||||
|  |     { type: 'number', min: 1, max: 65535, message: '端口号应在1-65535之间', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   protocol: [ | ||||||
|  |     { required: true, message: '请选择协议类型', trigger: 'change' } | ||||||
|  |   ], | ||||||
|  |   max_connections: [ | ||||||
|  |     { required: true, message: '请输入最大连接数', trigger: 'blur' }, | ||||||
|  |     { type: 'number', min: 1, max: 10000, message: '最大连接数应在1-10000之间', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   version: [ | ||||||
|  |     { max: 50, message: '版本信息长度不能超过50个字符', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   description: [ | ||||||
|  |     { max: 500, message: '描述长度不能超过500个字符', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   wechat: [ | ||||||
|  |     { max: 50, message: '微信号长度不能超过50个字符', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   qq_number: [ | ||||||
|  |     { max: 20, message: 'QQ号长度不能超过20个字符', trigger: 'blur' }, | ||||||
|  |     { pattern: /^[1-9][0-9]{4,19}$/, message: '请输入有效的QQ号', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   mail: [ | ||||||
|  |     { max: 100, message: '邮箱地址长度不能超过100个字符', trigger: 'blur' }, | ||||||
|  |     { type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' } | ||||||
|  |   ], | ||||||
|  |   contact_info: [ | ||||||
|  |     { | ||||||
|  |       validator: (rule, value, callback) => { | ||||||
|  |         if (!form.wechat && !form.qq_number && !form.mail) { | ||||||
|  |           callback(new Error('请至少填写一种联系方式')) | ||||||
|  |         } else { | ||||||
|  |           callback() | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       trigger: 'blur' | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   agreed: [ | ||||||
|  |     { | ||||||
|  |       validator: (rule, value, callback) => { | ||||||
|  |         if (!value) { | ||||||
|  |           callback(new Error('请阅读并同意节点共享协议')) | ||||||
|  |         } else { | ||||||
|  |           callback() | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       trigger: 'change' | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   // 新增:标签规则(仅在显示标签管理时生效) | ||||||
|  |   tags: [ | ||||||
|  |     { | ||||||
|  |       validator: (rule, value, callback) => { | ||||||
|  |         if (!props.showTags) { | ||||||
|  |           callback() | ||||||
|  |           return | ||||||
|  |         } | ||||||
|  |         if (!Array.isArray(form.tags)) { | ||||||
|  |           callback(new Error('标签格式错误')) | ||||||
|  |           return | ||||||
|  |         } | ||||||
|  |         if (form.tags.length > 10) { | ||||||
|  |           callback(new Error('最多添加 10 个标签')) | ||||||
|  |           return | ||||||
|  |         } | ||||||
|  |         for (const t of form.tags) { | ||||||
|  |           const s = (t || '').trim() | ||||||
|  |           if (s.length === 0) { | ||||||
|  |             callback(new Error('标签不能为空')) | ||||||
|  |             return | ||||||
|  |           } | ||||||
|  |           if (s.length > 32) { | ||||||
|  |             callback(new Error('每个标签不超过 32 字符')) | ||||||
|  |             return | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         callback() | ||||||
|  |       }, | ||||||
|  |       trigger: 'change' | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 是否可以测试连接 | ||||||
|  | const canTest = computed(() => { | ||||||
|  |   return form.host && form.port && form.protocol && form.network_name && form.network_secret | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const buildDataFromForm = () => { | ||||||
|  |   const data = { | ||||||
|  |     name: form.name || 'Test Node', | ||||||
|  |     host: form.host, | ||||||
|  |     port: form.port, | ||||||
|  |     protocol: form.protocol, | ||||||
|  |     description: form.description || null, | ||||||
|  |     max_connections: form.max_connections || 100, | ||||||
|  |     allow_relay: form.allow_relay, | ||||||
|  |     network_name: form.network_name || null, | ||||||
|  |     network_secret: form.network_secret || null, | ||||||
|  |     wechat: form.wechat || null, | ||||||
|  |     qq_number: form.qq_number || null, | ||||||
|  |     mail: form.mail || null | ||||||
|  |   } | ||||||
|  |   // 仅在管理员编辑时附带标签 | ||||||
|  |   if (props.showTags) { | ||||||
|  |     data.tags = Array.isArray(form.tags) ? form.tags : [] | ||||||
|  |   } | ||||||
|  |   return data | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 测试连接 | ||||||
|  | const testConnection = async () => { | ||||||
|  |   if (!canTest.value) { | ||||||
|  |     ElMessage.warning('请先填写主机地址、端口、协议、网络名称和网络密码') | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   testing.value = true | ||||||
|  |   testResult.value = null | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     // 构建测试数据 | ||||||
|  |     const testData = buildDataFromForm() | ||||||
|  |  | ||||||
|  |     // 调用实际的连接测试API | ||||||
|  |     const response = await nodeApi.testConnection(testData) | ||||||
|  |  | ||||||
|  |     if (response.success) { | ||||||
|  |       testResult.value = { | ||||||
|  |         success: true, | ||||||
|  |         message: '连接测试成功,节点可正常访问' | ||||||
|  |       } | ||||||
|  |       ElMessage.success('连接测试成功') | ||||||
|  |     } else { | ||||||
|  |       testResult.value = { | ||||||
|  |         success: false, | ||||||
|  |         message: response.error || '连接测试失败' | ||||||
|  |       } | ||||||
|  |       ElMessage.error('连接测试失败') | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('连接测试失败:', error) | ||||||
|  |     testResult.value = { | ||||||
|  |       success: false, | ||||||
|  |       message: error.response?.data?.error || '测试过程中发生错误,请检查网络连接' | ||||||
|  |     } | ||||||
|  |     ElMessage.error('连接测试失败') | ||||||
|  |   } finally { | ||||||
|  |     testing.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 提交表单 | ||||||
|  | const handleSubmit = async () => { | ||||||
|  |   if (!formRef.value) return | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const valid = await formRef.value.validate() | ||||||
|  |     if (!valid) return | ||||||
|  |  | ||||||
|  |     const submitData = buildDataFromForm() | ||||||
|  |  | ||||||
|  |     emit('submit', submitData) | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('表单验证失败:', error) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 重置表单 | ||||||
|  | const resetFields = () => { | ||||||
|  |   if (formRef.value) { | ||||||
|  |     formRef.value.resetFields() | ||||||
|  |   } | ||||||
|  |   // 重置标签 | ||||||
|  |   if (props.showTags) { | ||||||
|  |     form.tags = [] | ||||||
|  |   } | ||||||
|  |   testResult.value = null | ||||||
|  |   emit('reset') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const acceptTerms = () => { | ||||||
|  |   form.agreed = true | ||||||
|  |   showTerms.value = false | ||||||
|  |   ElMessage.success('已同意节点共享协议') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 暴露方法给父组件 | ||||||
|  | defineExpose({ | ||||||
|  |   validate: () => formRef.value?.validate(), | ||||||
|  |   resetFields: () => formRef.value?.resetFields() | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .form-tip { | ||||||
|  |   font-size: 12px; | ||||||
|  |   color: #909399; | ||||||
|  |   margin-top: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .test-section { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .test-result { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .test-message { | ||||||
|  |   font-size: 12px; | ||||||
|  |   color: #606266; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .submit-section { | ||||||
|  |   display: flex; | ||||||
|  |   gap: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .contact-section { | ||||||
|  |   width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .contact-section .el-form-item { | ||||||
|  |   margin-bottom: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .contact-section .el-form-item:last-of-type { | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .contact-section .el-form-item__label { | ||||||
|  |   font-size: 14px; | ||||||
|  |   color: #606266; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										22
									
								
								easytier-contrib/easytier-uptime/frontend/src/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | import { createApp } from 'vue' | ||||||
|  | import ElementPlus from 'element-plus' | ||||||
|  | import 'element-plus/dist/index.css' | ||||||
|  | import * as ElementPlusIconsVue from '@element-plus/icons-vue' | ||||||
|  | import router from './router' | ||||||
|  | import App from './App.vue' | ||||||
|  | import './style.css' | ||||||
|  |  | ||||||
|  | const app = createApp(App) | ||||||
|  |  | ||||||
|  | // 注册Element Plus | ||||||
|  | app.use(ElementPlus) | ||||||
|  |  | ||||||
|  | // 注册所有图标 | ||||||
|  | for (const [key, component] of Object.entries(ElementPlusIconsVue)) { | ||||||
|  |   app.component(key, component) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 注册路由 | ||||||
|  | app.use(router) | ||||||
|  |  | ||||||
|  | app.mount('#app') | ||||||
| @@ -0,0 +1,78 @@ | |||||||
|  | import { createRouter, createWebHistory } from 'vue-router' | ||||||
|  | import NodeDashboard from '../views/NodeDashboard.vue' | ||||||
|  | import SubmitNode from '../views/SubmitNode.vue' | ||||||
|  | import AdminLogin from '../views/AdminLogin.vue' | ||||||
|  | import AdminDashboard from '../views/AdminDashboard.vue' | ||||||
|  |  | ||||||
|  | const routes = [ | ||||||
|  |   { | ||||||
|  |     path: '/', | ||||||
|  |     name: 'Dashboard', | ||||||
|  |     component: NodeDashboard, | ||||||
|  |     meta: { | ||||||
|  |       title: '节点状态监控' | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: '/submit', | ||||||
|  |     name: 'Submit', | ||||||
|  |     component: SubmitNode, | ||||||
|  |     meta: { | ||||||
|  |       title: '提交共享节点' | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: '/admin/login', | ||||||
|  |     name: 'AdminLogin', | ||||||
|  |     component: AdminLogin, | ||||||
|  |     meta: { | ||||||
|  |       title: '管理员登录' | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: '/admin', | ||||||
|  |     name: 'AdminDashboard', | ||||||
|  |     component: AdminDashboard, | ||||||
|  |     meta: { | ||||||
|  |       title: '管理员面板', | ||||||
|  |       requiresAuth: true | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const router = createRouter({ | ||||||
|  |   history: createWebHistory(), | ||||||
|  |   routes | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 路由守卫 | ||||||
|  | router.beforeEach(async (to, from, next) => { | ||||||
|  |   // 设置页面标题 | ||||||
|  |   if (to.meta.title) { | ||||||
|  |     document.title = `${to.meta.title} - EasyTier Uptime` | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // 检查管理员权限 | ||||||
|  |   if (to.meta.requiresAuth) { | ||||||
|  |     const token = localStorage.getItem('admin_token') | ||||||
|  |     if (!token) { | ||||||
|  |       next('/admin/login') | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 验证token有效性 | ||||||
|  |     try { | ||||||
|  |       const { adminApi } = await import('../api') | ||||||
|  |       await adminApi.verifyToken() | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Token verification failed:', error) | ||||||
|  |       localStorage.removeItem('admin_token') | ||||||
|  |       next('/admin/login') | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   next() | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | export default router | ||||||
							
								
								
									
										243
									
								
								easytier-contrib/easytier-uptime/frontend/src/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,243 @@ | |||||||
|  | /* 自定义样式 */ | ||||||
|  | :root { | ||||||
|  |   --primary-color: #409EFF; | ||||||
|  |   --success-color: #67C23A; | ||||||
|  |   --warning-color: #E6A23C; | ||||||
|  |   --danger-color: #F56C6C; | ||||||
|  |   --info-color: #909399; | ||||||
|  |  | ||||||
|  |   --text-primary: #303133; | ||||||
|  |   --text-regular: #606266; | ||||||
|  |   --text-secondary: #909399; | ||||||
|  |   --text-placeholder: #C0C4CC; | ||||||
|  |  | ||||||
|  |   --border-base: #DCDFE6; | ||||||
|  |   --border-light: #E4E7ED; | ||||||
|  |   --border-lighter: #EBEEF5; | ||||||
|  |   --border-extra-light: #F2F6FC; | ||||||
|  |  | ||||||
|  |   --background-base: #F5F7FA; | ||||||
|  |   --background-light: #FAFAFA; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 滚动条样式 */ | ||||||
|  | ::-webkit-scrollbar { | ||||||
|  |   width: 6px; | ||||||
|  |   height: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ::-webkit-scrollbar-track { | ||||||
|  |   background: #f1f1f1; | ||||||
|  |   border-radius: 3px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ::-webkit-scrollbar-thumb { | ||||||
|  |   background: #c1c1c1; | ||||||
|  |   border-radius: 3px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ::-webkit-scrollbar-thumb:hover { | ||||||
|  |   background: #a8a8a8; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 工具类 */ | ||||||
|  | .text-center { | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-left { | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-right { | ||||||
|  |   text-align: right; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex { | ||||||
|  |   display: flex; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex-center { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex-between { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: space-between; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex-column { | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex-1 { | ||||||
|  |   flex: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mb-10 { | ||||||
|  |   margin-bottom: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mb-20 { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mt-10 { | ||||||
|  |   margin-top: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mt-20 { | ||||||
|  |   margin-top: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .p-10 { | ||||||
|  |   padding: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .p-20 { | ||||||
|  |   padding: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 动画效果 */ | ||||||
|  | .fade-in { | ||||||
|  |   animation: fadeIn 0.3s ease-in; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fadeIn { | ||||||
|  |   from { | ||||||
|  |     opacity: 0; | ||||||
|  |     transform: translateY(10px); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   to { | ||||||
|  |     opacity: 1; | ||||||
|  |     transform: translateY(0); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .slide-up { | ||||||
|  |   animation: slideUp 0.3s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes slideUp { | ||||||
|  |   from { | ||||||
|  |     opacity: 0; | ||||||
|  |     transform: translateY(20px); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   to { | ||||||
|  |     opacity: 1; | ||||||
|  |     transform: translateY(0); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 响应式断点 */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .mobile-hidden { | ||||||
|  |     display: none !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (min-width: 769px) { | ||||||
|  |   .desktop-hidden { | ||||||
|  |     display: none !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 状态指示器 */ | ||||||
|  | .status-online { | ||||||
|  |   color: var(--success-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .status-offline { | ||||||
|  |   color: var(--danger-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .status-warning { | ||||||
|  |   color: var(--warning-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 卡片阴影效果 */ | ||||||
|  | .card-shadow { | ||||||
|  |   box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); | ||||||
|  |   transition: box-shadow 0.3s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .card-shadow:hover { | ||||||
|  |   box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 加载状态 */ | ||||||
|  | .loading-overlay { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .loading-overlay::after { | ||||||
|  |   content: ''; | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   right: 0; | ||||||
|  |   bottom: 0; | ||||||
|  |   background: rgba(255, 255, 255, 0.8); | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   z-index: 1000; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 表格样式增强 */ | ||||||
|  | .el-table .el-table__row:hover { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 按钮组样式 */ | ||||||
|  | .button-group { | ||||||
|  |   display: flex; | ||||||
|  |   gap: 10px; | ||||||
|  |   flex-wrap: wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-group .el-button { | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 统计卡片样式 */ | ||||||
|  | .stat-card { | ||||||
|  |   text-align: center; | ||||||
|  |   padding: 10px; | ||||||
|  |   background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); | ||||||
|  |   border-radius: 8px; | ||||||
|  |   transition: transform 0.3s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-card:hover { | ||||||
|  |   transform: translateY(-2px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 标签样式 */ | ||||||
|  | .tag-group { | ||||||
|  |   display: flex; | ||||||
|  |   gap: 8px; | ||||||
|  |   flex-wrap: wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 描述列表样式 */ | ||||||
|  | .description-list { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: auto 1fr; | ||||||
|  |   gap: 10px 20px; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .description-list .label { | ||||||
|  |   font-weight: 600; | ||||||
|  |   color: var(--text-regular); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .description-list .value { | ||||||
|  |   color: var(--text-primary); | ||||||
|  | } | ||||||
| @@ -0,0 +1,62 @@ | |||||||
|  | // Deterministic tag color generator (pure frontend) | ||||||
|  | // Same tag => same color; different tags => different colors | ||||||
|  |  | ||||||
|  | function stringHash(str) { | ||||||
|  |   const s = String(str) | ||||||
|  |   let hash = 5381 | ||||||
|  |   for (let i = 0; i < s.length; i++) { | ||||||
|  |     hash = (hash * 33) ^ s.charCodeAt(i) | ||||||
|  |   } | ||||||
|  |   return hash >>> 0 // ensure positive | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function hslToRgb(h, s, l) { | ||||||
|  |   // h,s,l in [0,1] | ||||||
|  |   let r, g, b | ||||||
|  |  | ||||||
|  |   if (s === 0) { | ||||||
|  |     r = g = b = l // achromatic | ||||||
|  |   } else { | ||||||
|  |     const hue2rgb = (p, q, t) => { | ||||||
|  |       if (t < 0) t += 1 | ||||||
|  |       if (t > 1) t -= 1 | ||||||
|  |       if (t < 1 / 6) return p + (q - p) * 6 * t | ||||||
|  |       if (t < 1 / 2) return q | ||||||
|  |       if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 | ||||||
|  |       return p | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const q = l < 0.5 ? l * (1 + s) : l + s - l * s | ||||||
|  |     const p = 2 * l - q | ||||||
|  |     r = hue2rgb(p, q, h + 1 / 3) | ||||||
|  |     g = hue2rgb(p, q, h) | ||||||
|  |     b = hue2rgb(p, q, h - 1 / 3) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function rgbToHex(r, g, b) { | ||||||
|  |   const toHex = (v) => v.toString(16).padStart(2, '0') | ||||||
|  |   return `#${toHex(r)}${toHex(g)}${toHex(b)}` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function getTagStyle(tag) { | ||||||
|  |   const hash = stringHash(tag) | ||||||
|  |   const hue = hash % 360 // 0-359 | ||||||
|  |   const saturation = 65 // percentage | ||||||
|  |   const lightness = 47 // percentage | ||||||
|  |  | ||||||
|  |   const rgb = hslToRgb(hue / 360, saturation / 100, lightness / 100) | ||||||
|  |   const hex = rgbToHex(rgb[0], rgb[1], rgb[2]) | ||||||
|  |  | ||||||
|  |   // Perceived brightness for text color selection | ||||||
|  |   const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 | ||||||
|  |   const textColor = brightness > 160 ? '#1f1f1f' : '#ffffff' | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     backgroundColor: hex, | ||||||
|  |     borderColor: hex, | ||||||
|  |     color: textColor | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,631 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <el-container class="admin-dashboard"> | ||||||
|  |       <!-- 头部导航 --> | ||||||
|  |       <el-header class="admin-header"> | ||||||
|  |         <div class="header-content"> | ||||||
|  |           <div class="flex"> | ||||||
|  |             <h1 class="header-title">管理员面板</h1> | ||||||
|  |           </div> | ||||||
|  |           <div class="header-actions"> | ||||||
|  |             <router-link to="/" class="nav-link"> | ||||||
|  |               返回首页 | ||||||
|  |             </router-link> | ||||||
|  |             <el-button type="danger" @click="logout"> | ||||||
|  |               退出登录 | ||||||
|  |             </el-button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </el-header> | ||||||
|  |  | ||||||
|  |       <!-- 主要内容 --> | ||||||
|  |       <el-main class="main-content"> | ||||||
|  |         <!-- 统计卡片 --> | ||||||
|  |         <el-row :gutter="20" class="mb-20"> | ||||||
|  |           <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |             <el-card class="stat-card"> | ||||||
|  |               <div class="stat-content"> | ||||||
|  |                 <div class="stat-icon success"> | ||||||
|  |                   <el-icon> | ||||||
|  |                     <Check /> | ||||||
|  |                   </el-icon> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="stat-info"> | ||||||
|  |                   <div class="stat-label">已审批节点</div> | ||||||
|  |                   <div class="stat-value">{{ stats.approved }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </el-card> | ||||||
|  |           </el-col> | ||||||
|  |  | ||||||
|  |           <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |             <el-card class="stat-card"> | ||||||
|  |               <div class="stat-content"> | ||||||
|  |                 <div class="stat-icon warning"> | ||||||
|  |                   <el-icon> | ||||||
|  |                     <Clock /> | ||||||
|  |                   </el-icon> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="stat-info"> | ||||||
|  |                   <div class="stat-label">待审批节点</div> | ||||||
|  |                   <div class="stat-value">{{ stats.pending }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </el-card> | ||||||
|  |           </el-col> | ||||||
|  |  | ||||||
|  |           <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |             <el-card class="stat-card"> | ||||||
|  |               <div class="stat-content"> | ||||||
|  |                 <div class="stat-icon info"> | ||||||
|  |                   <el-icon> | ||||||
|  |                     <DataAnalysis /> | ||||||
|  |                   </el-icon> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="stat-info"> | ||||||
|  |                   <div class="stat-label">总节点数</div> | ||||||
|  |                   <div class="stat-value">{{ stats.total }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </el-card> | ||||||
|  |           </el-col> | ||||||
|  |  | ||||||
|  |           <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |             <el-card class="stat-card"> | ||||||
|  |               <div class="stat-content"> | ||||||
|  |                 <div class="stat-icon success"> | ||||||
|  |                   <el-icon> | ||||||
|  |                     <CircleCheck /> | ||||||
|  |                   </el-icon> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="stat-info"> | ||||||
|  |                   <div class="stat-label">在线节点</div> | ||||||
|  |                   <div class="stat-value">{{ stats.active }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </el-card> | ||||||
|  |           </el-col> | ||||||
|  |         </el-row> | ||||||
|  |  | ||||||
|  |         <!-- 筛选器 --> | ||||||
|  |         <el-card class="mb-20"> | ||||||
|  |           <template #header> | ||||||
|  |             <span>筛选条件</span> | ||||||
|  |           </template> | ||||||
|  |           <el-row :gutter="20"> | ||||||
|  |             <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |               <el-form-item label="审批状态"> | ||||||
|  |                 <el-select v-model="filters.approved" @change="loadNodes" placeholder="全部" clearable> | ||||||
|  |                   <el-option label="全部" value="" /> | ||||||
|  |                   <el-option label="已审批" value="true" /> | ||||||
|  |                   <el-option label="待审批" value="false" /> | ||||||
|  |                 </el-select> | ||||||
|  |               </el-form-item> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |               <el-form-item label="在线状态"> | ||||||
|  |                 <el-select v-model="filters.active" @change="loadNodes" placeholder="全部" clearable> | ||||||
|  |                   <el-option label="全部" value="" /> | ||||||
|  |                   <el-option label="在线" value="true" /> | ||||||
|  |                   <el-option label="离线" value="false" /> | ||||||
|  |                 </el-select> | ||||||
|  |               </el-form-item> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |               <el-form-item label="协议"> | ||||||
|  |                 <el-select v-model="filters.protocol" @change="loadNodes" placeholder="全部" clearable> | ||||||
|  |                   <el-option label="全部" value="" /> | ||||||
|  |                   <el-option label="TCP" value="tcp" /> | ||||||
|  |                   <el-option label="UDP" value="udp" /> | ||||||
|  |                   <el-option label="WireGuard" value="wg" /> | ||||||
|  |                   <el-option label="WebSocket" value="ws" /> | ||||||
|  |                   <el-option label="WebSocket Secure" value="wss" /> | ||||||
|  |                 </el-select> | ||||||
|  |               </el-form-item> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :xs="24" :sm="12" :md="6"> | ||||||
|  |               <el-form-item label="搜索"> | ||||||
|  |                 <el-input v-model="filters.search" @input="debounceSearch" placeholder="搜索节点名称或主机" clearable /> | ||||||
|  |               </el-form-item> | ||||||
|  |             </el-col> | ||||||
|  |           </el-row> | ||||||
|  |         </el-card> | ||||||
|  |  | ||||||
|  |         <!-- 节点列表 --> | ||||||
|  |         <el-card> | ||||||
|  |           <template #header> | ||||||
|  |             <div class="flex-between"> | ||||||
|  |               <div> | ||||||
|  |                 <h3>节点列表</h3> | ||||||
|  |                 <p class="text-secondary">管理所有共享节点</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |  | ||||||
|  |           <div v-if="loading" class="text-center p-20"> | ||||||
|  |             <el-icon class="is-loading" size="32"> | ||||||
|  |               <Loading /> | ||||||
|  |             </el-icon> | ||||||
|  |             <p class="mt-10">加载中...</p> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <el-table v-else-if="nodes.length > 0" :data="nodes" stripe> | ||||||
|  |             <el-table-column prop="name" label="节点名称" min-width="120"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <div class="flex items-center"> | ||||||
|  |                   <el-icon class="mr-2" | ||||||
|  |                     :color="row.is_active && row.is_approved ? '#67C23A' : !row.is_approved ? '#E6A23C' : '#F56C6C'"> | ||||||
|  |                     <CircleCheck v-if="row.is_active && row.is_approved" /> | ||||||
|  |                     <Clock v-else-if="!row.is_approved" /> | ||||||
|  |                     <el-icon v-else>❌</el-icon> | ||||||
|  |                   </el-icon> | ||||||
|  |                   <span>{{ row.name }}</span> | ||||||
|  |                 </div> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="host" label="主机地址" min-width="150"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 {{ row.host }}:{{ row.port }} | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="protocol" label="协议" width="80"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <el-tag :type="getProtocolType(row.protocol)" size="small"> | ||||||
|  |                   {{ row.protocol.toUpperCase() }} | ||||||
|  |                 </el-tag> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="is_approved" label="审批状态" width="100"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <el-tag :type="row.is_approved ? 'success' : 'warning'" size="small"> | ||||||
|  |                   {{ row.is_approved ? '已审批' : '待审批' }} | ||||||
|  |                 </el-tag> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="is_active" label="在线状态" width="100"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <el-tag :type="row.is_active ? 'success' : 'danger'" size="small"> | ||||||
|  |                   {{ row.is_active ? '在线' : '离线' }} | ||||||
|  |                 </el-tag> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip /> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="tags" label="标签" min-width="160"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <div class="tags-list"> | ||||||
|  |                   <el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" :style="getTagStyle(tag)"> | ||||||
|  |                     {{ tag }} | ||||||
|  |                   </el-tag> | ||||||
|  |                   <span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span> | ||||||
|  |                 </div> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column prop="created_at" label="创建时间" width="160"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 {{ formatDate(row.created_at) }} | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |  | ||||||
|  |             <el-table-column label="操作" width="200" fixed="right"> | ||||||
|  |               <template #default="{ row }"> | ||||||
|  |                 <el-button type="primary" size="small" @click="editNode(row)"> | ||||||
|  |                   编辑 | ||||||
|  |                 </el-button> | ||||||
|  |                 <el-button v-if="!row.is_approved" type="success" size="small" @click="approveNode(row.id)"> | ||||||
|  |                   审批 | ||||||
|  |                 </el-button> | ||||||
|  |                 <el-button v-if="row.is_approved" type="warning" size="small" @click="revokeApproval(row.id)"> | ||||||
|  |                   撤销 | ||||||
|  |                 </el-button> | ||||||
|  |                 <el-button type="danger" size="small" @click="deleteNode(row.id)"> | ||||||
|  |                   删除 | ||||||
|  |                 </el-button> | ||||||
|  |               </template> | ||||||
|  |             </el-table-column> | ||||||
|  |           </el-table> | ||||||
|  |  | ||||||
|  |           <el-empty v-else description="暂无节点数据" /> | ||||||
|  |         </el-card> | ||||||
|  |       </el-main> | ||||||
|  |     </el-container> | ||||||
|  |  | ||||||
|  |     <!-- 编辑节点对话框 --> | ||||||
|  |     <el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close> | ||||||
|  |       <NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit" | ||||||
|  |         :show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true" | ||||||
|  |         @submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" /> | ||||||
|  |     </el-dialog> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  | import { adminApi } from '../api' | ||||||
|  | import dayjs from 'dayjs' | ||||||
|  | import { ElMessage, ElMessageBox } from 'element-plus' | ||||||
|  | import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue' | ||||||
|  | import NodeForm from '../components/NodeForm.vue' | ||||||
|  | import { getTagStyle } from '../utils/tagColor' | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   name: 'AdminDashboard', | ||||||
|  |   components: { | ||||||
|  |     Check, | ||||||
|  |     Clock, | ||||||
|  |     DataAnalysis, | ||||||
|  |     CircleCheck, | ||||||
|  |     Loading, | ||||||
|  |     NodeForm | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       loading: false, | ||||||
|  |       nodes: [], | ||||||
|  |       filters: { | ||||||
|  |         approved: '', | ||||||
|  |         active: '', | ||||||
|  |         protocol: '', | ||||||
|  |         search: '' | ||||||
|  |       }, | ||||||
|  |       searchTimeout: null, | ||||||
|  |       editDialogVisible: false, | ||||||
|  |       editForm: { | ||||||
|  |         name: '', | ||||||
|  |         host: '', | ||||||
|  |         port: 11010, | ||||||
|  |         protocol: 'tcp', | ||||||
|  |         version: '', | ||||||
|  |         max_connections: 100, | ||||||
|  |         description: '', | ||||||
|  |         tags: [] | ||||||
|  |       }, | ||||||
|  |       editingNodeId: null, | ||||||
|  |       updating: false | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     stats() { | ||||||
|  |       const total = this.nodes.length | ||||||
|  |       const approved = this.nodes.filter(node => node.is_approved).length | ||||||
|  |       const pending = this.nodes.filter(node => !node.is_approved).length | ||||||
|  |       const active = this.nodes.filter(node => node.is_active).length | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         total, | ||||||
|  |         approved, | ||||||
|  |         pending, | ||||||
|  |         active | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   async mounted() { | ||||||
|  |     // 先验证token有效性 | ||||||
|  |     try { | ||||||
|  |       await adminApi.verifyToken() | ||||||
|  |       await this.loadNodes() | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Token verification failed in mounted:', error) | ||||||
|  |       this.logout() | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     getTagStyle, | ||||||
|  |     async loadNodes() { | ||||||
|  |       try { | ||||||
|  |         this.loading = true | ||||||
|  |         const params = {} | ||||||
|  |         if (this.filters.approved !== '') { | ||||||
|  |           params.approved = this.filters.approved | ||||||
|  |         } | ||||||
|  |         if (this.filters.active !== '') { | ||||||
|  |           params.active = this.filters.active | ||||||
|  |         } | ||||||
|  |         if (this.filters.protocol) { | ||||||
|  |           params.protocol = this.filters.protocol | ||||||
|  |         } | ||||||
|  |         if (this.filters.search) { | ||||||
|  |           params.search = this.filters.search | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const response = await adminApi.getNodes(params) | ||||||
|  |         this.nodes = response.data?.items || [] | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('加载节点失败:', error) | ||||||
|  |         if (error.response?.status === 401) { | ||||||
|  |           this.logout() | ||||||
|  |         } else { | ||||||
|  |           ElMessage.error('加载节点失败') | ||||||
|  |         } | ||||||
|  |       } finally { | ||||||
|  |         this.loading = false | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async approveNode(nodeId) { | ||||||
|  |       try { | ||||||
|  |         await ElMessageBox.confirm('确定要审批通过这个节点吗?', '确认审批', { | ||||||
|  |           type: 'warning' | ||||||
|  |         }) | ||||||
|  |         await adminApi.approveNode(nodeId) | ||||||
|  |         ElMessage.success('审批成功') | ||||||
|  |         await this.loadNodes() | ||||||
|  |       } catch (error) { | ||||||
|  |         if (error !== 'cancel') { | ||||||
|  |           console.error('审批失败:', error) | ||||||
|  |           ElMessage.error('审批失败') | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async revokeApproval(nodeId) { | ||||||
|  |       try { | ||||||
|  |         await ElMessageBox.confirm('确定要撤销这个节点的审批吗?撤销后节点将变为待审批状态。', '确认撤销审批', { | ||||||
|  |           type: 'warning' | ||||||
|  |         }) | ||||||
|  |         await adminApi.revokeApproval(nodeId) | ||||||
|  |         ElMessage.success('撤销审批成功') | ||||||
|  |         await this.loadNodes() | ||||||
|  |       } catch (error) { | ||||||
|  |         if (error !== 'cancel') { | ||||||
|  |           console.error('撤销审批失败:', error) | ||||||
|  |           ElMessage.error('撤销审批失败') | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async deleteNode(nodeId) { | ||||||
|  |       try { | ||||||
|  |         await ElMessageBox.confirm('确定要删除这个节点吗?此操作不可恢复!', '确认删除', { | ||||||
|  |           type: 'warning' | ||||||
|  |         }) | ||||||
|  |         await adminApi.deleteNode(nodeId) | ||||||
|  |         ElMessage.success('删除成功') | ||||||
|  |         await this.loadNodes() | ||||||
|  |       } catch (error) { | ||||||
|  |         if (error !== 'cancel') { | ||||||
|  |           console.error('删除失败:', error) | ||||||
|  |           ElMessage.error('删除失败') | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     editNode(node) { | ||||||
|  |       this.editingNodeId = node.id | ||||||
|  |       // 只取需要的字段,并复制 tags 数组以避免引用问题 | ||||||
|  |       this.editForm = { | ||||||
|  |         id: node.id, | ||||||
|  |         name: node.name, | ||||||
|  |         host: node.host, | ||||||
|  |         port: node.port, | ||||||
|  |         protocol: node.protocol, | ||||||
|  |         version: node.version, | ||||||
|  |         max_connections: node.max_connections, | ||||||
|  |         description: node.description || '', | ||||||
|  |         allow_relay: node.allow_relay, | ||||||
|  |         network_name: node.network_name, | ||||||
|  |         network_secret: node.network_secret, | ||||||
|  |         wechat: node.wechat, | ||||||
|  |         qq_number: node.qq_number, | ||||||
|  |         mail: node.mail, | ||||||
|  |         tags: Array.isArray(node.tags) ? [...node.tags] : [] | ||||||
|  |       } | ||||||
|  |       this.editDialogVisible = true | ||||||
|  |     }, | ||||||
|  |     async handleUpdateNode(formData) { | ||||||
|  |       try { | ||||||
|  |         this.updating = true | ||||||
|  |         // 确保提交包含 tags 字段(为空数组也传) | ||||||
|  |         const payload = { | ||||||
|  |           name: formData.name, | ||||||
|  |           host: formData.host, | ||||||
|  |           port: formData.port, | ||||||
|  |           protocol: formData.protocol, | ||||||
|  |           version: formData.version, | ||||||
|  |           max_connections: formData.max_connections, | ||||||
|  |           description: formData.description, | ||||||
|  |           allow_relay: formData.allow_relay, | ||||||
|  |           network_name: formData.network_name, | ||||||
|  |           network_secret: formData.network_secret, | ||||||
|  |           wechat: formData.wechat, | ||||||
|  |           qq_number: formData.qq_number, | ||||||
|  |           mail: formData.mail, | ||||||
|  |           tags: Array.isArray(formData.tags) ? formData.tags : [] | ||||||
|  |         } | ||||||
|  |         await adminApi.updateNode(this.editingNodeId, payload) | ||||||
|  |         ElMessage.success('节点更新成功') | ||||||
|  |         this.editDialogVisible = false | ||||||
|  |         await this.loadNodes() | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('更新节点失败:', error) | ||||||
|  |         ElMessage.error('更新节点失败') | ||||||
|  |       } finally { | ||||||
|  |         this.updating = false | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     resetEditForm() { | ||||||
|  |       this.editForm = { | ||||||
|  |         name: '', | ||||||
|  |         host: '', | ||||||
|  |         port: 11010, | ||||||
|  |         protocol: 'tcp', | ||||||
|  |         version: '', | ||||||
|  |         max_connections: 100, | ||||||
|  |         description: '' | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     debounceSearch() { | ||||||
|  |       if (this.searchTimeout) { | ||||||
|  |         clearTimeout(this.searchTimeout) | ||||||
|  |       } | ||||||
|  |       this.searchTimeout = setTimeout(() => { | ||||||
|  |         this.loadNodes() | ||||||
|  |       }, 500) | ||||||
|  |     }, | ||||||
|  |     formatDate(dateString) { | ||||||
|  |       return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss') | ||||||
|  |     }, | ||||||
|  |     getProtocolType(protocol) { | ||||||
|  |       const typeMap = { | ||||||
|  |         tcp: 'primary', | ||||||
|  |         udp: 'success', | ||||||
|  |         wg: 'warning', | ||||||
|  |         ws: 'info', | ||||||
|  |         wss: 'danger' | ||||||
|  |       } | ||||||
|  |       return typeMap[protocol] || 'info' | ||||||
|  |     }, | ||||||
|  |     async logout() { | ||||||
|  |       try { | ||||||
|  |         await ElMessageBox.confirm('确定要退出登录吗?', '确认退出', { | ||||||
|  |           type: 'warning' | ||||||
|  |         }) | ||||||
|  |         localStorage.removeItem('admin_token') | ||||||
|  |         this.$router.push('/admin/login') | ||||||
|  |       } catch (error) { | ||||||
|  |         // 用户取消 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .admin-dashboard { | ||||||
|  |   min-height: 100vh; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .admin-header { | ||||||
|  |   background: white; | ||||||
|  |   border-bottom: 1px solid #e4e7ed; | ||||||
|  |   padding: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header-content { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  |   padding: 0 20px; | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header-title { | ||||||
|  |   margin: 0; | ||||||
|  |   color: #303133; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header-actions { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-link { | ||||||
|  |   color: #409eff; | ||||||
|  |   text-decoration: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-link:hover { | ||||||
|  |   color: #66b1ff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .main-content { | ||||||
|  |   background: #f5f7fa; | ||||||
|  |   padding: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mb-20 { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-card { | ||||||
|  |   position: relative; | ||||||
|  |   overflow: hidden; | ||||||
|  |   height: 100px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-content { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-info { | ||||||
|  |   flex: 1; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-label { | ||||||
|  |   font-size: 12px; | ||||||
|  |   color: #909399; | ||||||
|  |   margin: 0 0 4px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-value { | ||||||
|  |   font-size: 24px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   color: #303133; | ||||||
|  |   line-height: 1; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-icon { | ||||||
|  |   font-size: 28px; | ||||||
|  |   opacity: 0.3; | ||||||
|  |   margin-left: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-icon.success { | ||||||
|  |   color: #67c23a; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-icon.warning { | ||||||
|  |   color: #e6a23c; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-icon.info { | ||||||
|  |   color: #409eff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex { | ||||||
|  |   display: flex; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flex-between { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .items-center { | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mr-2 { | ||||||
|  |   margin-right: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mt-10 { | ||||||
|  |   margin-top: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .p-20 { | ||||||
|  |   padding: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-center { | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-secondary { | ||||||
|  |   color: #909399; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tag-chip { | ||||||
|  |   margin-right: 4px; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,251 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="login-container"> | ||||||
|  |     <div class="login-card"> | ||||||
|  |       <div class="login-header"> | ||||||
|  |         <div class="login-icon"> | ||||||
|  |           <el-icon :size="48" color="#409EFF"> | ||||||
|  |             <Lock /> | ||||||
|  |           </el-icon> | ||||||
|  |         </div> | ||||||
|  |         <h2 class="login-title">管理员登录</h2> | ||||||
|  |         <p class="login-subtitle">请输入管理员密码以访问管理面板</p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="login-form"> | ||||||
|  |         <el-form @submit.prevent="handleLogin" :model="form" :rules="rules" ref="loginForm"> | ||||||
|  |           <el-form-item prop="password"> | ||||||
|  |             <el-input v-model="form.password" type="password" placeholder="请输入管理员密码" size="large" show-password | ||||||
|  |               :prefix-icon="Lock" @keyup.enter="handleLogin" /> | ||||||
|  |           </el-form-item> | ||||||
|  |  | ||||||
|  |           <el-form-item v-if="error"> | ||||||
|  |             <el-alert :title="error" type="error" :closable="false" show-icon /> | ||||||
|  |           </el-form-item> | ||||||
|  |  | ||||||
|  |           <el-form-item> | ||||||
|  |             <el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-button"> | ||||||
|  |               {{ loading ? '登录中...' : '登录' }} | ||||||
|  |             </el-button> | ||||||
|  |           </el-form-item> | ||||||
|  |         </el-form> | ||||||
|  |  | ||||||
|  |         <div class="login-divider"> | ||||||
|  |           <el-divider>或</el-divider> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="login-actions"> | ||||||
|  |           <el-button size="large" @click="$router.push('/')" class="back-button"> | ||||||
|  |             <el-icon class="mr-2"> | ||||||
|  |               <ArrowLeft /> | ||||||
|  |             </el-icon> | ||||||
|  |             返回首页 | ||||||
|  |           </el-button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  | import { adminApi } from '../api' | ||||||
|  | import { Lock, ArrowLeft } from '@element-plus/icons-vue' | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   name: 'AdminLogin', | ||||||
|  |   components: { | ||||||
|  |     Lock, | ||||||
|  |     ArrowLeft | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       loading: false, | ||||||
|  |       error: '', | ||||||
|  |       form: { | ||||||
|  |         password: '' | ||||||
|  |       }, | ||||||
|  |       rules: { | ||||||
|  |         password: [ | ||||||
|  |           { required: true, message: '请输入密码', trigger: 'blur' }, | ||||||
|  |           { min: 1, message: '密码不能为空', trigger: 'blur' } | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     async handleLogin() { | ||||||
|  |       if (!this.form.password) { | ||||||
|  |         this.error = '请输入密码' | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       this.loading = true | ||||||
|  |       this.error = '' | ||||||
|  |  | ||||||
|  |       try { | ||||||
|  |         const response = await adminApi.login(this.form.password) | ||||||
|  |  | ||||||
|  |         // 保存token | ||||||
|  |         const token = response.data?.token || response.token | ||||||
|  |         if (token) { | ||||||
|  |           localStorage.setItem('admin_token', token) | ||||||
|  |  | ||||||
|  |           // 跳转到管理面板 | ||||||
|  |           this.$router.push('/admin') | ||||||
|  |         } else { | ||||||
|  |           throw new Error('No token received from server') | ||||||
|  |         } | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('Login error:', error) | ||||||
|  |         console.error('Error details:', { | ||||||
|  |           message: error.message, | ||||||
|  |           status: error.response?.status, | ||||||
|  |           statusText: error.response?.statusText, | ||||||
|  |           data: error.response?.data | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |         if (error.response?.status === 401) { | ||||||
|  |           this.error = '密码错误,请重新输入' | ||||||
|  |         } else if (error.response?.data?.message) { | ||||||
|  |           this.error = error.response.data.message | ||||||
|  |         } else if (error.message) { | ||||||
|  |           this.error = error.message | ||||||
|  |         } else { | ||||||
|  |           this.error = '登录失败,请检查网络连接' | ||||||
|  |         } | ||||||
|  |       } finally { | ||||||
|  |         this.loading = false | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     // 如果已经登录,直接跳转到管理面板 | ||||||
|  |     const token = localStorage.getItem('admin_token') | ||||||
|  |     if (token) { | ||||||
|  |       this.$router.push('/admin') | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .login-container { | ||||||
|  |   min-height: 100vh; | ||||||
|  |   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   padding: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-card { | ||||||
|  |   background: white; | ||||||
|  |   border-radius: 16px; | ||||||
|  |   box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); | ||||||
|  |   padding: 40px; | ||||||
|  |   width: 100%; | ||||||
|  |   max-width: 400px; | ||||||
|  |   backdrop-filter: blur(10px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-header { | ||||||
|  |   text-align: center; | ||||||
|  |   margin-bottom: 32px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-icon { | ||||||
|  |   margin-bottom: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-title { | ||||||
|  |   font-size: 28px; | ||||||
|  |   font-weight: 600; | ||||||
|  |   color: var(--text-primary); | ||||||
|  |   margin: 0 0 8px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-subtitle { | ||||||
|  |   font-size: 14px; | ||||||
|  |   color: var(--text-secondary); | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-form { | ||||||
|  |   width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-button { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 48px; | ||||||
|  |   font-size: 16px; | ||||||
|  |   font-weight: 500; | ||||||
|  |   border-radius: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-divider { | ||||||
|  |   margin: 24px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-actions { | ||||||
|  |   width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .back-button { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 48px; | ||||||
|  |   font-size: 16px; | ||||||
|  |   border-radius: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mr-2 { | ||||||
|  |   margin-right: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 响应式设计 */ | ||||||
|  | @media (max-width: 480px) { | ||||||
|  |   .login-card { | ||||||
|  |     padding: 24px; | ||||||
|  |     margin: 16px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .login-title { | ||||||
|  |     font-size: 24px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 动画效果 */ | ||||||
|  | .login-card { | ||||||
|  |   animation: fadeInUp 0.6s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fadeInUp { | ||||||
|  |   from { | ||||||
|  |     opacity: 0; | ||||||
|  |     transform: translateY(30px); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   to { | ||||||
|  |     opacity: 1; | ||||||
|  |     transform: translateY(0); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Element Plus 组件样式覆盖 */ | ||||||
|  | :deep(.el-input__wrapper) { | ||||||
|  |   border-radius: 8px; | ||||||
|  |   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||||
|  |   transition: all 0.3s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-input__wrapper:hover) { | ||||||
|  |   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-button) { | ||||||
|  |   transition: all 0.3s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-button:hover) { | ||||||
|  |   transform: translateY(-2px); | ||||||
|  |   box-shadow: 0 6px 16px rgba(64, 158, 255, 0.3); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,718 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="node-dashboard"> | ||||||
|  |     <!-- 页面头部 --> | ||||||
|  |     <div class="dashboard-header"> | ||||||
|  |       <h1>EasyTier 节点状态监控</h1> | ||||||
|  |       <p class="subtitle">实时监控所有共享节点的健康状态和连接信息</p> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <!-- 统计卡片 --> | ||||||
|  |     <el-row :gutter="20" class="stats-row"> | ||||||
|  |       <el-col :span="6"> | ||||||
|  |         <el-card class="stat-card"> | ||||||
|  |           <div class="stat-content"> | ||||||
|  |             <div class="stat-number">{{ totalNodes }}</div> | ||||||
|  |             <div class="stat-label">总节点数</div> | ||||||
|  |           </div> | ||||||
|  |           <el-icon class="stat-icon" color="#409EFF"> | ||||||
|  |             <Monitor /> | ||||||
|  |           </el-icon> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |       <el-col :span="6"> | ||||||
|  |         <el-card class="stat-card"> | ||||||
|  |           <div class="stat-content"> | ||||||
|  |             <div class="stat-number">{{ activeNodes }}</div> | ||||||
|  |             <div class="stat-label">在线节点</div> | ||||||
|  |           </div> | ||||||
|  |           <el-icon class="stat-icon" color="#67C23A"> | ||||||
|  |             <CircleCheck /> | ||||||
|  |           </el-icon> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |       <el-col :span="6"> | ||||||
|  |         <el-card class="stat-card"> | ||||||
|  |           <div class="stat-content"> | ||||||
|  |             <div class="stat-number">{{ averageLoad }} %</div> | ||||||
|  |             <div class="stat-label">平均负载</div> | ||||||
|  |           </div> | ||||||
|  |           <el-icon class="stat-icon" color="#E6A23C"> | ||||||
|  |             <Link /> | ||||||
|  |           </el-icon> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |       <el-col :span="6"> | ||||||
|  |         <el-card class="stat-card"> | ||||||
|  |           <div class="stat-content"> | ||||||
|  |             <div class="stat-number">{{ averageUptime }}%</div> | ||||||
|  |             <div class="stat-label">平均在线率</div> | ||||||
|  |           </div> | ||||||
|  |           <el-icon class="stat-icon" color="#F56C6C"> | ||||||
|  |             <TrendCharts /> | ||||||
|  |           </el-icon> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |     </el-row> | ||||||
|  |  | ||||||
|  |     <!-- 搜索和筛选 --> | ||||||
|  |     <el-card class="filter-card"> | ||||||
|  |       <el-row :gutter="26"> | ||||||
|  |         <el-col :span="8"> | ||||||
|  |           <el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable | ||||||
|  |             @input="handleSearch" /> | ||||||
|  |         </el-col> | ||||||
|  |         <el-col :span="4"> | ||||||
|  |           <el-select v-model="statusFilter" placeholder="状态筛选" clearable @change="handleFilter"> | ||||||
|  |             <el-option label="全部" value="" /> | ||||||
|  |             <el-option label="在线" value="true" /> | ||||||
|  |             <el-option label="离线" value="false" /> | ||||||
|  |           </el-select> | ||||||
|  |         </el-col> | ||||||
|  |         <el-col :span="4"> | ||||||
|  |           <el-select v-model="protocolFilter" placeholder="协议筛选" clearable @change="handleFilter"> | ||||||
|  |             <el-option label="全部" value="" /> | ||||||
|  |             <el-option label="TCP" value="tcp" /> | ||||||
|  |             <el-option label="UDP" value="udp" /> | ||||||
|  |             <el-option label="WS" value="ws" /> | ||||||
|  |             <el-option label="WSS" value="wss" /> | ||||||
|  |           </el-select> | ||||||
|  |         </el-col> | ||||||
|  |         <!-- 新增:标签多选筛选 --> | ||||||
|  |         <el-col :span="4"> | ||||||
|  |           <el-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable | ||||||
|  |             placeholder="按标签筛选(可多选)" @change="handleFilter"> | ||||||
|  |             <el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"> | ||||||
|  |               <span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span> | ||||||
|  |             </el-option> | ||||||
|  |           </el-select> | ||||||
|  |         </el-col> | ||||||
|  |  | ||||||
|  |         <el-col :span="4"> | ||||||
|  |           <el-button type="success" @click="$router.push('/submit')"> | ||||||
|  |             <el-icon> | ||||||
|  |               <Plus /> | ||||||
|  |             </el-icon> | ||||||
|  |             提交节点 | ||||||
|  |           </el-button> | ||||||
|  |         </el-col> | ||||||
|  |       </el-row> | ||||||
|  |     </el-card> | ||||||
|  |  | ||||||
|  |     <!-- 节点列表 --> | ||||||
|  |     <el-card ref="nodesCardRef" class="nodes-card"> | ||||||
|  |       <template #header> | ||||||
|  |         <div class="card-header"> | ||||||
|  |           <span> | ||||||
|  |             节点列表 | ||||||
|  |             <el-button type="text" :loading="loading" @click="refreshData" style="margin-left: 8px;"> | ||||||
|  |               <el-icon> | ||||||
|  |                 <Refresh /> | ||||||
|  |               </el-icon> | ||||||
|  |             </el-button> | ||||||
|  |           </span> | ||||||
|  |           <el-tag :type="loading ? 'info' : 'success'"> | ||||||
|  |             {{ loading ? '加载中...' : `共 ${pagination.total} 个节点` }} | ||||||
|  |           </el-tag> | ||||||
|  |         </div> | ||||||
|  |       </template> | ||||||
|  |  | ||||||
|  |       <el-table ref="tableRef" :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id"> | ||||||
|  |         <!-- 展开列 --> | ||||||
|  |         <el-table-column type="expand" width="50"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <div class="expanded-content"> | ||||||
|  |               <HealthTimeline :node-info="row" :compact="true" /> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column prop="name" label="节点名称" width="150"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <div class="node-name"> | ||||||
|  |               <el-icon :color="row.is_active ? '#67C23A' : '#F56C6C'"> | ||||||
|  |                 <CircleCheck v-if="row.is_active" /> | ||||||
|  |                 <CircleClose v-else /> | ||||||
|  |               </el-icon> | ||||||
|  |               <span>{{ row.name }}</span> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column prop="address" label="节点连接地址" width="250"> | ||||||
|  |           <template #header> | ||||||
|  |             <span>节点连接地址</span> | ||||||
|  |             <el-tooltip content="可以将节点链接填入命令行的 -p 参数,或者图形界面的节点地址字段(公共服务器或手动皆可)" placement="top" effect="light"> | ||||||
|  |               <el-icon class="help-icon"> | ||||||
|  |                 <QuestionFilled /> | ||||||
|  |               </el-icon> | ||||||
|  |             </el-tooltip> | ||||||
|  |           </template> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <el-tag type="primary" size="" style="margin-bottom: 0.2rem;" | ||||||
|  |               @click="copyAddress(apiUrl + 'node/' + row.id)"> {{ | ||||||
|  |                 apiUrl | ||||||
|  |               }}node/{{ row.id }}</el-tag> | ||||||
|  |             <el-tag type="info" size="" @click="copyAddress(row.address)">{{ row.address }}</el-tag> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column label="版本" width="90"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;"> | ||||||
|  |               <el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version | ||||||
|  |               }}</el-tag> | ||||||
|  |               <span v-else class="text-muted" style="font-size: 11px;">未知</span> | ||||||
|  |               <el-tag :type="row.allow_relay ? 'success' : 'info'" size="small" | ||||||
|  |                 style="font-size: 9px; padding: 1px 3px;"> | ||||||
|  |                 {{ row.allow_relay ? '可中转' : '禁中转' }} | ||||||
|  |               </el-tag> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column label="连接状态" width="150"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <div class="connection-info"> | ||||||
|  |               <span>{{ row.current_connections }}/{{ row.max_connections }}</span> | ||||||
|  |               <el-progress :percentage="row.usage_percentage" :color="getProgressColor(row.usage_percentage)" | ||||||
|  |                 :stroke-width="6" :show-text="false" /> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column prop="description" label="描述" min-width="200"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <span class="description">{{ row.description || '暂无描述' }}</span> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |         <!-- 新增:标签展示 --> | ||||||
|  |         <el-table-column label="标签" min-width="160"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <div class="tags-list"> | ||||||
|  |               <el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" | ||||||
|  |                 :style="getTagStyle(tag)" style="margin: 2px 6px 2px 0;"> | ||||||
|  |                 {{ tag }} | ||||||
|  |               </el-tag> | ||||||
|  |               <span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column prop="created_at" label="创建时间" width="180"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             {{ formatDate(row.created_at) }} | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |  | ||||||
|  |         <el-table-column label="操作" width="120" fixed="right"> | ||||||
|  |           <template #default="{ row }"> | ||||||
|  |             <el-button type="primary" size="small" @click.stop="viewNodeDetails(row)"> | ||||||
|  |               详情 | ||||||
|  |             </el-button> | ||||||
|  |           </template> | ||||||
|  |         </el-table-column> | ||||||
|  |       </el-table> | ||||||
|  |  | ||||||
|  |       <!-- 分页 --> | ||||||
|  |       <div class="pagination-wrapper"> | ||||||
|  |         <el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" | ||||||
|  |           :page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper" | ||||||
|  |           @size-change="handleSizeChange" @current-change="handleCurrentChange" /> | ||||||
|  |       </div> | ||||||
|  |     </el-card> | ||||||
|  |  | ||||||
|  |     <!-- 节点详情对话框 --> | ||||||
|  |     <el-dialog v-model="detailDialogVisible" :title="selectedNode?.name + ' - 详细信息'" width="800px" destroy-on-close> | ||||||
|  |       <div v-if="selectedNode" class="node-details"> | ||||||
|  |         <el-descriptions :column="2" border> | ||||||
|  |           <el-descriptions-item label="节点名称">{{ selectedNode.name }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="状态"> | ||||||
|  |             <el-tag :type="selectedNode.is_active ? 'success' : 'danger'"> | ||||||
|  |               {{ selectedNode.is_active ? '在线' : '离线' }} | ||||||
|  |             </el-tag> | ||||||
|  |           </el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="主机地址">{{ selectedNode.host }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="端口">{{ selectedNode.port }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="协议">{{ selectedNode.protocol.toUpperCase() }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="版本">{{ selectedNode.version || '未知' }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="允许中转"> | ||||||
|  |             <el-tag :type="selectedNode.allow_relay ? 'success' : 'info'" size="small"> | ||||||
|  |               {{ selectedNode.allow_relay ? '是' : '否' }} | ||||||
|  |             </el-tag> | ||||||
|  |           </el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="使用率">{{ selectedNode.usage_percentage.toFixed(1) }}%</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item> | ||||||
|  |           <el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item> | ||||||
|  |           <!-- 新增:标签 --> | ||||||
|  |           <el-descriptions-item label="标签" :span="2"> | ||||||
|  |             <div class="tags-list"> | ||||||
|  |               <el-tag v-for="(tag, idx) in selectedNode.tags" :key="tag + idx" size="small" class="tag-chip" | ||||||
|  |                 style="margin: 2px 6px 2px 0;"> | ||||||
|  |                 {{ tag }} | ||||||
|  |               </el-tag> | ||||||
|  |               <span v-if="!selectedNode.tags || selectedNode.tags.length === 0" class="text-muted">无</span> | ||||||
|  |             </div> | ||||||
|  |           </el-descriptions-item> | ||||||
|  |         </el-descriptions> | ||||||
|  |  | ||||||
|  |         <!-- 健康状态统计 --> | ||||||
|  |         <div class="health-stats" v-if="healthStats"> | ||||||
|  |           <h3>健康状态统计 (最近24小时)</h3> | ||||||
|  |           <el-row :gutter="20"> | ||||||
|  |             <el-col :span="6"> | ||||||
|  |               <div class="health-stat-item"> | ||||||
|  |                 <div class="stat-value">{{ healthStats.uptime_percentage?.toFixed(1) || 0 }}%</div> | ||||||
|  |                 <div class="stat-label">在线率</div> | ||||||
|  |               </div> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :span="6"> | ||||||
|  |               <div class="health-stat-item"> | ||||||
|  |                 <div class="stat-value">{{ (selectedNode.last_response_time / 1000) || 0 }}ms</div> | ||||||
|  |                 <div class="stat-label">平均响应时间</div> | ||||||
|  |               </div> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :span="6"> | ||||||
|  |               <div class="health-stat-item"> | ||||||
|  |                 <div class="stat-value">{{ healthStats.total_checks || 0 }}</div> | ||||||
|  |                 <div class="stat-label">检查次数</div> | ||||||
|  |               </div> | ||||||
|  |             </el-col> | ||||||
|  |             <el-col :span="6"> | ||||||
|  |               <div class="health-stat-item"> | ||||||
|  |                 <div class="stat-value">{{ healthStats.failed_checks || 0 }}</div> | ||||||
|  |                 <div class="stat-label">失败次数</div> | ||||||
|  |               </div> | ||||||
|  |             </el-col> | ||||||
|  |           </el-row> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </el-dialog> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue' | ||||||
|  | import { ElMessage } from 'element-plus' | ||||||
|  | import { nodeApi } from '../api' | ||||||
|  | import dayjs from 'dayjs' | ||||||
|  | import HealthTimeline from '../components/HealthTimeline.vue' | ||||||
|  | import { | ||||||
|  |   Monitor, | ||||||
|  |   CircleCheck, | ||||||
|  |   CircleClose, | ||||||
|  |   Link, | ||||||
|  |   TrendCharts, | ||||||
|  |   Search, | ||||||
|  |   Refresh, | ||||||
|  |   Plus | ||||||
|  | } from '@element-plus/icons-vue' | ||||||
|  | import { getTagStyle } from '../utils/tagColor' | ||||||
|  |  | ||||||
|  | // 响应式数据 | ||||||
|  | const loading = ref(false) | ||||||
|  | const nodes = ref([]) | ||||||
|  | const searchText = ref('') | ||||||
|  | const statusFilter = ref('') | ||||||
|  | const protocolFilter = ref('') | ||||||
|  | const selectedTags = ref([]) | ||||||
|  | const allTags = ref([]) | ||||||
|  | const detailDialogVisible = ref(false) | ||||||
|  | const selectedNode = ref(null) | ||||||
|  | const healthStats = ref(null) | ||||||
|  | const expandedRows = ref([]) | ||||||
|  | const apiUrl = ref(window.location.href) | ||||||
|  | const tableRef = ref(null) | ||||||
|  | const nodesCardRef = ref(null) | ||||||
|  |  | ||||||
|  | // 请求取消控制(避免重复请求覆盖) | ||||||
|  | let fetchController = null | ||||||
|  |  | ||||||
|  | // 分页数据 | ||||||
|  | const pagination = reactive({ | ||||||
|  |   page: 1, | ||||||
|  |   per_page: 50, | ||||||
|  |   total: 0 | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 计算属性 | ||||||
|  | const totalNodes = computed(() => nodes.value.length) | ||||||
|  | const activeNodes = computed(() => nodes.value.filter(node => node.is_active).length) | ||||||
|  | const averageLoad = computed(() => | ||||||
|  |   (nodes.value.reduce((sum, node) => sum + node.current_connections, 0) / (nodes.value.length)).toFixed(2) | ||||||
|  | ) | ||||||
|  | const averageUptime = computed(() => { | ||||||
|  |   if (nodes.value.length === 0) return 0 | ||||||
|  |   const activeCount = nodes.value.filter(node => node.is_active).length | ||||||
|  |   return ((activeCount / nodes.value.length) * 100).toFixed(1) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 方法 | ||||||
|  | const fetchTags = async () => { | ||||||
|  |   try { | ||||||
|  |     const resp = await nodeApi.getAllTags() | ||||||
|  |     if (resp.success && Array.isArray(resp.data)) { | ||||||
|  |       allTags.value = resp.data | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('获取标签列表失败:', error) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const fetchNodes = async (with_loading = true) => { | ||||||
|  |   try { | ||||||
|  |     if (with_loading) { | ||||||
|  |       loading.value = true | ||||||
|  |     } | ||||||
|  |     const params = { | ||||||
|  |       page: pagination.page, | ||||||
|  |       per_page: pagination.per_page | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (searchText.value) { | ||||||
|  |       params.search = searchText.value | ||||||
|  |     } | ||||||
|  |     if (statusFilter.value !== '') { | ||||||
|  |       params.is_active = statusFilter.value === 'true' | ||||||
|  |     } | ||||||
|  |     if (protocolFilter.value) { | ||||||
|  |       params.protocol = protocolFilter.value | ||||||
|  |     } | ||||||
|  |     if (selectedTags.value && selectedTags.value.length > 0) { | ||||||
|  |       params.tags = selectedTags.value | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 取消上一请求,创建新的请求控制器 | ||||||
|  |     if (fetchController) { | ||||||
|  |       try { fetchController.abort() } catch (_) { } | ||||||
|  |     } | ||||||
|  |     fetchController = new AbortController() | ||||||
|  |  | ||||||
|  |     const response = await nodeApi.getNodes(params, { signal: fetchController.signal }) | ||||||
|  |     if (response.success && response.data) { | ||||||
|  |       nodes.value = response.data.items | ||||||
|  |       pagination.total = response.data.total | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     if (error.name === 'CanceledError' || error.name === 'AbortError') { | ||||||
|  |       // 被取消的旧请求,忽略 | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |     console.error('获取节点列表失败:', error) | ||||||
|  |     ElMessage.error('获取节点列表失败') | ||||||
|  |   } finally { | ||||||
|  |     if (with_loading) { | ||||||
|  |       loading.value = false | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const refreshData = () => { | ||||||
|  |   pagination.page = 1 | ||||||
|  |   fetchNodes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleSearch = () => { | ||||||
|  |   pagination.page = 1 | ||||||
|  |   fetchNodes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleFilter = () => { | ||||||
|  |   pagination.page = 1 | ||||||
|  |   fetchNodes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleSizeChange = (size) => { | ||||||
|  |   pagination.per_page = size | ||||||
|  |   pagination.page = 1 | ||||||
|  |   fetchNodes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleCurrentChange = (page) => { | ||||||
|  |   pagination.page = page | ||||||
|  |   fetchNodes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const viewNodeDetails = async (node) => { | ||||||
|  |   selectedNode.value = node | ||||||
|  |   detailDialogVisible.value = true | ||||||
|  |  | ||||||
|  |   // 获取健康状态统计 | ||||||
|  |   try { | ||||||
|  |     const response = await nodeApi.getNodeHealthStats(node.id, { hours: 24 }) | ||||||
|  |     if (response.success && response.data) { | ||||||
|  |       healthStats.value = response.data | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('获取健康状态统计失败:', error) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const formatDate = (dateString) => { | ||||||
|  |   return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const getProgressColor = (percentage) => { | ||||||
|  |   if (percentage < 50) return '#67C23A' | ||||||
|  |   if (percentage < 80) return '#E6A23C' | ||||||
|  |   return '#F56C6C' | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const copyAddress = (address) => { | ||||||
|  |   try { | ||||||
|  |     navigator.clipboard.writeText(address).then(() => { | ||||||
|  |       ElMessage.success(`地址已复制, ${address}`) | ||||||
|  |     }).catch(() => { | ||||||
|  |       ElMessage.error(`复制失败, ${address}`) | ||||||
|  |     }) | ||||||
|  |   } catch (error) { | ||||||
|  |     ElMessage.error(`复制失败, ${address}`) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 生命周期 | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchTags() | ||||||
|  |   fetchNodes() | ||||||
|  |  | ||||||
|  |   // 设置定时刷新 | ||||||
|  |   setInterval(() => { | ||||||
|  |     fetchNodes(false) | ||||||
|  |   }, 30000) // 每30秒刷新一次 | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 智能滚动处理:纵向滚动时页面整体滚动,横向滚动时表格内部滚动 | ||||||
|  | let wheelHandler = null | ||||||
|  | let wheelTargets = [] | ||||||
|  |  | ||||||
|  | const detachWheelHandlers = () => { | ||||||
|  |   if (wheelTargets && wheelTargets.length) { | ||||||
|  |     wheelTargets.forEach((el) => { | ||||||
|  |       try { el.removeEventListener('wheel', wheelHandler, { capture: true }) } catch (_) { } | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |   wheelTargets = [] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const attachWheelHandler = () => { | ||||||
|  |   const tableEl = tableRef.value?.$el | ||||||
|  |   const body = tableEl ? tableEl.querySelector('.el-table__body-wrapper') : null | ||||||
|  |   if (!body) return | ||||||
|  |  | ||||||
|  |   detachWheelHandlers() | ||||||
|  |   const wrap = body.querySelector('.el-scrollbar__wrap') || body | ||||||
|  |  | ||||||
|  |   wheelHandler = (e) => { | ||||||
|  |     const deltaX = e.deltaX | ||||||
|  |     const deltaY = e.deltaY | ||||||
|  |  | ||||||
|  |     // 如果是横向滚动(Shift + 滚轮 或 触摸板横向滑动) | ||||||
|  |     if (Math.abs(deltaX) > Math.abs(deltaY) || e.shiftKey) { | ||||||
|  |       // 允许表格内部横向滚动,不阻止默认行为 | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 如果是纵向滚动,阻止表格内部滚动,让页面整体滚动 | ||||||
|  |     if (deltaY) { | ||||||
|  |       e.preventDefault() | ||||||
|  |       e.stopPropagation() | ||||||
|  |       const scroller = document.scrollingElement || document.documentElement | ||||||
|  |       scroller.scrollTop += deltaY | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   body.addEventListener('wheel', wheelHandler, { passive: false, capture: true }) | ||||||
|  |   wheelTargets.push(body) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   nextTick(attachWheelHandler) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | watch(nodes, () => { | ||||||
|  |   nextTick(attachWheelHandler) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | onBeforeUnmount(() => { | ||||||
|  |   detachWheelHandlers() | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .node-dashboard { | ||||||
|  |   padding: 20px; | ||||||
|  |   background-color: #f5f7fa; | ||||||
|  |   min-height: 100vh; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dashboard-header { | ||||||
|  |   text-align: center; | ||||||
|  |   margin-bottom: 30px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dashboard-header h1 { | ||||||
|  |   color: #303133; | ||||||
|  |   margin-bottom: 10px; | ||||||
|  |   font-size: 32px; | ||||||
|  |   font-weight: 600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .subtitle { | ||||||
|  |   color: #606266; | ||||||
|  |   font-size: 16px; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stats-row { | ||||||
|  |   margin-bottom: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-card { | ||||||
|  |   position: relative; | ||||||
|  |   overflow: hidden; | ||||||
|  |   height: 100px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-content { | ||||||
|  |   padding: 0 16px; | ||||||
|  |   height: 100%; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   justify-content: center; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-number { | ||||||
|  |   font-size: 24px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   color: #303133; | ||||||
|  |   line-height: 1; | ||||||
|  |   margin-bottom: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-label { | ||||||
|  |   font-size: 12px; | ||||||
|  |   color: #909399; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-icon { | ||||||
|  |   position: absolute; | ||||||
|  |   right: 12px; | ||||||
|  |   top: 50%; | ||||||
|  |   transform: translateY(-50%); | ||||||
|  |   font-size: 28px; | ||||||
|  |   opacity: 0.3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .filter-card { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nodes-card { | ||||||
|  |   background: white; | ||||||
|  |   border-radius: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .card-header { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .node-name { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .address { | ||||||
|  |   margin-left: 8px; | ||||||
|  |   font-family: monospace; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .connection-info { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .description { | ||||||
|  |   color: #606266; | ||||||
|  |   font-size: 13px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-muted { | ||||||
|  |   color: #C0C4CC; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pagination-wrapper { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  |   margin-top: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .node-details { | ||||||
|  |   padding: 10px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-stats { | ||||||
|  |   margin-top: 30px; | ||||||
|  |   padding-top: 20px; | ||||||
|  |   border-top: 1px solid #EBEEF5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-stats h3 { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  |   color: #303133; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-stat-item { | ||||||
|  |   text-align: center; | ||||||
|  |   padding: 15px; | ||||||
|  |   background: #f8f9fa; | ||||||
|  |   border-radius: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-stat-item .stat-value { | ||||||
|  |   font-size: 24px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   color: #409EFF; | ||||||
|  |   margin-bottom: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .health-stat-item .stat-label { | ||||||
|  |   font-size: 12px; | ||||||
|  |   color: #909399; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .expanded-content { | ||||||
|  |   padding: 16px 24px; | ||||||
|  |   background-color: #fafafa; | ||||||
|  |   border-top: 1px solid #ebeef5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tag-option { | ||||||
|  |   display: inline-block; | ||||||
|  |   padding: 2px 6px; | ||||||
|  |   border-radius: 4px; | ||||||
|  |   font-size: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-table__body-wrapper) { | ||||||
|  |   overflow-x: auto !important; | ||||||
|  |   overflow-y: hidden !important; | ||||||
|  |   height: auto !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-card__body) { | ||||||
|  |   overflow: visible !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :deep(.el-table__body-wrapper .el-scrollbar__wrap) { | ||||||
|  |   overflow-x: auto !important; | ||||||
|  |   overflow-y: hidden !important; | ||||||
|  |   height: auto !important; | ||||||
|  |   max-height: none !important; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,351 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="submit-node"> | ||||||
|  |     <!-- 页面头部 --> | ||||||
|  |     <div class="page-header"> | ||||||
|  |       <el-button type="primary" @click="$router.back()" class="back-btn"> | ||||||
|  |         <el-icon> | ||||||
|  |           <ArrowLeft /> | ||||||
|  |         </el-icon> | ||||||
|  |         返回 | ||||||
|  |       </el-button> | ||||||
|  |       <h1>提交共享节点</h1> | ||||||
|  |       <p class="subtitle">分享您的EasyTier节点,为社区贡献力量</p> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <el-row :gutter="20" justify="center"> | ||||||
|  |       <el-col :span="16"> | ||||||
|  |         <!-- 提交表单 --> | ||||||
|  |         <el-card class="form-card"> | ||||||
|  |           <template #header> | ||||||
|  |             <div class="card-header"> | ||||||
|  |               <el-icon> | ||||||
|  |                 <Plus /> | ||||||
|  |               </el-icon> | ||||||
|  |               <span>节点信息</span> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |           <NodeForm ref="formRef" @submit="handleSubmit" :submitting="submitting" /> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |  | ||||||
|  |       <!-- 侧边栏信息 --> | ||||||
|  |       <el-col :span="8"> | ||||||
|  |         <el-card class="info-card"> | ||||||
|  |           <template #header> | ||||||
|  |             <div class="card-header"> | ||||||
|  |               <el-icon> | ||||||
|  |                 <InfoFilled /> | ||||||
|  |               </el-icon> | ||||||
|  |               <span>提交须知</span> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |  | ||||||
|  |           <div class="info-content"> | ||||||
|  |             <div class="info-item"> | ||||||
|  |               <el-icon color="#409EFF"> | ||||||
|  |                 <CircleCheck /> | ||||||
|  |               </el-icon> | ||||||
|  |               <div> | ||||||
|  |                 <h4>节点要求</h4> | ||||||
|  |                 <p>确保您的节点稳定运行,具有良好的网络连接</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="info-item"> | ||||||
|  |               <el-icon color="#67C23A"> | ||||||
|  |                 <Lock /> | ||||||
|  |               </el-icon> | ||||||
|  |               <div> | ||||||
|  |                 <h4>隐私保护</h4> | ||||||
|  |                 <p>关键信息仅社区管理员可见</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="info-item"> | ||||||
|  |               <el-icon color="#E6A23C"> | ||||||
|  |                 <Warning /> | ||||||
|  |               </el-icon> | ||||||
|  |               <div> | ||||||
|  |                 <h4>注意事项</h4> | ||||||
|  |                 <p>请确保节点信息准确,避免提交虚假信息</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="info-item"> | ||||||
|  |               <el-icon color="#F56C6C"> | ||||||
|  |                 <Delete /> | ||||||
|  |               </el-icon> | ||||||
|  |               <div> | ||||||
|  |                 <h4>移除条件</h4> | ||||||
|  |                 <p>长期离线或不稳定的节点将被自动移除</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="info-item"> | ||||||
|  |               <el-icon color="#F56C6C"> | ||||||
|  |                 <DocumentChecked /> | ||||||
|  |               </el-icon> | ||||||
|  |               <div> | ||||||
|  |                 <h4>审核机制</h4> | ||||||
|  |                 <p>所有节点提交均需要审核,审核通过后才会展示在节点列表中</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </el-card> | ||||||
|  |  | ||||||
|  |         <!-- 统计信息 --> | ||||||
|  |         <el-card class="stats-card"> | ||||||
|  |           <template #header> | ||||||
|  |             <div class="card-header"> | ||||||
|  |               <el-icon> | ||||||
|  |                 <DataAnalysis /> | ||||||
|  |               </el-icon> | ||||||
|  |               <span>社区统计</span> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |  | ||||||
|  |           <div class="stats-content"> | ||||||
|  |             <div class="stat-item"> | ||||||
|  |               <div class="stat-number">{{ communityStats.totalNodes }}</div> | ||||||
|  |               <div class="stat-label">总节点数</div> | ||||||
|  |             </div> | ||||||
|  |             <div class="stat-item"> | ||||||
|  |               <div class="stat-number">{{ communityStats.activeNodes }}</div> | ||||||
|  |               <div class="stat-label">在线节点</div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </el-card> | ||||||
|  |       </el-col> | ||||||
|  |     </el-row> | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, reactive, computed, onMounted } from 'vue' | ||||||
|  | import { useRouter } from 'vue-router' | ||||||
|  | import { ElMessage, ElMessageBox } from 'element-plus' | ||||||
|  | import { nodeApi } from '../api' | ||||||
|  | import { | ||||||
|  |   ArrowLeft, | ||||||
|  |   Plus, | ||||||
|  |   InfoFilled, | ||||||
|  |   CircleCheck, | ||||||
|  |   Lock, | ||||||
|  |   Warning, | ||||||
|  |   DocumentChecked, | ||||||
|  |   Delete, | ||||||
|  |   DataAnalysis | ||||||
|  | } from '@element-plus/icons-vue' | ||||||
|  | import NodeForm from '../components/NodeForm.vue' | ||||||
|  |  | ||||||
|  | const formRef = ref() | ||||||
|  | const router = useRouter() | ||||||
|  | const submitting = ref(false) | ||||||
|  |  | ||||||
|  | // 社区统计数据 | ||||||
|  | const communityStats = reactive({ | ||||||
|  |   totalNodes: 0, | ||||||
|  |   activeNodes: 0, | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const handleSubmit = async (submitData) => { | ||||||
|  |   try { | ||||||
|  |     const response = await nodeApi.createNode(submitData) | ||||||
|  |  | ||||||
|  |     if (response.success) { | ||||||
|  |       ElMessage.success('节点提交成功!') | ||||||
|  |       ElMessageBox.confirm( | ||||||
|  |         '节点已成功提交,等待管理员审核后将会展示在节点列表中。如果信息填写错误请重新提交或者联系管理员更改。', | ||||||
|  |         '提交成功', | ||||||
|  |         { | ||||||
|  |           confirmButtonText: '查看列表', | ||||||
|  |           cancelButtonText: '继续提交', | ||||||
|  |           type: 'success' | ||||||
|  |         } | ||||||
|  |       ).then(() => { | ||||||
|  |         router.push('/') | ||||||
|  |       }).catch(() => { | ||||||
|  |  | ||||||
|  |       }) | ||||||
|  |     } else { | ||||||
|  |       ElMessage.error(response.error || '提交失败,请重试') | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('提交节点失败:', error) | ||||||
|  |     ElMessage.error('提交失败,请检查网络连接') | ||||||
|  |   } finally { | ||||||
|  |     submitting.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const fetchCommunityStats = async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await nodeApi.getNodes({ page: 1, per_page: 1 }) | ||||||
|  |     if (response.success && response.data) { | ||||||
|  |       communityStats.totalNodes = response.data.total | ||||||
|  |  | ||||||
|  |       // 获取活跃节点数 | ||||||
|  |       const activeResponse = await nodeApi.getNodes({ page: 1, per_page: 1, is_active: true }) | ||||||
|  |       if (activeResponse.success && activeResponse.data) { | ||||||
|  |         communityStats.activeNodes = activeResponse.data.total | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('获取社区统计失败:', error) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 生命周期 | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchCommunityStats() | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .submit-node { | ||||||
|  |   padding: 20px; | ||||||
|  |   background-color: #f5f7fa; | ||||||
|  |   min-height: 100vh; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .page-header { | ||||||
|  |   text-align: center; | ||||||
|  |   margin-bottom: 30px; | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .back-btn { | ||||||
|  |   position: absolute; | ||||||
|  |   left: 0; | ||||||
|  |   top: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .page-header h1 { | ||||||
|  |   color: #303133; | ||||||
|  |   margin-bottom: 10px; | ||||||
|  |   font-size: 28px; | ||||||
|  |   font-weight: 600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .subtitle { | ||||||
|  |   color: #606266; | ||||||
|  |   font-size: 16px; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .info-card { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-content { | ||||||
|  |   padding: 10px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-item { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: flex-start; | ||||||
|  |   gap: 12px; | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-item:last-child { | ||||||
|  |   margin-bottom: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-item h4 { | ||||||
|  |   margin: 0 0 5px 0; | ||||||
|  |   font-size: 14px; | ||||||
|  |   color: #303133; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-item p { | ||||||
|  |   margin: 0; | ||||||
|  |   font-size: 13px; | ||||||
|  |   color: #606266; | ||||||
|  |   line-height: 1.4; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stats-card { | ||||||
|  |   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |   color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stats-card :deep(.el-card__header) { | ||||||
|  |   border-bottom-color: rgba(255, 255, 255, 0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stats-card :deep(.card-header) { | ||||||
|  |   color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stats-content { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 15px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-item { | ||||||
|  |   text-align: center; | ||||||
|  |   padding: 15px; | ||||||
|  |   background: rgba(255, 255, 255, 0.1); | ||||||
|  |   border-radius: 8px; | ||||||
|  |   backdrop-filter: blur(10px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-number { | ||||||
|  |   font-size: 24px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   margin-bottom: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .stat-label { | ||||||
|  |   font-size: 12px; | ||||||
|  |   opacity: 0.8; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .terms-content { | ||||||
|  |   max-height: 400px; | ||||||
|  |   overflow-y: auto; | ||||||
|  |   padding: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .terms-content h3 { | ||||||
|  |   color: #303133; | ||||||
|  |   margin: 20px 0 10px 0; | ||||||
|  |   font-size: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .terms-content h3:first-child { | ||||||
|  |   margin-top: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .terms-content p { | ||||||
|  |   margin: 5px 0; | ||||||
|  |   color: #606266; | ||||||
|  |   line-height: 1.6; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* 响应式设计 */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .submit-node { | ||||||
|  |     padding: 10px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .page-header { | ||||||
|  |     margin-bottom: 20px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .back-btn { | ||||||
|  |     position: static; | ||||||
|  |     margin-bottom: 10px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .submit-section { | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										30
									
								
								easytier-contrib/easytier-uptime/frontend/vite.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | |||||||
|  | import { defineConfig } from 'vite' | ||||||
|  | import vue from '@vitejs/plugin-vue' | ||||||
|  | import AutoImport from 'unplugin-auto-import/vite' | ||||||
|  | import Components from 'unplugin-vue-components/vite' | ||||||
|  | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' | ||||||
|  |  | ||||||
|  | // https://vite.dev/config/ | ||||||
|  | export default defineConfig({ | ||||||
|  |   plugins: [ | ||||||
|  |     vue(), | ||||||
|  |     AutoImport({ | ||||||
|  |       resolvers: [ElementPlusResolver()], | ||||||
|  |     }), | ||||||
|  |     Components({ | ||||||
|  |       resolvers: [ElementPlusResolver()], | ||||||
|  |     }), | ||||||
|  |   ], | ||||||
|  |   server: { | ||||||
|  |     proxy: { | ||||||
|  |       '/api': { | ||||||
|  |         target: 'http://localhost:11030', | ||||||
|  |         changeOrigin: true, | ||||||
|  |       }, | ||||||
|  |       '/health': { | ||||||
|  |         target: 'http://localhost:11030', | ||||||
|  |         changeOrigin: true, | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										80
									
								
								easytier-contrib/easytier-uptime/src/api/error.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | |||||||
|  | use axum::http::StatusCode; | ||||||
|  | use axum::response::{IntoResponse, Response}; | ||||||
|  | use serde_json::json; | ||||||
|  | use thiserror::Error; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Error)] | ||||||
|  | pub enum ApiError { | ||||||
|  |     #[error("Database error: {0}")] | ||||||
|  |     Database(#[from] sea_orm::DbErr), | ||||||
|  |  | ||||||
|  |     #[error("Validation error: {0}")] | ||||||
|  |     Validation(String), | ||||||
|  |  | ||||||
|  |     #[error("Not found: {0}")] | ||||||
|  |     NotFound(String), | ||||||
|  |  | ||||||
|  |     #[error("Bad request: {0}")] | ||||||
|  |     BadRequest(String), | ||||||
|  |  | ||||||
|  |     #[error("Internal server error: {0}")] | ||||||
|  |     Internal(String), | ||||||
|  |  | ||||||
|  |     #[error("Unauthorized: {0}")] | ||||||
|  |     Unauthorized(String), | ||||||
|  |  | ||||||
|  |     #[error("Forbidden: {0}")] | ||||||
|  |     Forbidden(String), | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl IntoResponse for ApiError { | ||||||
|  |     fn into_response(self) -> Response { | ||||||
|  |         let (status, error_message) = match self { | ||||||
|  |             ApiError::Database(err) => ( | ||||||
|  |                 StatusCode::INTERNAL_SERVER_ERROR, | ||||||
|  |                 format!("Database error: {}", err), | ||||||
|  |             ), | ||||||
|  |             ApiError::Validation(msg) => (StatusCode::BAD_REQUEST, msg), | ||||||
|  |             ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), | ||||||
|  |             ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), | ||||||
|  |             ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), | ||||||
|  |             ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), | ||||||
|  |             ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let body = json!({ | ||||||
|  |             "error": { | ||||||
|  |                 "code": status.as_u16(), | ||||||
|  |                 "message": error_message | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         (status, axum::Json(body)).into_response() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub type ApiResult<T> = Result<T, ApiError>; | ||||||
|  |  | ||||||
|  | impl From<validator::ValidationErrors> for ApiError { | ||||||
|  |     fn from(err: validator::ValidationErrors) -> Self { | ||||||
|  |         let errors: Vec<String> = err | ||||||
|  |             .field_errors() | ||||||
|  |             .iter() | ||||||
|  |             .map(|(field, errors)| { | ||||||
|  |                 let error_msgs: Vec<String> = errors | ||||||
|  |                     .iter() | ||||||
|  |                     .map(|error| { | ||||||
|  |                         if let Some(msg) = &error.message { | ||||||
|  |                             msg.to_string() | ||||||
|  |                         } else { | ||||||
|  |                             format!("Validation failed for field: {}", field) | ||||||
|  |                         } | ||||||
|  |                     }) | ||||||
|  |                     .collect(); | ||||||
|  |                 error_msgs.join(", ") | ||||||
|  |             }) | ||||||
|  |             .collect(); | ||||||
|  |  | ||||||
|  |         ApiError::Validation(errors.join("; ")) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										607
									
								
								easytier-contrib/easytier-uptime/src/api/handlers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,607 @@ | |||||||
|  | use std::ops::{Div, Mul}; | ||||||
|  |  | ||||||
|  | use axum::extract::{Path, State}; | ||||||
|  | use axum::Json; | ||||||
|  | use sea_orm::{ | ||||||
|  |     ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait, | ||||||
|  |     QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel, | ||||||
|  | }; | ||||||
|  | use serde::Deserialize; | ||||||
|  | use validator::Validate; | ||||||
|  |  | ||||||
|  | use crate::api::{ | ||||||
|  |     error::{ApiError, ApiResult}, | ||||||
|  |     models::*, | ||||||
|  | }; | ||||||
|  | use crate::db::entity::{self, health_records, shared_nodes}; | ||||||
|  | use crate::db::{operations::*, Db}; | ||||||
|  | use crate::health_checker_manager::HealthCheckerManager; | ||||||
|  | use axum_extra::extract::Query; | ||||||
|  | use std::sync::Arc; | ||||||
|  |  | ||||||
|  | #[derive(Clone)] | ||||||
|  | pub struct AppState { | ||||||
|  |     pub db: Db, | ||||||
|  |     pub health_checker_manager: Arc<HealthCheckerManager>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn health_check() -> Json<ApiResponse<String>> { | ||||||
|  |     Json(ApiResponse::message("Service is healthy".to_string())) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_nodes( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Query(pagination): Query<PaginationParams>, | ||||||
|  |     Query(filters): Query<NodeFilterParams>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> { | ||||||
|  |     let page = pagination.page.unwrap_or(1); | ||||||
|  |     let per_page = pagination.per_page.unwrap_or(20); | ||||||
|  |  | ||||||
|  |     let offset = (page - 1) * per_page; | ||||||
|  |  | ||||||
|  |     let mut query = entity::shared_nodes::Entity::find(); | ||||||
|  |  | ||||||
|  |     // 普通用户只能看到已审核的节点 | ||||||
|  |     query = query.filter(entity::shared_nodes::Column::IsApproved.eq(true)); | ||||||
|  |  | ||||||
|  |     if let Some(is_active) = filters.is_active { | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(protocol) = filters.protocol { | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(search) = filters.search { | ||||||
|  |         query = query.filter( | ||||||
|  |             sea_orm::Condition::any() | ||||||
|  |                 .add(entity::shared_nodes::Column::Name.contains(&search)) | ||||||
|  |                 .add(entity::shared_nodes::Column::Host.contains(&search)) | ||||||
|  |                 .add(entity::shared_nodes::Column::Description.contains(&search)), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 标签过滤(支持单标签与多标签 OR) | ||||||
|  |     let mut filtered_ids: Option<Vec<i32>> = None; | ||||||
|  |     if !filters.tags.is_empty() { | ||||||
|  |         let ids_any = | ||||||
|  |             NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &filters.tags).await?; | ||||||
|  |         filtered_ids = match filtered_ids { | ||||||
|  |             Some(mut existing) => { | ||||||
|  |                 // 合并去重 | ||||||
|  |                 existing.extend(ids_any); | ||||||
|  |                 existing.sort(); | ||||||
|  |                 existing.dedup(); | ||||||
|  |                 Some(existing) | ||||||
|  |             } | ||||||
|  |             None => Some(ids_any), | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     if let Some(ids) = filtered_ids { | ||||||
|  |         if ids.is_empty() { | ||||||
|  |             return Ok(Json(ApiResponse::success(PaginatedResponse { | ||||||
|  |                 items: vec![], | ||||||
|  |                 total: 0, | ||||||
|  |                 page, | ||||||
|  |                 per_page, | ||||||
|  |                 total_pages: 0, | ||||||
|  |             }))); | ||||||
|  |         } | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::Id.is_in(ids)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let total = query.clone().count(app_state.db.orm_db()).await?; | ||||||
|  |     let nodes = query | ||||||
|  |         .order_by_asc(entity::shared_nodes::Column::Id) | ||||||
|  |         .limit(Some(per_page as u64)) | ||||||
|  |         .offset(Some(offset as u64)) | ||||||
|  |         .all(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect(); | ||||||
|  |     let total_pages = total.div_ceil(per_page as u64); | ||||||
|  |  | ||||||
|  |     // 补充标签 | ||||||
|  |     let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect(); | ||||||
|  |     let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?; | ||||||
|  |     for n in &mut node_responses { | ||||||
|  |         n.tags = tags_map.get(&n.id).cloned().unwrap_or_default(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 为每个节点添加健康状态信息 | ||||||
|  |     for node_response in &mut node_responses { | ||||||
|  |         if let Some(mut health_record) = app_state | ||||||
|  |             .health_checker_manager | ||||||
|  |             .get_node_memory_record(node_response.id) | ||||||
|  |         { | ||||||
|  |             node_response.current_health_status = | ||||||
|  |                 Some(health_record.get_current_health_status().to_string()); | ||||||
|  |             node_response.last_check_time = Some(health_record.get_last_check_time()); | ||||||
|  |             node_response.last_response_time = health_record.get_last_response_time(); | ||||||
|  |  | ||||||
|  |             // 获取24小时健康统计 | ||||||
|  |             if let Some(stats) = app_state | ||||||
|  |                 .health_checker_manager | ||||||
|  |                 .get_node_health_stats(node_response.id, 24) | ||||||
|  |             { | ||||||
|  |                 node_response.health_percentage_24h = Some(stats.health_percentage); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let (total_ring, healthy_ring) = health_record.get_counter_ring(); | ||||||
|  |             node_response.health_record_total_counter_ring = total_ring; | ||||||
|  |             node_response.health_record_healthy_counter_ring = healthy_ring; | ||||||
|  |             node_response.ring_granularity = health_record.get_ring_granularity(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // remove sensitive information | ||||||
|  |     node_responses.iter_mut().for_each(|node| { | ||||||
|  |         node.network_name = None; | ||||||
|  |         node.network_secret = None; | ||||||
|  |  | ||||||
|  |         // make cur connection and max conn round to percentage | ||||||
|  |         if node.max_connections != 0 { | ||||||
|  |             node.current_connections = node.current_connections.mul(100).div(node.max_connections); | ||||||
|  |             node.max_connections = 100; | ||||||
|  |         } else { | ||||||
|  |             node.current_connections = 0; | ||||||
|  |             node.max_connections = 0; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         node.wechat = None; | ||||||
|  |         node.qq_number = None; | ||||||
|  |         node.mail = None; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(PaginatedResponse { | ||||||
|  |         items: node_responses, | ||||||
|  |         total, | ||||||
|  |         page, | ||||||
|  |         per_page, | ||||||
|  |         total_pages: total_pages as u32, | ||||||
|  |     }))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn create_node( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Json(request): Json<CreateNodeRequest>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     request.validate()?; | ||||||
|  |  | ||||||
|  |     let node = NodeOperations::create_node(&app_state.db, request).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(NodeResponse::from(node)))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn test_connection( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Json(request): Json<CreateNodeRequest>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     let mut node = NodeOperations::create_node_model(request); | ||||||
|  |     node.id = Set(0); | ||||||
|  |     let node = node.try_into_model()?; | ||||||
|  |     app_state | ||||||
|  |         .health_checker_manager | ||||||
|  |         .test_connection(&node, std::time::Duration::from_secs(5)) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(e.to_string()))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(NodeResponse::from(node)))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_node( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     let node = NodeOperations::get_node_by_id(&app_state.db, id) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?; | ||||||
|  |  | ||||||
|  |     let mut resp = NodeResponse::from(node); | ||||||
|  |     resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(resp))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_node_health( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(node_id): Path<i32>, | ||||||
|  |     Query(pagination): Query<PaginationParams>, | ||||||
|  |     Query(filters): Query<HealthFilterParams>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<PaginatedResponse<HealthRecordResponse>>>> { | ||||||
|  |     let page = pagination.page.unwrap_or(1); | ||||||
|  |     let per_page = pagination.per_page.unwrap_or(20); | ||||||
|  |     let offset = (page - 1) * per_page; | ||||||
|  |  | ||||||
|  |     let mut query = entity::health_records::Entity::find() | ||||||
|  |         .filter(entity::health_records::Column::NodeId.eq(node_id)); | ||||||
|  |  | ||||||
|  |     if let Some(status) = filters.status { | ||||||
|  |         query = query.filter(entity::health_records::Column::Status.eq(status)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(since) = filters.since { | ||||||
|  |         query = query.filter(entity::health_records::Column::CheckedAt.gte(since.naive_utc())); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let total = query.clone().count(app_state.db.orm_db()).await?; | ||||||
|  |     let records = query | ||||||
|  |         .order_by_desc(entity::health_records::Column::CheckedAt) | ||||||
|  |         .limit(Some(per_page as u64)) | ||||||
|  |         .offset(Some(offset as u64)) | ||||||
|  |         .all(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     let record_responses: Vec<HealthRecordResponse> = records | ||||||
|  |         .into_iter() | ||||||
|  |         .map(HealthRecordResponse::from) | ||||||
|  |         .collect(); | ||||||
|  |     let total_pages = total.div_ceil(per_page as u64); | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(PaginatedResponse { | ||||||
|  |         items: record_responses, | ||||||
|  |         total, | ||||||
|  |         page, | ||||||
|  |         per_page, | ||||||
|  |         total_pages: total_pages as u32, | ||||||
|  |     }))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_node_health_stats( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(node_id): Path<i32>, | ||||||
|  |     Query(params): Query<HealthStatsParams>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<HealthStatsResponse>>> { | ||||||
|  |     let hours = params.hours.unwrap_or(24); | ||||||
|  |     let stats = HealthOperations::get_health_stats(&app_state.db, node_id, hours).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(HealthStatsResponse::from(stats)))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct HealthStatsParams { | ||||||
|  |     pub hours: Option<i64>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct InstanceFilterParams { | ||||||
|  |     pub node_id: Option<i32>, | ||||||
|  |     pub status: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 管理员相关处理器 | ||||||
|  | use crate::config::AppConfig; | ||||||
|  | use axum::http::{HeaderMap, StatusCode}; | ||||||
|  | use chrono::{Duration, Utc}; | ||||||
|  | use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; | ||||||
|  | use serde::Serialize; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | struct AdminClaims { | ||||||
|  |     sub: String, | ||||||
|  |     exp: usize, | ||||||
|  |     iat: usize, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_node_connect_url( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  | ) -> ApiResult<String> { | ||||||
|  |     let node = NodeOperations::get_node_by_id(&app_state.db, id) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?; | ||||||
|  |     let connect_url = format!("{}://{}:{}", node.protocol, node.host, node.port); | ||||||
|  |     Ok(connect_url) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_login( | ||||||
|  |     Json(request): Json<AdminLoginRequest>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<AdminLoginResponse>>> { | ||||||
|  |     request | ||||||
|  |         .validate() | ||||||
|  |         .map_err(|e| ApiError::Validation(e.to_string()))?; | ||||||
|  |  | ||||||
|  |     let config = AppConfig::default(); | ||||||
|  |  | ||||||
|  |     if request.password != config.security.admin_password { | ||||||
|  |         return Err(ApiError::Unauthorized("Invalid password".to_string())); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let now = Utc::now(); | ||||||
|  |     let expires_at = now + Duration::hours(24); | ||||||
|  |  | ||||||
|  |     let claims = AdminClaims { | ||||||
|  |         sub: "admin".to_string(), | ||||||
|  |         exp: expires_at.timestamp() as usize, | ||||||
|  |         iat: now.timestamp() as usize, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let token = encode( | ||||||
|  |         &Header::default(), | ||||||
|  |         &claims, | ||||||
|  |         &EncodingKey::from_secret(config.security.jwt_secret.as_ref()), | ||||||
|  |     ) | ||||||
|  |     .map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(AdminLoginResponse { | ||||||
|  |         token, | ||||||
|  |         expires_at, | ||||||
|  |     }))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_get_nodes( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Query(pagination): Query<PaginationParams>, | ||||||
|  |     Query(filters): Query<AdminNodeFilterParams>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |  | ||||||
|  |     let page = pagination.page.unwrap_or(1); | ||||||
|  |     let per_page = pagination.per_page.unwrap_or(200); | ||||||
|  |     let offset = (page - 1) * per_page; | ||||||
|  |  | ||||||
|  |     let mut query = entity::shared_nodes::Entity::find(); | ||||||
|  |  | ||||||
|  |     if let Some(is_active) = filters.is_active { | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(is_approved) = filters.is_approved { | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::IsApproved.eq(is_approved)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(protocol) = filters.protocol { | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(search) = filters.search { | ||||||
|  |         query = query.filter( | ||||||
|  |             sea_orm::Condition::any() | ||||||
|  |                 .add(entity::shared_nodes::Column::Name.contains(&search)) | ||||||
|  |                 .add(entity::shared_nodes::Column::Host.contains(&search)) | ||||||
|  |                 .add(entity::shared_nodes::Column::Description.contains(&search)), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 标签过滤(支持单标签与多标签 OR) | ||||||
|  |     let mut filtered_ids: Option<Vec<i32>> = None; | ||||||
|  |     if let Some(tag) = filters.tag { | ||||||
|  |         let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?; | ||||||
|  |         filtered_ids = Some(ids); | ||||||
|  |     } | ||||||
|  |     if let Some(tags) = filters.tags { | ||||||
|  |         if !tags.is_empty() { | ||||||
|  |             let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?; | ||||||
|  |             filtered_ids = match filtered_ids { | ||||||
|  |                 Some(mut existing) => { | ||||||
|  |                     existing.extend(ids_any); | ||||||
|  |                     existing.sort(); | ||||||
|  |                     existing.dedup(); | ||||||
|  |                     Some(existing) | ||||||
|  |                 } | ||||||
|  |                 None => Some(ids_any), | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     if let Some(ids) = filtered_ids { | ||||||
|  |         if ids.is_empty() { | ||||||
|  |             return Ok(Json(ApiResponse::success(PaginatedResponse { | ||||||
|  |                 items: vec![], | ||||||
|  |                 total: 0, | ||||||
|  |                 page, | ||||||
|  |                 per_page, | ||||||
|  |                 total_pages: 0, | ||||||
|  |             }))); | ||||||
|  |         } | ||||||
|  |         query = query.filter(entity::shared_nodes::Column::Id.is_in(ids)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let total = query.clone().count(app_state.db.orm_db()).await?; | ||||||
|  |  | ||||||
|  |     let nodes = query | ||||||
|  |         .order_by(entity::shared_nodes::Column::CreatedAt, Order::Desc) | ||||||
|  |         .offset(offset as u64) | ||||||
|  |         .limit(per_page as u64) | ||||||
|  |         .all(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect(); | ||||||
|  |  | ||||||
|  |     // 补充标签 | ||||||
|  |     let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect(); | ||||||
|  |     let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?; | ||||||
|  |     for n in &mut node_responses { | ||||||
|  |         n.tags = tags_map.get(&n.id).cloned().unwrap_or_default(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let total_pages = (total as f64 / per_page as f64).ceil() as u32; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(PaginatedResponse { | ||||||
|  |         items: node_responses, | ||||||
|  |         total, | ||||||
|  |         page, | ||||||
|  |         per_page, | ||||||
|  |         total_pages, | ||||||
|  |     }))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_approve_node( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |  | ||||||
|  |     let node = entity::shared_nodes::Entity::find_by_id(id) | ||||||
|  |         .one(app_state.db.orm_db()) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?; | ||||||
|  |  | ||||||
|  |     let mut active_model = node.into_active_model(); | ||||||
|  |     active_model.is_approved = sea_orm::Set(true); | ||||||
|  |  | ||||||
|  |     let updated_node = entity::shared_nodes::Entity::update(active_model) | ||||||
|  |         .exec(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     let mut resp = NodeResponse::from(updated_node); | ||||||
|  |     resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(resp))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_update_node( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  |     Json(request): Json<UpdateNodeRequest>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |     request.validate()?; | ||||||
|  |  | ||||||
|  |     let mut node = NodeOperations::get_node_by_id(&app_state.db, id) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?; | ||||||
|  |  | ||||||
|  |     let mut node = node.into_active_model(); | ||||||
|  |  | ||||||
|  |     if let Some(name) = request.name { | ||||||
|  |         node.name = Set(name); | ||||||
|  |     } | ||||||
|  |     if let Some(host) = request.host { | ||||||
|  |         node.host = Set(host); | ||||||
|  |     } | ||||||
|  |     if let Some(port) = request.port { | ||||||
|  |         node.port = Set(port); | ||||||
|  |     } | ||||||
|  |     if let Some(protocol) = request.protocol { | ||||||
|  |         node.protocol = Set(protocol); | ||||||
|  |     } | ||||||
|  |     if let Some(description) = request.description { | ||||||
|  |         node.description = Set(description); | ||||||
|  |     } | ||||||
|  |     if let Some(max_connections) = request.max_connections { | ||||||
|  |         node.max_connections = Set(max_connections); | ||||||
|  |     } | ||||||
|  |     if let Some(is_active) = request.is_active { | ||||||
|  |         node.is_active = Set(is_active); | ||||||
|  |     } | ||||||
|  |     if let Some(allow_relay) = request.allow_relay { | ||||||
|  |         node.allow_relay = Set(allow_relay); | ||||||
|  |     } | ||||||
|  |     if let Some(network_name) = request.network_name { | ||||||
|  |         node.network_name = Set(network_name); | ||||||
|  |     } | ||||||
|  |     if let Some(network_secret) = request.network_secret { | ||||||
|  |         node.network_secret = Set(network_secret); | ||||||
|  |     } | ||||||
|  |     if let Some(wechat) = request.wechat { | ||||||
|  |         node.wechat = Set(wechat); | ||||||
|  |     } | ||||||
|  |     if let Some(mail) = request.mail { | ||||||
|  |         node.mail = Set(mail); | ||||||
|  |     } | ||||||
|  |     if let Some(qq_number) = request.qq_number { | ||||||
|  |         node.qq_number = Set(qq_number); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     node.updated_at = Set(chrono::Utc::now().fixed_offset()); | ||||||
|  |  | ||||||
|  |     tracing::info!("updated node: {:?}", node); | ||||||
|  |  | ||||||
|  |     let updated_node = entity::shared_nodes::Entity::update(node) | ||||||
|  |         .exec(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     // 更新标签 | ||||||
|  |     if let Some(tags) = request.tags { | ||||||
|  |         NodeOperations::set_node_tags(&app_state.db, updated_node.id, tags).await?; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let mut resp = NodeResponse::from(updated_node); | ||||||
|  |     resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(resp))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_revoke_approval( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<NodeResponse>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |  | ||||||
|  |     let node = entity::shared_nodes::Entity::find_by_id(id) | ||||||
|  |         .one(app_state.db.orm_db()) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?; | ||||||
|  |  | ||||||
|  |     let mut active_model = node.into_active_model(); | ||||||
|  |     active_model.is_approved = sea_orm::Set(false); | ||||||
|  |  | ||||||
|  |     let updated_node = entity::shared_nodes::Entity::update(active_model) | ||||||
|  |         .exec(app_state.db.orm_db()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |     let mut resp = NodeResponse::from(updated_node); | ||||||
|  |     resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::success(resp))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_delete_node( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<String>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |  | ||||||
|  |     let node = entity::shared_nodes::Entity::find_by_id(id) | ||||||
|  |         .one(app_state.db.orm_db()) | ||||||
|  |         .await? | ||||||
|  |         .ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?; | ||||||
|  |  | ||||||
|  |     node.delete(app_state.db.orm_db()).await?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ApiResponse::message( | ||||||
|  |         "Node deleted successfully".to_string(), | ||||||
|  |     ))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn admin_verify_token(headers: HeaderMap) -> ApiResult<Json<ApiResponse<String>>> { | ||||||
|  |     verify_admin_token(&headers)?; | ||||||
|  |     Ok(Json(ApiResponse::message("Token is valid".to_string()))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> { | ||||||
|  |     let config = AppConfig::default(); | ||||||
|  |  | ||||||
|  |     let auth_header = headers | ||||||
|  |         .get("authorization") | ||||||
|  |         .ok_or_else(|| ApiError::Unauthorized("Missing authorization header".to_string()))?; | ||||||
|  |  | ||||||
|  |     let auth_str = auth_header | ||||||
|  |         .to_str() | ||||||
|  |         .map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?; | ||||||
|  |  | ||||||
|  |     let token = auth_str | ||||||
|  |         .strip_prefix("Bearer ") | ||||||
|  |         .ok_or_else(|| ApiError::Unauthorized("Invalid authorization format".to_string()))?; | ||||||
|  |  | ||||||
|  |     let _claims = decode::<AdminClaims>( | ||||||
|  |         token, | ||||||
|  |         &DecodingKey::from_secret(config.security.jwt_secret.as_ref()), | ||||||
|  |         &Validation::default(), | ||||||
|  |     ) | ||||||
|  |     .map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?; | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_all_tags( | ||||||
|  |     State(app_state): State<AppState>, | ||||||
|  | ) -> ApiResult<Json<ApiResponse<Vec<String>>>> { | ||||||
|  |     let tags = NodeOperations::get_all_tags(&app_state.db).await?; | ||||||
|  |     Ok(Json(ApiResponse::success(tags))) | ||||||
|  | } | ||||||
							
								
								
									
										8
									
								
								easytier-contrib/easytier-uptime/src/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | |||||||
|  | pub mod error; | ||||||
|  | pub mod handlers; | ||||||
|  | pub mod models; | ||||||
|  | pub mod routes; | ||||||
|  |  | ||||||
|  | pub use error::{ApiError, ApiResult}; | ||||||
|  | pub use handlers::*; | ||||||
|  | pub use models::*; | ||||||
							
								
								
									
										325
									
								
								easytier-contrib/easytier-uptime/src/api/models.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,325 @@ | |||||||
|  | use crate::db::entity; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use validator::Validate; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct ApiResponse<T> { | ||||||
|  |     pub success: bool, | ||||||
|  |     pub data: Option<T>, | ||||||
|  |     pub error: Option<String>, | ||||||
|  |     pub message: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<T> ApiResponse<T> { | ||||||
|  |     pub fn success(data: T) -> Self { | ||||||
|  |         Self { | ||||||
|  |             success: true, | ||||||
|  |             data: Some(data), | ||||||
|  |             error: None, | ||||||
|  |             message: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn error(error: String) -> Self { | ||||||
|  |         Self { | ||||||
|  |             success: false, | ||||||
|  |             data: None, | ||||||
|  |             error: Some(error), | ||||||
|  |             message: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn message(message: String) -> Self { | ||||||
|  |         Self { | ||||||
|  |             success: true, | ||||||
|  |             data: None, | ||||||
|  |             error: None, | ||||||
|  |             message: Some(message), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct PaginatedResponse<T> { | ||||||
|  |     pub items: Vec<T>, | ||||||
|  |     pub total: u64, | ||||||
|  |     pub page: u32, | ||||||
|  |     pub per_page: u32, | ||||||
|  |     pub total_pages: u32, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct PaginationParams { | ||||||
|  |     pub page: Option<u32>, | ||||||
|  |     pub per_page: Option<u32>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for PaginationParams { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         Self { | ||||||
|  |             page: Some(1), | ||||||
|  |             per_page: Some(20), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Validate)] | ||||||
|  | #[validate(schema(function = "validate_contact_info", skip_on_field_errors = false))] | ||||||
|  | pub struct CreateNodeRequest { | ||||||
|  |     #[validate(length(min = 1, max = 100))] | ||||||
|  |     pub name: String, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 255))] | ||||||
|  |     pub host: String, | ||||||
|  |  | ||||||
|  |     #[validate(range(min = 1, max = 65535))] | ||||||
|  |     pub port: i32, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 20))] | ||||||
|  |     pub protocol: String, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 500))] | ||||||
|  |     pub description: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(range(min = 1, max = 10000))] | ||||||
|  |     pub max_connections: i32, | ||||||
|  |  | ||||||
|  |     pub allow_relay: bool, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 100))] | ||||||
|  |     pub network_name: String, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 100))] | ||||||
|  |     pub network_secret: Option<String>, | ||||||
|  |  | ||||||
|  |     // 联系方式字段 | ||||||
|  |     #[validate(length(max = 20))] | ||||||
|  |     pub qq_number: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 50))] | ||||||
|  |     pub wechat: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(email)] | ||||||
|  |     pub mail: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 自定义验证函数:确保至少填写一种联系方式 | ||||||
|  | fn validate_contact_info(request: &CreateNodeRequest) -> Result<(), validator::ValidationError> { | ||||||
|  |     let has_qq = request | ||||||
|  |         .qq_number | ||||||
|  |         .as_ref() | ||||||
|  |         .is_some_and(|s| !s.trim().is_empty()); | ||||||
|  |     let has_wechat = request | ||||||
|  |         .wechat | ||||||
|  |         .as_ref() | ||||||
|  |         .is_some_and(|s| !s.trim().is_empty()); | ||||||
|  |     let has_mail = request.mail.as_ref().is_some_and(|s| !s.trim().is_empty()); | ||||||
|  |  | ||||||
|  |     if !has_qq && !has_wechat && !has_mail { | ||||||
|  |         return Err(validator::ValidationError::new("contact_required")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Validate)] | ||||||
|  | pub struct UpdateNodeRequest { | ||||||
|  |     #[validate(length(min = 1, max = 100))] | ||||||
|  |     pub name: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 255))] | ||||||
|  |     pub host: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(range(min = 1, max = 65535))] | ||||||
|  |     pub port: Option<i32>, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 20))] | ||||||
|  |     pub protocol: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 500))] | ||||||
|  |     pub description: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(range(min = 1, max = 10000))] | ||||||
|  |     pub max_connections: Option<i32>, | ||||||
|  |  | ||||||
|  |     pub is_active: Option<bool>, | ||||||
|  |  | ||||||
|  |     pub allow_relay: Option<bool>, | ||||||
|  |  | ||||||
|  |     #[validate(length(min = 1, max = 100))] | ||||||
|  |     pub network_name: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 100))] | ||||||
|  |     pub network_secret: Option<String>, | ||||||
|  |  | ||||||
|  |     // 联系方式字段 | ||||||
|  |     #[validate(length(max = 20))] | ||||||
|  |     pub qq_number: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(length(max = 50))] | ||||||
|  |     pub wechat: Option<String>, | ||||||
|  |  | ||||||
|  |     #[validate(email)] | ||||||
|  |     pub mail: Option<String>, | ||||||
|  |  | ||||||
|  |     // 标签字段(仅管理员可用) | ||||||
|  |     pub tags: Option<Vec<String>>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct NodeResponse { | ||||||
|  |     pub id: i32, | ||||||
|  |     pub name: String, | ||||||
|  |     pub host: String, | ||||||
|  |     pub port: i32, | ||||||
|  |     pub protocol: String, | ||||||
|  |     pub version: Option<String>, | ||||||
|  |     pub description: Option<String>, | ||||||
|  |     pub max_connections: i32, | ||||||
|  |     pub current_connections: i32, | ||||||
|  |     pub is_active: bool, | ||||||
|  |     pub is_approved: bool, | ||||||
|  |     pub allow_relay: bool, | ||||||
|  |     pub network_name: Option<String>, | ||||||
|  |     pub network_secret: Option<String>, | ||||||
|  |     pub created_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub updated_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub address: String, | ||||||
|  |     pub usage_percentage: f64, | ||||||
|  |     // 健康状态相关字段 | ||||||
|  |     pub current_health_status: Option<String>, | ||||||
|  |     pub last_check_time: Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  |     pub last_response_time: Option<i32>, | ||||||
|  |     pub health_percentage_24h: Option<f64>, | ||||||
|  |  | ||||||
|  |     pub health_record_total_counter_ring: Vec<u64>, | ||||||
|  |     pub health_record_healthy_counter_ring: Vec<u64>, | ||||||
|  |     pub ring_granularity: u32, | ||||||
|  |  | ||||||
|  |     // 联系方式字段 | ||||||
|  |     pub qq_number: Option<String>, | ||||||
|  |     pub wechat: Option<String>, | ||||||
|  |     pub mail: Option<String>, | ||||||
|  |     pub tags: Vec<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<entity::shared_nodes::Model> for NodeResponse { | ||||||
|  |     fn from(node: entity::shared_nodes::Model) -> Self { | ||||||
|  |         Self { | ||||||
|  |             id: node.id, | ||||||
|  |             name: node.name.clone(), | ||||||
|  |             host: node.host.clone(), | ||||||
|  |             port: node.port, | ||||||
|  |             protocol: node.protocol.clone(), | ||||||
|  |             version: Some(node.version.clone()), | ||||||
|  |             description: Some(node.description.clone()), | ||||||
|  |             max_connections: node.max_connections, | ||||||
|  |             current_connections: node.current_connections, | ||||||
|  |             is_active: node.is_active, | ||||||
|  |             is_approved: node.is_approved, | ||||||
|  |             allow_relay: node.allow_relay, | ||||||
|  |             network_name: Some(node.network_name.clone()), | ||||||
|  |             network_secret: Some(node.network_secret.clone()), | ||||||
|  |             created_at: node.created_at.into(), | ||||||
|  |             updated_at: node.updated_at.into(), | ||||||
|  |             address: format!("{}://{}:{}", node.protocol, node.host, node.port), | ||||||
|  |             usage_percentage: node.current_connections as f64 / node.max_connections as f64 * 100.0, | ||||||
|  |             // 健康状态字段初始化为 None,将在 handlers 中填充 | ||||||
|  |             current_health_status: None, | ||||||
|  |             last_check_time: None, | ||||||
|  |             last_response_time: None, | ||||||
|  |             health_percentage_24h: None, | ||||||
|  |  | ||||||
|  |             health_record_healthy_counter_ring: Vec::new(), | ||||||
|  |             health_record_total_counter_ring: Vec::new(), | ||||||
|  |             ring_granularity: 0, | ||||||
|  |  | ||||||
|  |             // 联系方式字段 | ||||||
|  |             qq_number: if node.qq_number.is_empty() { | ||||||
|  |                 None | ||||||
|  |             } else { | ||||||
|  |                 Some(node.qq_number) | ||||||
|  |             }, | ||||||
|  |             wechat: if node.wechat.is_empty() { | ||||||
|  |                 None | ||||||
|  |             } else { | ||||||
|  |                 Some(node.wechat) | ||||||
|  |             }, | ||||||
|  |             mail: if node.mail.is_empty() { | ||||||
|  |                 None | ||||||
|  |             } else { | ||||||
|  |                 Some(node.mail) | ||||||
|  |             }, | ||||||
|  |             tags: Vec::new(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct HealthRecordResponse { | ||||||
|  |     pub id: i32, | ||||||
|  |     pub node_id: i32, | ||||||
|  |     pub status: String, | ||||||
|  |     pub response_time: Option<i32>, | ||||||
|  |     pub error_message: Option<String>, | ||||||
|  |     pub checked_at: chrono::DateTime<chrono::Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<entity::health_records::Model> for HealthRecordResponse { | ||||||
|  |     fn from(record: entity::health_records::Model) -> Self { | ||||||
|  |         Self { | ||||||
|  |             id: record.id, | ||||||
|  |             node_id: record.node_id, | ||||||
|  |             status: record.status.to_string(), | ||||||
|  |             response_time: Some(record.response_time), | ||||||
|  |             error_message: Some(record.error_message), | ||||||
|  |             checked_at: record.checked_at.into(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub type HealthStatsResponse = crate::db::HealthStats; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct NodeFilterParams { | ||||||
|  |     pub is_active: Option<bool>, | ||||||
|  |     pub protocol: Option<String>, | ||||||
|  |     pub search: Option<String>, | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub tags: Vec<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct HealthFilterParams { | ||||||
|  |     pub status: Option<String>, | ||||||
|  |     pub since: Option<DateTime<Utc>>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 管理员相关模型 | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Validate)] | ||||||
|  | pub struct AdminLoginRequest { | ||||||
|  |     #[validate(length(min = 1))] | ||||||
|  |     pub password: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct AdminLoginResponse { | ||||||
|  |     pub token: String, | ||||||
|  |     pub expires_at: DateTime<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct ApproveNodeRequest { | ||||||
|  |     pub approved: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct AdminNodeFilterParams { | ||||||
|  |     pub is_active: Option<bool>, | ||||||
|  |     pub is_approved: Option<bool>, | ||||||
|  |     pub protocol: Option<String>, | ||||||
|  |     pub search: Option<String>, | ||||||
|  |     pub tag: Option<String>, | ||||||
|  |     pub tags: Option<Vec<String>>, | ||||||
|  | } | ||||||
							
								
								
									
										65
									
								
								easytier-contrib/easytier-uptime/src/api/routes.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,65 @@ | |||||||
|  | use axum::routing::{delete, get, post, put}; | ||||||
|  | use axum::Router; | ||||||
|  | use tower_http::compression::CompressionLayer; | ||||||
|  | use tower_http::cors::CorsLayer; | ||||||
|  |  | ||||||
|  | use super::handlers::AppState; | ||||||
|  | use super::handlers::{ | ||||||
|  |     admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval, | ||||||
|  |     admin_update_node, admin_verify_token, create_node, get_all_tags, get_node, get_node_health, | ||||||
|  |     get_node_health_stats, get_nodes, health_check, | ||||||
|  | }; | ||||||
|  | use crate::api::{get_node_connect_url, test_connection}; | ||||||
|  | use crate::config::AppConfig; | ||||||
|  | use crate::db::Db; | ||||||
|  |  | ||||||
|  | pub fn create_routes() -> Router<AppState> { | ||||||
|  |     let config = AppConfig::default(); | ||||||
|  |  | ||||||
|  |     let compression_layer = if config.security.enable_compression { | ||||||
|  |         Some( | ||||||
|  |             CompressionLayer::new() | ||||||
|  |                 .br(true) | ||||||
|  |                 .deflate(true) | ||||||
|  |                 .gzip(true) | ||||||
|  |                 .zstd(true), | ||||||
|  |         ) | ||||||
|  |     } else { | ||||||
|  |         None | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let cors_layer = if config.cors.enabled { | ||||||
|  |         Some(CorsLayer::very_permissive()) | ||||||
|  |     } else { | ||||||
|  |         None | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let mut router = Router::new() | ||||||
|  |         .route("/node/{id}", get(get_node_connect_url)) | ||||||
|  |         .route("/health", get(health_check)) | ||||||
|  |         .route("/api/nodes", get(get_nodes).post(create_node)) | ||||||
|  |         .route("/api/tags", get(get_all_tags)) | ||||||
|  |         .route("/api/test_connection", post(test_connection)) | ||||||
|  |         .route("/api/nodes/{id}/health", get(get_node_health)) | ||||||
|  |         .route("/api/nodes/{id}/health/stats", get(get_node_health_stats)) | ||||||
|  |         // 管理员路由 | ||||||
|  |         .route("/api/admin/login", post(admin_login)) | ||||||
|  |         .route("/api/admin/verify", get(admin_verify_token)) | ||||||
|  |         .route("/api/admin/nodes", get(admin_get_nodes)) | ||||||
|  |         .route("/api/admin/nodes/{id}/approve", put(admin_approve_node)) | ||||||
|  |         .route("/api/admin/nodes/{id}/revoke", put(admin_revoke_approval)) | ||||||
|  |         .route( | ||||||
|  |             "/api/admin/nodes/{id}", | ||||||
|  |             put(admin_update_node).delete(admin_delete_node), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |     if let Some(layer) = compression_layer { | ||||||
|  |         router = router.layer(layer); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if let Some(layer) = cors_layer { | ||||||
|  |         router = router.layer(layer); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     router | ||||||
|  | } | ||||||
							
								
								
									
										206
									
								
								easytier-contrib/easytier-uptime/src/config.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,206 @@ | |||||||
|  | use std::env; | ||||||
|  | use std::net::{IpAddr, SocketAddr}; | ||||||
|  | use std::path::PathBuf; | ||||||
|  |  | ||||||
|  | use easytier::common::config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfig}; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct AppConfig { | ||||||
|  |     pub server: ServerConfig, | ||||||
|  |     pub database: DatabaseConfig, | ||||||
|  |     pub health_check: HealthCheckConfig, | ||||||
|  |     pub logging: LoggingConfig, | ||||||
|  |     pub cors: CorsConfig, | ||||||
|  |     pub security: SecurityConfig, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct ServerConfig { | ||||||
|  |     pub host: String, | ||||||
|  |     pub port: u16, | ||||||
|  |     pub addr: SocketAddr, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct DatabaseConfig { | ||||||
|  |     pub path: PathBuf, | ||||||
|  |     pub max_connections: u32, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct HealthCheckConfig { | ||||||
|  |     pub interval_seconds: u64, | ||||||
|  |     pub timeout_seconds: u64, | ||||||
|  |     pub max_retries: u32, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct CorsConfig { | ||||||
|  |     pub allowed_origins: Vec<String>, | ||||||
|  |     pub allowed_methods: Vec<String>, | ||||||
|  |     pub allowed_headers: Vec<String>, | ||||||
|  |     pub enabled: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct SecurityConfig { | ||||||
|  |     pub enable_compression: bool, | ||||||
|  |     pub secret_key: String, | ||||||
|  |     pub jwt_secret: String, | ||||||
|  |     pub admin_password: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for AppConfig { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         Self::from_env().unwrap_or_else(|_| Self::default_config()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl AppConfig { | ||||||
|  |     pub fn from_env() -> Result<Self, env::VarError> { | ||||||
|  |         let server_config = ServerConfig { | ||||||
|  |             host: env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()), | ||||||
|  |             port: env::var("SERVER_PORT") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(8080)) | ||||||
|  |                 .unwrap_or(8080), | ||||||
|  |             addr: SocketAddr::from(( | ||||||
|  |                 env::var("SERVER_HOST") | ||||||
|  |                     .unwrap_or_else(|_| "127.0.0.1".to_string()) | ||||||
|  |                     .parse::<IpAddr>() | ||||||
|  |                     .unwrap(), | ||||||
|  |                 env::var("SERVER_PORT") | ||||||
|  |                     .map(|s| s.parse().unwrap_or(8080)) | ||||||
|  |                     .unwrap_or(8080), | ||||||
|  |             )), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let database_config = DatabaseConfig { | ||||||
|  |             path: PathBuf::from( | ||||||
|  |                 env::var("DATABASE_PATH").unwrap_or_else(|_| "uptime.db".to_string()), | ||||||
|  |             ), | ||||||
|  |             max_connections: env::var("DATABASE_MAX_CONNECTIONS") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(10)) | ||||||
|  |                 .unwrap_or(10), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let health_check_config = HealthCheckConfig { | ||||||
|  |             interval_seconds: env::var("HEALTH_CHECK_INTERVAL") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(30)) | ||||||
|  |                 .unwrap_or(30), | ||||||
|  |             timeout_seconds: env::var("HEALTH_CHECK_TIMEOUT") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(10)) | ||||||
|  |                 .unwrap_or(10), | ||||||
|  |             max_retries: env::var("HEALTH_CHECK_RETRIES") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(3)) | ||||||
|  |                 .unwrap_or(3), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let logging_config = LoggingConfig { | ||||||
|  |             file_logger: Some(FileLoggerConfig { | ||||||
|  |                 level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())), | ||||||
|  |                 file: Some("easytier-uptime.log".to_string()), | ||||||
|  |                 ..Default::default() | ||||||
|  |             }), | ||||||
|  |             console_logger: Some(ConsoleLoggerConfig { | ||||||
|  |                 level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())), | ||||||
|  |             }), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let cors_config = CorsConfig { | ||||||
|  |             allowed_origins: env::var("CORS_ALLOWED_ORIGINS") | ||||||
|  |                 .unwrap_or_else(|_| "http://localhost:3000,http://localhost:8080".to_string()) | ||||||
|  |                 .split(',') | ||||||
|  |                 .map(|s| s.trim().to_string()) | ||||||
|  |                 .collect(), | ||||||
|  |             allowed_methods: env::var("CORS_ALLOWED_METHODS") | ||||||
|  |                 .unwrap_or_else(|_| "GET,POST,PUT,DELETE,OPTIONS".to_string()) | ||||||
|  |                 .split(',') | ||||||
|  |                 .map(|s| s.trim().to_string()) | ||||||
|  |                 .collect(), | ||||||
|  |             allowed_headers: env::var("CORS_ALLOWED_HEADERS") | ||||||
|  |                 .unwrap_or_else(|_| "content-type,authorization".to_string()) | ||||||
|  |                 .split(',') | ||||||
|  |                 .map(|s| s.trim().to_string()) | ||||||
|  |                 .collect(), | ||||||
|  |             enabled: env::var("ENABLE_CORS") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(true)) | ||||||
|  |                 .unwrap_or(true), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let security_config = SecurityConfig { | ||||||
|  |             enable_compression: env::var("ENABLE_COMPRESSION") | ||||||
|  |                 .map(|s| s.parse().unwrap_or(true)) | ||||||
|  |                 .unwrap_or(true), | ||||||
|  |             secret_key: env::var("SECRET_KEY").unwrap_or_else(|_| "default-secret-key".to_string()), | ||||||
|  |             jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "default-jwt-secret".to_string()), | ||||||
|  |             admin_password: env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string()), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         Ok(AppConfig { | ||||||
|  |             server: server_config, | ||||||
|  |             database: database_config, | ||||||
|  |             health_check: health_check_config, | ||||||
|  |             logging: logging_config, | ||||||
|  |             cors: cors_config, | ||||||
|  |             security: security_config, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn default_config() -> Self { | ||||||
|  |         Self { | ||||||
|  |             server: ServerConfig { | ||||||
|  |                 host: "127.0.0.1".to_string(), | ||||||
|  |                 port: 8080, | ||||||
|  |                 addr: SocketAddr::from(([127, 0, 0, 1], 8080)), | ||||||
|  |             }, | ||||||
|  |             database: DatabaseConfig { | ||||||
|  |                 path: PathBuf::from("uptime.db"), | ||||||
|  |                 max_connections: 10, | ||||||
|  |             }, | ||||||
|  |             health_check: HealthCheckConfig { | ||||||
|  |                 interval_seconds: 30, | ||||||
|  |                 timeout_seconds: 10, | ||||||
|  |                 max_retries: 3, | ||||||
|  |             }, | ||||||
|  |             logging: LoggingConfig { | ||||||
|  |                 file_logger: Some(FileLoggerConfig { | ||||||
|  |                     level: Some("info".to_string()), | ||||||
|  |                     file: Some("easytier-uptime.log".to_string()), | ||||||
|  |                     ..Default::default() | ||||||
|  |                 }), | ||||||
|  |                 console_logger: Some(ConsoleLoggerConfig { | ||||||
|  |                     level: Some("info".to_string()), | ||||||
|  |                 }), | ||||||
|  |             }, | ||||||
|  |             cors: CorsConfig { | ||||||
|  |                 allowed_origins: vec![ | ||||||
|  |                     "http://localhost:3000".to_string(), | ||||||
|  |                     "http://localhost:8080".to_string(), | ||||||
|  |                 ], | ||||||
|  |                 allowed_methods: vec![ | ||||||
|  |                     "GET".to_string(), | ||||||
|  |                     "POST".to_string(), | ||||||
|  |                     "PUT".to_string(), | ||||||
|  |                     "DELETE".to_string(), | ||||||
|  |                     "OPTIONS".to_string(), | ||||||
|  |                 ], | ||||||
|  |                 allowed_headers: vec!["content-type".to_string(), "authorization".to_string()], | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |             security: SecurityConfig { | ||||||
|  |                 enable_compression: true, | ||||||
|  |                 secret_key: "default-secret-key".to_string(), | ||||||
|  |                 jwt_secret: "default-jwt-secret".to_string(), | ||||||
|  |                 admin_password: "admin123".to_string(), | ||||||
|  |             }, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn is_development(&self) -> bool { | ||||||
|  |         env::var("NODE_ENV").unwrap_or_else(|_| "development".to_string()) == "development" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn is_production(&self) -> bool { | ||||||
|  |         env::var("NODE_ENV").unwrap_or_else(|_| "development".to_string()) == "production" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										360
									
								
								easytier-contrib/easytier-uptime/src/db/cleanup.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,360 @@ | |||||||
|  | use crate::db::entity::*; | ||||||
|  | use crate::db::Db; | ||||||
|  | use sea_orm::*; | ||||||
|  | use tokio::time::{sleep, Duration}; | ||||||
|  | use tracing::{error, info, warn}; | ||||||
|  |  | ||||||
|  | /// 数据清理策略配置 | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct CleanupConfig { | ||||||
|  |     /// 健康记录保留天数 | ||||||
|  |     pub health_record_retention_days: i64, | ||||||
|  |     /// 每个节点保留的健康记录最大数量 | ||||||
|  |     pub max_health_records_per_node: u64, | ||||||
|  |     /// 清理任务运行间隔(秒) | ||||||
|  |     pub cleanup_interval_seconds: u64, | ||||||
|  |     /// 是否启用自动清理 | ||||||
|  |     pub auto_cleanup_enabled: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for CleanupConfig { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         Self { | ||||||
|  |             health_record_retention_days: 30, | ||||||
|  |             max_health_records_per_node: 70000, | ||||||
|  |             cleanup_interval_seconds: 1200, // 20分钟 | ||||||
|  |             auto_cleanup_enabled: true, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 数据清理管理器 | ||||||
|  | pub struct CleanupManager { | ||||||
|  |     db: Db, | ||||||
|  |     config: CleanupConfig, | ||||||
|  |     running: std::sync::Arc<std::sync::atomic::AtomicBool>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl CleanupManager { | ||||||
|  |     /// 创建新的清理管理器 | ||||||
|  |     pub fn new(db: Db, config: CleanupConfig) -> Self { | ||||||
|  |         Self { | ||||||
|  |             db, | ||||||
|  |             config, | ||||||
|  |             running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 使用默认配置创建清理管理器 | ||||||
|  |     pub fn with_default_config(db: Db) -> Self { | ||||||
|  |         Self::new(db, CleanupConfig::default()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 启动自动清理任务 | ||||||
|  |     pub async fn start_auto_cleanup(&self) -> anyhow::Result<()> { | ||||||
|  |         if self.config.auto_cleanup_enabled { | ||||||
|  |             let running = self.running.clone(); | ||||||
|  |             let db = self.db.clone(); | ||||||
|  |             let config = self.config.clone(); | ||||||
|  |  | ||||||
|  |             running.store(true, std::sync::atomic::Ordering::SeqCst); | ||||||
|  |  | ||||||
|  |             tokio::spawn(async move { | ||||||
|  |                 info!("Auto cleanup task started"); | ||||||
|  |  | ||||||
|  |                 while running.load(std::sync::atomic::Ordering::SeqCst) { | ||||||
|  |                     if let Err(e) = Self::perform_cleanup(&db, &config).await { | ||||||
|  |                         error!("Auto cleanup failed: {}", e); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     sleep(Duration::from_secs(config.cleanup_interval_seconds)).await; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 info!("Auto cleanup task stopped"); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 停止自动清理任务 | ||||||
|  |     pub fn stop_auto_cleanup(&self) { | ||||||
|  |         self.running | ||||||
|  |             .store(false, std::sync::atomic::Ordering::SeqCst); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 执行一次完整的清理操作 | ||||||
|  |     pub async fn perform_cleanup(db: &Db, config: &CleanupConfig) -> anyhow::Result<CleanupResult> { | ||||||
|  |         let mut result = CleanupResult::default(); | ||||||
|  |  | ||||||
|  |         // 清理旧的健康记录 | ||||||
|  |         let health_cleanup_result = | ||||||
|  |             Self::cleanup_old_health_records(db, config.health_record_retention_days).await?; | ||||||
|  |         result.old_health_records_cleaned = health_cleanup_result.records_removed; | ||||||
|  |  | ||||||
|  |         // 清理过量的健康记录 | ||||||
|  |         let excess_cleanup_result = | ||||||
|  |             Self::cleanup_excess_health_records(db, config.max_health_records_per_node).await?; | ||||||
|  |         result.excess_health_records_cleaned = excess_cleanup_result.records_removed; | ||||||
|  |  | ||||||
|  |         // 数据库维护 | ||||||
|  |         let maintenance_result = Self::perform_database_maintenance(db).await?; | ||||||
|  |         result.vacuum_performed = maintenance_result.vacuum_performed; | ||||||
|  |         result.analyze_performed = maintenance_result.analyze_performed; | ||||||
|  |  | ||||||
|  |         info!("Cleanup completed: {:?}", result); | ||||||
|  |  | ||||||
|  |         Ok(result) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 清理旧的健康记录 | ||||||
|  |     async fn cleanup_old_health_records( | ||||||
|  |         db: &Db, | ||||||
|  |         days: i64, | ||||||
|  |     ) -> anyhow::Result<CleanupHealthRecordsResult> { | ||||||
|  |         let cutoff = chrono::Local::now().fixed_offset() - chrono::Duration::days(days); | ||||||
|  |  | ||||||
|  |         let result = health_records::Entity::delete_many() | ||||||
|  |             .filter(health_records::Column::CheckedAt.lt(cutoff)) | ||||||
|  |             .exec(db.orm_db()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         let records_removed = result.rows_affected; | ||||||
|  |  | ||||||
|  |         if records_removed > 0 { | ||||||
|  |             info!( | ||||||
|  |                 "Cleaned {} old health records (older than {} days)", | ||||||
|  |                 records_removed, days | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(CleanupHealthRecordsResult { records_removed }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 清理过量的健康记录 | ||||||
|  |     async fn cleanup_excess_health_records( | ||||||
|  |         db: &Db, | ||||||
|  |         max_records: u64, | ||||||
|  |     ) -> anyhow::Result<CleanupExcessRecordsResult> { | ||||||
|  |         // 获取所有节点 | ||||||
|  |         let nodes = shared_nodes::Entity::find().all(db.orm_db()).await?; | ||||||
|  |  | ||||||
|  |         let mut total_removed = 0; | ||||||
|  |  | ||||||
|  |         for node in nodes { | ||||||
|  |             // 计算需要删除的记录数量 | ||||||
|  |             let total_count = health_records::Entity::find() | ||||||
|  |                 .filter(health_records::Column::NodeId.eq(node.id)) | ||||||
|  |                 .count(db.orm_db()) | ||||||
|  |                 .await?; | ||||||
|  |  | ||||||
|  |             if total_count > max_records { | ||||||
|  |                 let to_remove = total_count - max_records; | ||||||
|  |  | ||||||
|  |                 // 获取需要保留的最小ID | ||||||
|  |                 let keep_id = health_records::Entity::find() | ||||||
|  |                     .filter(health_records::Column::NodeId.eq(node.id)) | ||||||
|  |                     .order_by_desc(health_records::Column::CheckedAt) | ||||||
|  |                     .offset(max_records) | ||||||
|  |                     .limit(1) | ||||||
|  |                     .into_model::<health_records::Model>() | ||||||
|  |                     .one(db.orm_db()) | ||||||
|  |                     .await?; | ||||||
|  |  | ||||||
|  |                 info!( | ||||||
|  |                     "Node {}: total count: {}, to remove: {}, last keep record: {:?}", | ||||||
|  |                     node.id, total_count, to_remove, keep_id | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 if let Some(keep_record) = keep_id { | ||||||
|  |                     // 删除比保留记录更早的记录 | ||||||
|  |                     let result = health_records::Entity::delete_many() | ||||||
|  |                         .filter(health_records::Column::NodeId.eq(node.id)) | ||||||
|  |                         .filter(health_records::Column::Id.lt(keep_record.id)) | ||||||
|  |                         .exec(db.orm_db()) | ||||||
|  |                         .await?; | ||||||
|  |  | ||||||
|  |                     total_removed += result.rows_affected; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if total_removed > 0 { | ||||||
|  |             info!( | ||||||
|  |                 "Cleaned {} excess health records (max {} per node)", | ||||||
|  |                 total_removed, max_records | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(CleanupExcessRecordsResult { | ||||||
|  |             records_removed: total_removed, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 执行数据库维护操作 | ||||||
|  |     async fn perform_database_maintenance(db: &Db) -> anyhow::Result<DatabaseMaintenanceResult> { | ||||||
|  |         let mut vacuum_performed = false; | ||||||
|  |         let mut analyze_performed = false; | ||||||
|  |  | ||||||
|  |         // 执行 ANALYZE | ||||||
|  |         match db | ||||||
|  |             .orm_db() | ||||||
|  |             .execute(Statement::from_string( | ||||||
|  |                 DatabaseBackend::Sqlite, | ||||||
|  |                 "ANALYZE".to_string(), | ||||||
|  |             )) | ||||||
|  |             .await | ||||||
|  |         { | ||||||
|  |             Ok(_) => { | ||||||
|  |                 analyze_performed = true; | ||||||
|  |                 info!("Database ANALYZE completed"); | ||||||
|  |             } | ||||||
|  |             Err(e) => { | ||||||
|  |                 warn!("Database ANALYZE failed: {}", e); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 执行 VACUUM(仅在需要时) | ||||||
|  |         if vacuum_performed || analyze_performed { | ||||||
|  |             match db | ||||||
|  |                 .orm_db() | ||||||
|  |                 .execute(Statement::from_string( | ||||||
|  |                     DatabaseBackend::Sqlite, | ||||||
|  |                     "VACUUM".to_string(), | ||||||
|  |                 )) | ||||||
|  |                 .await | ||||||
|  |             { | ||||||
|  |                 Ok(_) => { | ||||||
|  |                     vacuum_performed = true; | ||||||
|  |                     info!("Database VACUUM completed"); | ||||||
|  |                 } | ||||||
|  |                 Err(e) => { | ||||||
|  |                     warn!("Database VACUUM failed: {}", e); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(DatabaseMaintenanceResult { | ||||||
|  |             vacuum_performed, | ||||||
|  |             analyze_performed, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 获取数据库统计信息 | ||||||
|  |     pub async fn get_database_stats(db: &Db) -> anyhow::Result<DatabaseStats> { | ||||||
|  |         let total_nodes = shared_nodes::Entity::find().count(db.orm_db()).await?; | ||||||
|  |  | ||||||
|  |         let total_health_records = health_records::Entity::find().count(db.orm_db()).await?; | ||||||
|  |  | ||||||
|  |         let active_nodes = shared_nodes::Entity::find() | ||||||
|  |             .filter(shared_nodes::Column::IsActive.eq(true)) | ||||||
|  |             .count(db.orm_db()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         Ok(DatabaseStats { | ||||||
|  |             total_nodes, | ||||||
|  |             active_nodes, | ||||||
|  |             total_health_records, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 获取清理配置 | ||||||
|  |     pub fn get_config(&self) -> &CleanupConfig { | ||||||
|  |         &self.config | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 更新清理配置 | ||||||
|  |     pub fn update_config(&mut self, config: CleanupConfig) { | ||||||
|  |         self.config = config; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 清理结果 | ||||||
|  | #[derive(Default, Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct CleanupResult { | ||||||
|  |     pub old_health_records_cleaned: u64, | ||||||
|  |     pub old_instances_cleaned: u64, | ||||||
|  |     pub excess_health_records_cleaned: u64, | ||||||
|  |     pub vacuum_performed: bool, | ||||||
|  |     pub analyze_performed: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 健康记录清理结果 | ||||||
|  | #[derive(Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct CleanupHealthRecordsResult { | ||||||
|  |     pub records_removed: u64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 停止实例清理结果 | ||||||
|  | #[derive(Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct CleanupStoppedInstancesResult { | ||||||
|  |     pub instances_removed: u64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 过量记录清理结果 | ||||||
|  | #[derive(Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct CleanupExcessRecordsResult { | ||||||
|  |     pub records_removed: u64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 数据库维护结果 | ||||||
|  | #[derive(Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct DatabaseMaintenanceResult { | ||||||
|  |     pub vacuum_performed: bool, | ||||||
|  |     pub analyze_performed: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 数据库统计信息 | ||||||
|  | #[derive(Debug, Clone, serde::Serialize)] | ||||||
|  | pub struct DatabaseStats { | ||||||
|  |     pub total_nodes: u64, | ||||||
|  |     pub active_nodes: u64, | ||||||
|  |     pub total_health_records: u64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use super::*; | ||||||
|  |     use crate::Db; | ||||||
|  |  | ||||||
|  |     #[tokio::test] | ||||||
|  |     async fn test_cleanup_manager() { | ||||||
|  |         let db = Db::memory_db().await; | ||||||
|  |         let cleanup_manager = CleanupManager::with_default_config(db.clone()); | ||||||
|  |  | ||||||
|  |         // 测试获取配置 | ||||||
|  |         let config = cleanup_manager.get_config(); | ||||||
|  |         assert_eq!(config.health_record_retention_days, 30); | ||||||
|  |  | ||||||
|  |         // 测试清理操作 | ||||||
|  |         let result = CleanupManager::perform_cleanup(&db, config).await.unwrap(); | ||||||
|  |         println!("Cleanup result: {:?}", result); | ||||||
|  |  | ||||||
|  |         // 测试获取统计信息 | ||||||
|  |         let stats = CleanupManager::get_database_stats(&db).await.unwrap(); | ||||||
|  |         println!("Database stats: {:?}", stats); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[tokio::test] | ||||||
|  |     async fn test_cleanup_config() { | ||||||
|  |         let config = CleanupConfig { | ||||||
|  |             health_record_retention_days: 7, | ||||||
|  |             max_health_records_per_node: 500, | ||||||
|  |             cleanup_interval_seconds: 1800, | ||||||
|  |             auto_cleanup_enabled: false, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let db = Db::memory_db().await; | ||||||
|  |         let mut cleanup_manager = CleanupManager::new(db, config.clone()); | ||||||
|  |  | ||||||
|  |         assert_eq!(cleanup_manager.get_config().health_record_retention_days, 7); | ||||||
|  |  | ||||||
|  |         // 测试更新配置 | ||||||
|  |         let new_config = CleanupConfig::default(); | ||||||
|  |         cleanup_manager.update_config(new_config); | ||||||
|  |         assert_eq!( | ||||||
|  |             cleanup_manager.get_config().health_record_retention_days, | ||||||
|  |             30 | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,39 @@ | |||||||
|  | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 | ||||||
|  |  | ||||||
|  | use sea_orm::entity::prelude::*; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
|  | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] | ||||||
|  | #[sea_orm(table_name = "connection_instances")] | ||||||
|  | pub struct Model { | ||||||
|  |     #[sea_orm(primary_key)] | ||||||
|  |     pub id: i32, | ||||||
|  |     pub node_id: i32, | ||||||
|  |     #[sea_orm(unique)] | ||||||
|  |     pub instance_id: String, | ||||||
|  |     pub status: String, | ||||||
|  |     #[sea_orm(column_type = "Text")] | ||||||
|  |     pub config: String, | ||||||
|  |     pub started_at: DateTimeWithTimeZone, | ||||||
|  |     pub stopped_at: DateTimeWithTimeZone, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||||
|  | pub enum Relation { | ||||||
|  |     #[sea_orm( | ||||||
|  |         belongs_to = "super::shared_nodes::Entity", | ||||||
|  |         from = "Column::NodeId", | ||||||
|  |         to = "super::shared_nodes::Column::Id", | ||||||
|  |         on_update = "Cascade", | ||||||
|  |         on_delete = "Cascade" | ||||||
|  |     )] | ||||||
|  |     SharedNodes, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Related<super::shared_nodes::Entity> for Entity { | ||||||
|  |     fn to() -> RelationDef { | ||||||
|  |         Relation::SharedNodes.def() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl ActiveModelBehavior for ActiveModel {} | ||||||
| @@ -0,0 +1,37 @@ | |||||||
|  | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 | ||||||
|  |  | ||||||
|  | use sea_orm::entity::prelude::*; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
|  | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] | ||||||
|  | #[sea_orm(table_name = "health_records")] | ||||||
|  | pub struct Model { | ||||||
|  |     #[sea_orm(primary_key)] | ||||||
|  |     pub id: i32, | ||||||
|  |     pub node_id: i32, | ||||||
|  |     pub status: String, | ||||||
|  |     pub response_time: i32, | ||||||
|  |     #[sea_orm(column_type = "Text")] | ||||||
|  |     pub error_message: String, | ||||||
|  |     pub checked_at: DateTimeWithTimeZone, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||||
|  | pub enum Relation { | ||||||
|  |     #[sea_orm( | ||||||
|  |         belongs_to = "super::shared_nodes::Entity", | ||||||
|  |         from = "Column::NodeId", | ||||||
|  |         to = "super::shared_nodes::Column::Id", | ||||||
|  |         on_update = "Cascade", | ||||||
|  |         on_delete = "Cascade" | ||||||
|  |     )] | ||||||
|  |     SharedNodes, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Related<super::shared_nodes::Entity> for Entity { | ||||||
|  |     fn to() -> RelationDef { | ||||||
|  |         Relation::SharedNodes.def() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl ActiveModelBehavior for ActiveModel {} | ||||||
							
								
								
									
										7
									
								
								easytier-contrib/easytier-uptime/src/db/entity/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | |||||||
|  | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 | ||||||
|  |  | ||||||
|  | pub mod prelude; | ||||||
|  |  | ||||||
|  | pub mod health_records; | ||||||
|  | pub mod node_tags; | ||||||
|  | pub mod shared_nodes; | ||||||
							
								
								
									
										32
									
								
								easytier-contrib/easytier-uptime/src/db/entity/node_tags.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | |||||||
|  | //! `SeaORM` Entity for node tags | ||||||
|  |  | ||||||
|  | use sea_orm::entity::prelude::*; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
|  | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] | ||||||
|  | #[sea_orm(table_name = "node_tags")] | ||||||
|  | pub struct Model { | ||||||
|  |     #[sea_orm(primary_key)] | ||||||
|  |     pub id: i32, | ||||||
|  |     pub node_id: i32, | ||||||
|  |     pub tag: String, | ||||||
|  |     pub created_at: DateTimeWithTimeZone, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||||
|  | pub enum Relation { | ||||||
|  |     #[sea_orm( | ||||||
|  |         belongs_to = "super::shared_nodes::Entity", | ||||||
|  |         from = "Column::NodeId", | ||||||
|  |         to = "super::shared_nodes::Column::Id" | ||||||
|  |     )] | ||||||
|  |     SharedNodes, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Related<super::shared_nodes::Entity> for Entity { | ||||||
|  |     fn to() -> RelationDef { | ||||||
|  |         Relation::SharedNodes.def() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl ActiveModelBehavior for ActiveModel {} | ||||||