diff --git a/README.md b/README.md index 56816f50..8e1a76b3 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ image image +

Table of Contents

+ - [✨ Rust 版 ServerStatus 云探针](#-rust-版-serverstatus-云探针) - [1. 介绍](#1-介绍) - [🍀 主题](#-主题) - [2. 安装部署](#2-安装部署) - [2.1 快速体验](#21-快速体验) - - [2.2 服务管理脚本部署,感谢 @Colsro 提供](#22-服务管理脚本部署感谢-colsro-提供) - - [2.3 Railway 部署](#23-railway-部署) - - [2.4 前后端分离部署](#24-前后端分离部署) + - [2.2 快速部署](#22-快速部署) + - [2.3 服务管理脚本部署,感谢 @Colsro 提供](#23-服务管理脚本部署感谢-colsro-提供) + - [2.4 Railway 部署](#24-railway-部署) + - [2.5 前后端分离部署](#25-前后端分离部署) - [3. 服务端说明](#3-服务端说明) - [3.1 配置文件 `config.toml`](#31-配置文件-configtoml) - [3.2 服务端运行](#32-服务端运行) @@ -38,15 +41,17 @@ - 支持 `systemd` 开机自启 - 其它功能,如 🗺️ 见 [wiki](https://github.com/zdz/ServerStatus-Rust/wiki) -演示:[tz-rust.vercel.app](https://tz-rust.vercel.app) +演示:[ssr.rs](https://d.ssr.rs) | [vercel.app](https://tz-rust.vercel.app) | 下载:[Releases](https://github.com/zdz/ServerStatus-Rust/releases) | 反馈:[Discussions](https://github.com/zdz/ServerStatus-Rust/discussions) +📕 完整文档迁移至 [doc.ssr.rs](https://doc.ssr.rs) + ### 🍀 主题 -如果你觉得你修改的主题还不错,欢迎分享/PR,前端单独部署方法参见 [#37](https://github.com/zdz/ServerStatus-Rust/discussions/37) +如果你觉得你创造/修改的主题还不错,欢迎分享/PR,前端单独部署方法参见 [#37](https://github.com/zdz/ServerStatus-Rust/discussions/37)
Hotaru 主题 @@ -70,7 +75,11 @@ bash -ex one-touch.sh # 自定义部署可参照 one-touch.sh 脚本 ``` -### 2.2 服务管理脚本部署,感谢 [@Colsro](https://github.com/Colsro) 提供 +### 2.2 快速部署 + +参见 [快速部署](https://doc.ssr.rs/rapid_deploy) + +### 2.3 服务管理脚本部署,感谢 [@Colsro](https://github.com/Colsro) 提供
管理脚本使用说明 @@ -115,14 +124,14 @@ help:
-### 2.3 Railway 部署 +### 2.4 Railway 部署 懒得配置 `Nginx`,`SSL` 证书?试试 [在 Railway 部署 Server 教程](https://github.com/zdz/ServerStatus-Rust/wiki/Railway) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/kzT46l?referralCode=pJYbdU) -### 2.4 前后端分离部署 +### 2.5 前后端分离部署
前后端分离部署 @@ -195,15 +204,26 @@ admin_pass = "" # name 主机唯一标识,不可重复,alias 为展示名 # 使用 ansible 批量部署时可以用主机 hostname 作为 name,统一密码 # notify = false 单独禁止单台机器的告警,一般针对网络差,频繁上下线 -# monthstart = 1 没启用 vnstat 时,表示月流量从每月哪天开始统计 +# monthstart = 1 没启用vnstat时,表示月流量从每月哪天开始统计 # disabled = true 单机禁用,跟删除这条配置的效果一样 hosts = [ - {name = "h1", password = "p1", alias = "n1", location = "🏠", type = "kvm"}, - {name = "h2", password = "p2", alias = "n2", location = "🏢", type = "kvm", notify = true}, - {name = "h3", password = "p3", alias = "n3", location = "🏝️", type = "kvm", monthstart = 1}, - {name = "h4", password = "p4", alias = "n4", location = "🏢", type = "kvm", disabled = false}, + {name = "h1", password = "p1", alias = "n1", location = "🏠", type = "kvm", notify = true}, + {name = "h2", password = "p2", alias = "n2", location = "🏢", type = "kvm", disabled = false}, + {name = "h3", password = "p3", alias = "n3", location = "🏡", type = "kvm", monthstart = 1}, ] +# 动态注册模式,不再需要针对每一个主机做单独配置 +# gid 为模板组id, 动态注册唯一标识,不可重复 +hosts_group = [ + # 可以按国家地区或用途来做分组 + {gid = "g1", password = "pp", location = "🏠", type = "kvm", notify = true}, + {gid = "g2", password = "pp", location = "🏢", type = "kvm", notify = true}, + # 例如不发送通知可以单独做一组 + {gid = "silent", password = "pp", location = "🏡", type = "kvm", notify = false}, +] +# 动态注册模式下,无效数据清理间隔,默认 30s +group_gc = 30 + # 不开启告警,可忽略后面配置,或者删除不需要的通知方式 # 告警间隔默认为30s notify_interval = 30 @@ -277,27 +297,31 @@ docker-compose up -d # rust client 可用参数 ./stat_client -h OPTIONS: - -6, --ipv6 ipv6 only, default:false - -a, --addr [default: http://127.0.0.1:8080/report] - --cm China Mobile probe addr [default: cm.tz.cloudcpp.com:80] - --ct China Telecom probe addr [default: ct.tz.cloudcpp.com:80] - --cu China Unicom probe addr [default: cu.tz.cloudcpp.com:80] - --disable-extra disable extra info report, default:false - --disable-ping disable ping, default:false - --disable-tupd disable t/u/p/d, default:false - -h, --help Print help information - --ip-info show ip info, default:false - --json use json protocol, default:false - -n, --vnstat enable vnstat, default:false - -p, --pass password [default: p1] - -u, --user username [default: h1] - -V, --version Print version information + -6, --ipv6 ipv6 only, default:false + -a, --addr [default: http://127.0.0.1:8080/report] + --alias alias for host [default: unknown] + --cm China Mobile probe addr [default: cm.tz.cloudcpp.com:80] + --ct China Telecom probe addr [default: ct.tz.cloudcpp.com:80] + --cu China Unicom probe addr [default: cu.tz.cloudcpp.com:80] + --disable-extra disable extra info report, default:false + --disable-ping disable ping, default:false + --disable-tupd disable t/u/p/d, default:false + -g, --gid group id [default: ] + -h, --help Print help information + --ip-info show ip info, default:false + --json use json protocol, default:false + -n, --vnstat enable vnstat, default:false + -p, --pass password [default: p1] + -u, --user username [default: h1] + -V, --version Print version information + -w, --weight weight for rank [default: 0] # 一些参数说明 --ip-info # 显示本机ip信息后立即退出,目前使用 ip-api.com 数据 --disable-extra # 不上报系统信息和IP信息 --disable-ping # 停用三网延时和丢包率探测 --disable-tupd # 不上报 tcp/udp/进程数/线程数,减少CPU占用 +-w, --weight # 排序加分,微调让主机靠前显示,无强迫症可忽略 ``` ### 4.2 跨平台版本 (`Window`, `Linux`, `...`) @@ -361,10 +385,6 @@ vnstat --version vnstat -m vnstat --json m -# server config.toml 开启 vnstat -# 从 v1.3.6 不再需要在 server 配置开启,client 自由选择启用与否,client 可部分打开,部分关闭 -vnstat = true - # client 使用 -n 参数开启 vnstat 统计 ./stat_client -a "grpc://127.0.0.1:9394" -u h1 -p p1 -n # 或 @@ -449,8 +469,8 @@ OPTIONS:
关于这个轮子 - 之前一直在使用 `Prometheus` + `Grafana` + `Alertmanager` + `node_exporter` 做VPS监控,这也是业界比较成熟的监控方案,用过一段时间后,发现非生产环境的话,很多监控指标都用不上,反而显得有些重。 - 而 `ServerStatus` 很好,足够简单和轻量,一眼可以看尽大好山河,只是 `c++` 版本很久没迭代过,自己的一些需求在原版上不是很好修改,如自带 `tcp` 上报对跨区机器不是很友好,也不方便对上报的链路优化 等等。过年的时候正值疫情闲来无事,学习 `Rust` 正好需要个小项目练手,于是撸了个 `ServerStatus` 来练手,项目后面会继续维护但不会增加复杂的功能,保持小而美,简单部署,配合 [Uptime Kuma](https://github.com/louislam/uptime-kuma) 基本上可以满足个人大部分监控需求。 + 之前一直在使用 `Prometheus` + `Grafana` + `Alertmanager` + `node_exporter` 做VPS监控,这也是业界比较成熟的监控方案,用过一段时间后,发现非生产环境,很多监控指标都用不上,反而显得有些重。 + 而 `ServerStatus` 很好,足够简单和轻量,一眼可以看尽所有小机机,只是 `c++` 版本很久没迭代过,自己的一些需求在原版上不是很好修改,如自带 `tcp` 上报对跨区机器不是很友好,也不方便对上报的链路做优化 等等。过年的时候正值疫情闲来无事,学习 `Rust` 正好需要个小项目练手,于是撸了个 `ServerStatus` 来练手,项目后面会继续维护但不会增加复杂的功能,保持小而美,简单部署,配合 [Uptime Kuma](https://github.com/louislam/uptime-kuma) 基本上可以满足个人大部分监控需求。
diff --git a/client/Cargo.toml b/client/Cargo.toml index 21e4fff2..22505366 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "stat_client" -version = "1.4.2" +version = "1.5.0" rust-version = "1.60" @@ -35,6 +35,7 @@ sysinfo = "0.23" tokio = {version = "1", features = ["full"]} tonic = {version = "0.7", features = ["tokio-rustls"]} tower = { version = "0.4" } +md5 = "0.7.0" [features] default = ["native"] diff --git a/client/src/grpc.rs b/client/src/grpc.rs index 41893ba0..ebc98abf 100644 --- a/client/src/grpc.rs +++ b/client/src/grpc.rs @@ -33,7 +33,16 @@ pub async fn report(args: &Args, stat_base: &mut StatRequest) -> anyhow::Result< ); } - let token = MetadataValue::try_from(format!("{}@_@{}", args.user, args.pass))?; + let auth_user: String; + let ssr_auth: &[u8]; + if args.gid.is_empty() { + auth_user = args.user.to_string(); + ssr_auth = b"single"; + } else { + auth_user = args.gid.to_string(); + ssr_auth = b"group"; + } + let token = MetadataValue::try_from(format!("{}@_@{}", auth_user, args.pass))?; let channel = Channel::from_shared(args.addr.to_string())? .connect() @@ -43,6 +52,9 @@ pub async fn report(args: &Args, stat_base: &mut StatRequest) -> anyhow::Result< let grpc_client = ServerStatusClient::with_interceptor(timeout_channel, move |mut req: Request<()>| { req.metadata_mut().insert("authorization", token.clone()); + req.metadata_mut() + .insert("ssr-auth", MetadataValue::try_from(ssr_auth).unwrap()); + Ok(req) }); diff --git a/client/src/main.rs b/client/src/main.rs index 288e30b8..e257ff65 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -68,6 +68,15 @@ pub struct Args { json: bool, #[clap(short = '6', long = "ipv6", help = "ipv6 only, default:false")] ipv6: bool, + // #[clap(long = "debug", help = "debug mode, default:false")] + // debug: bool, + // for group + #[clap(short, long, default_value = "", help = "group id")] + gid: String, + #[clap(long = "alias", default_value = "unknown", help = "alias for host")] + alias: String, + #[clap(short, long, default_value = "0", help = "weight for rank")] + weight: u64, } fn sample_all(args: &Args, stat_base: &StatRequest) -> StatRequest { @@ -145,8 +154,16 @@ fn http_report(args: &Args, stat_base: &mut StatRequest) -> Result<()> { let client = http_client.clone(); let url = args.addr.to_string(); - let auth_user = args.user.to_string(); let auth_pass = args.pass.to_string(); + let auth_user: String; + let ssr_auth: &str; + if args.gid.is_empty() { + auth_user = args.user.to_string(); + ssr_auth = "single"; + } else { + auth_user = args.gid.to_string(); + ssr_auth = "group"; + } // http tokio::spawn(async move { @@ -155,6 +172,7 @@ fn http_report(args: &Args, stat_base: &mut StatRequest) -> Result<()> { .basic_auth(auth_user, Some(auth_pass)) .timeout(Duration::from_secs(3)) .header(header::CONTENT_TYPE, content_type) + .header("ssr-auth", ssr_auth) .body(body_data.unwrap()) .send() .await @@ -196,7 +214,7 @@ async fn refresh_ip_info(args: &Args) { #[tokio::main] async fn main() -> Result<()> { pretty_env_logger::init(); - let args = Args::parse(); + let mut args = Args::parse(); dbg!(&args); if args.ip_info { @@ -205,19 +223,21 @@ async fn main() -> Result<()> { process::exit(0); } + // support check + if !System::IS_SUPPORTED { + panic!("当前系统不支持,请切换到Python跨平台版本!"); + } + let sys_info = sys_info::collect_sys_info(&args); let sys_info_json = serde_json::to_string(&sys_info)?; + let sys_id = sys_info::gen_sys_id(&sys_info); + eprintln!("sys id: {}", sys_id); eprintln!("sys info: {}", sys_info_json); if let Ok(mut o) = G_CONFIG.lock() { o.sys_info = Some(sys_info); } - // support check - if !System::IS_SUPPORTED { - panic!("当前系统不支持,请切换到Python跨平台版本!"); - } - // use native #[cfg(all(feature = "native", not(feature = "sysinfo")))] { @@ -250,8 +270,22 @@ async fn main() -> Result<()> { online4: ipv4, online6: ipv6, vnstat: args.vnstat, + weight: args.weight, + version: env!("CARGO_PKG_VERSION").to_string(), ..Default::default() }; + if !args.gid.is_empty() { + stat_base.gid = args.gid.to_owned(); + if stat_base.name.eq("h1") { + stat_base.name = sys_id; + } + if args.alias.eq("unknown") { + args.alias = stat_base.name.to_owned(); + } else { + stat_base.alias = args.alias.to_owned(); + } + } + // dbg!(&stat_base); if args.addr.starts_with("http") { let result = http_report(&args, &mut stat_base); diff --git a/client/src/sys_info.rs b/client/src/sys_info.rs index 5b6b61ec..d81902d3 100644 --- a/client/src/sys_info.rs +++ b/client/src/sys_info.rs @@ -83,6 +83,7 @@ pub fn start_net_speed_collect_t() { }); } +// TODO pub fn sample(args: &Args, stat: &mut StatRequest) { stat.version = env!("CARGO_PKG_VERSION").to_string(); stat.vnstat = args.vnstat; @@ -211,3 +212,19 @@ pub fn collect_sys_info(args: &Args) -> SysInfo { info_pb } + +pub fn gen_sys_id(sys_info: &SysInfo) -> String { + format!( + "{:x}", + md5::compute(format!( + "{}/{}/{}/{}/{}/{}/{}", + sys_info.host_name, + sys_info.os_name, + sys_info.os_arch, + sys_info.os_family, + sys_info.os_release, + sys_info.kernel_version, + sys_info.cpu_brand, + )) + ) +} diff --git a/common/Cargo.toml b/common/Cargo.toml index 8cc0bbdb..87ac3464 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "stat_common" -version = "1.0.0" +version = "1.1.0" authors = ["doge "] categories = ["monitoring-tools"] diff --git a/common/proto/server_status.proto b/common/proto/server_status.proto index 2f40b7cb..731d8243 100644 --- a/common/proto/server_status.proto +++ b/common/proto/server_status.proto @@ -89,6 +89,11 @@ message StatRequest { optional SysInfo sys_info = 37; optional IpInfo ip_info = 38; + + // group + string gid = 39; + string alias = 40; + uint64 weight = 41; } message Response { @@ -96,6 +101,4 @@ message Response { string message = 2; } -service ServerStatus { - rpc Report(StatRequest) returns (Response); -} \ No newline at end of file +service ServerStatus { rpc Report(StatRequest) returns (Response); } \ No newline at end of file diff --git a/config.toml b/config.toml index eb2e4ed4..8ca8b47a 100644 --- a/config.toml +++ b/config.toml @@ -14,11 +14,29 @@ admin_pass = "" # monthstart = 1 没启用vnstat时,表示月流量从每月哪天开始统计 # disabled = true 单机禁用,跟删除这条配置的效果一样 hosts = [ - {name = "h1", password = "p1", alias = "n1", location = "🏠", type = "kvm", notify = true}, + {name = "h1", password = "p1", alias = "n1", location = "🏠", type = "kvm"}, {name = "h2", password = "p2", alias = "n2", location = "🏢", type = "kvm", disabled = false}, - {name = "h3", password = "p3", alias = "n3", location = "🏝️", type = "kvm", monthstart = 1}, + {name = "h3", password = "p3", alias = "n3", location = "🏡", type = "kvm", monthstart = 1}, + {name = "h4", password = "p4", alias = "n4", location = "🏡", type = "kvm", notify = true}, ] +# 动态注册模式,不再需要针对每一个主机做单独配置 +# gid 为模板组id, 自动注册唯一标识,不可重复 +hosts_group = [ + # 可以按国家地区或用途来做分组 + {gid = "g1", password = "pp", location = "🏠", type = "kvm", notify = true}, + {gid = "g2", password = "pp", location = "🏢", type = "kvm", notify = true}, + # 例如不发送通知可以单独做一组 + {gid = "silent", password = "pp", location = "🏡", type = "kvm", notify = false}, +] +# 动态注册模式下,无效数据清理间隔,默认 30s +group_gc = 30 + +# !!! 一键部署如果没问题则不需要动,Server 会自行根据你的域名生成 server_url +# 修正一键部署,请自行替换 ssr.rs 为你的域名, +# server_url = "https://ssr.rs/report" +# stat_client 默认安装的路径 +workspace = "/opt/ServerStatus" # 不开启告警,可忽略后面配置,或者删除不需的通知方式 # 告警间隔默认为30s diff --git a/server/Cargo.toml b/server/Cargo.toml index 3e27bfda..d666ac4c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "stat_server" -version = "1.4.2" +version = "1.5.0" rust-version = "1.60" @@ -43,3 +43,4 @@ tokio = {version = "1", features = ["full"]} toml = "0.5" tonic = {version = "0.7", features = ["tokio-rustls"]} uuid = {version = "1.0", default-features = false, features = ["serde", "v4"]} +url = "2.2.2" \ No newline at end of file diff --git a/server/src/config.rs b/server/src/config.rs index a93d8fd6..5a03d2d9 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -17,16 +17,18 @@ fn default_grpc_addr() -> String { fn default_http_addr() -> String { "0.0.0.0:8080".to_string() } +fn default_workspace() -> String { + "/opt/ServerStatus".to_string() +} -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Host { pub name: String, pub password: String, #[serde(default = "Default::default")] pub alias: String, pub location: String, - #[serde(rename = "type")] - pub host_type: String, + pub r#type: String, #[serde(default = "u32::default")] pub monthstart: u32, #[serde(default = "default_as_true")] @@ -42,6 +44,44 @@ pub struct Host { // user data #[serde(skip_serializing, skip_deserializing)] pub pos: usize, + #[serde(default = "Default::default", skip_serializing)] + pub weight: u64, + #[serde(default = "Default::default")] + pub gid: String, + #[serde(default = "Default::default")] + pub latest_ts: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HostGroup { + pub gid: String, + pub password: String, + pub location: String, + pub r#type: String, + #[serde(default = "default_as_true")] + pub notify: bool, + // user data + #[serde(skip_serializing, skip_deserializing)] + pub pos: usize, + #[serde(default = "Default::default", skip_serializing)] + pub weight: u64, +} + +impl HostGroup { + pub fn inst_host(&self, name: &str) -> Host { + Host { + name: name.to_owned(), + gid: self.gid.to_owned(), + password: self.password.to_owned(), + location: self.location.to_owned(), + r#type: self.r#type.to_owned(), + monthstart: 1, + notify: self.notify, + pos: self.pos, + weight: self.weight, + ..Default::default() + } + } } #[derive(Debug, Deserialize, Serialize)] @@ -54,7 +94,7 @@ pub struct Config { pub notify_interval: u64, #[serde(default = "Default::default")] pub offline_threshold: u64, - // admin user&pass + // admin user & pass pub admin_user: Option, pub admin_pass: Option, @@ -64,10 +104,25 @@ pub struct Config { pub wechat: notifier::wechat::Config, #[serde(default = "Default::default")] pub email: notifier::email::Config, + + #[serde(default = "Default::default")] pub hosts: Vec, + #[serde(default = "Default::default")] + pub hosts_group: Vec, + #[serde(default = "Default::default")] + pub group_gc: u64, + + // deploy + #[serde(default = "Default::default")] + pub server_url: String, + #[serde(default = "default_workspace")] + pub workspace: String, #[serde(skip_deserializing)] pub hosts_map: HashMap, + + #[serde(skip_deserializing)] + pub hosts_group_map: HashMap, } impl Config { @@ -77,6 +132,12 @@ impl Config { } false } + pub fn group_auth(&self, gid: &str, pass: &str) -> bool { + if let Some(o) = self.hosts_group_map.get(gid) { + return pass.eq(o.password.as_str()); + } + false + } pub fn admin_auth(&self, user: &str, pass: &str) -> bool { if let (Some(u), Some(p)) = (self.admin_user.as_ref(), self.admin_pass.as_ref()) { return user.eq(u.as_str()) && pass.eq(p.as_str()); @@ -86,13 +147,9 @@ impl Config { pub fn get_host(&self, name: &str) -> Option<&Host> { self.hosts_map.get(name) } -} - -pub fn test_from_file(cfg: &str) -> Result { - fs::read_to_string(cfg) - .map(|contents| toml::from_str::(&contents)) - .unwrap() - .map_err(anyhow::Error::new) + // pub fn get_host_group(&self, gid: &str) -> Option<&HostGroup> { + // self.hosts_group_map.get(gid) + // } } pub fn from_str(content: &str) -> Option { @@ -107,14 +164,27 @@ pub fn from_str(content: &str) -> Option { if host.monthstart < 1 || host.monthstart > 31 { host.monthstart = 1; } + host.weight = 10000_u64 - idx as u64; o.hosts_map.insert(host.name.to_owned(), host.clone()); } - if o.notify_interval < 30 { - o.notify_interval = 30; + + for (idx, group) in o.hosts_group.iter_mut().enumerate() { + group.pos = idx; + group.weight = (10000 - (1 + idx) * 100) as u64; + o.hosts_group_map + .insert(group.gid.to_owned(), group.clone()); } + if o.offline_threshold < 30 { o.offline_threshold = 30; } + if o.notify_interval < 30 { + o.notify_interval = 30; + } + if o.group_gc < 30 { + o.group_gc = 30; + } + if o.admin_user.is_none() || o.admin_user.as_ref()?.is_empty() { o.admin_user = Some("admin".to_string()); } @@ -141,3 +211,10 @@ pub fn from_file(cfg: &str) -> Option { .map(|contents| from_str(contents.as_str())) .ok()? } + +pub fn test_from_file(cfg: &str) -> Result { + fs::read_to_string(cfg) + .map(|contents| toml::from_str::(&contents)) + .unwrap() + .map_err(anyhow::Error::new) +} diff --git a/server/src/grpc.rs b/server/src/grpc.rs index faf0c967..d32803b8 100644 --- a/server/src/grpc.rs +++ b/server/src/grpc.rs @@ -36,6 +36,13 @@ impl ServerStatus for ServerStatusSrv { } fn check_auth(req: Request<()>) -> Result, Status> { + let mut group_auth = false; + req.metadata().get("ssr-auth").map(|v| { + v.to_str().map(|s| { + group_auth = s.eq("group"); + }) + }); + match req.metadata().get("authorization") { Some(token) => { let tuple = token @@ -45,17 +52,21 @@ fn check_auth(req: Request<()>) -> Result, Status> { .collect::>(); if tuple.len() == 2 { - if let Some(mgr) = G_CONFIG.get() { - if mgr.auth(tuple[0], tuple[1]) { + if let Some(cfg) = G_CONFIG.get() { + if group_auth { + if cfg.group_auth(tuple[0], tuple[1]) { + return Ok(req); + } + } else if cfg.auth(tuple[0], tuple[1]) { return Ok(req); } } } - Err(Status::unauthenticated("invalid user && pass")) + Err(Status::unauthenticated("invalid user/group && pass")) } - _ => Err(Status::unauthenticated("invalid user && pass")), + _ => Err(Status::unauthenticated("invalid user/group && pass")), } } diff --git a/server/src/http.rs b/server/src/http.rs new file mode 100644 index 00000000..36ea7fa2 --- /dev/null +++ b/server/src/http.rs @@ -0,0 +1,327 @@ +// #![allow(unused)] +use http_auth_basic::Credentials; +use hyper::{header, Body, Request, Response, StatusCode}; +use minijinja::context; +use prettytable::Table; +use std::collections::HashMap; + +use crate::jinja; +use crate::Asset; +use crate::G_CONFIG; +use crate::G_STATS_MGR; + +type GenericError = Box; +type Result = std::result::Result; + +static UNAUTHORIZED: &[u8] = b"Unauthorized"; +static INTERNAL_SERVER_ERROR: &[u8] = b"Internal Server Error"; + +// admin auth +fn is_admin(req: &Request) -> bool { + if let Some(auth) = req.headers().get(hyper::header::AUTHORIZATION) { + let auth_header_value = auth.to_str().unwrap().to_string(); + if let Ok(credentials) = Credentials::from_header(auth_header_value) { + if let Some(cfg) = G_CONFIG.get() { + return cfg.admin_auth(&credentials.user_id, &credentials.password); + } + } + } + false +} + +pub async fn init_client(req: Request) -> Result> { + // dbg!(&req); + let params: HashMap = req + .uri() + .query() + .map(|v| { + url::form_urlencoded::parse(v.as_bytes()) + .into_owned() + .collect() + }) + .unwrap_or_else(HashMap::new); + + // query args + let invalid = "".to_string(); + let pass = params.get("pass").unwrap_or(&invalid); + let uid = params.get("uid").unwrap_or(&invalid); + let gid = params.get("gid").unwrap_or(&invalid); + let alias = params.get("alias").unwrap_or(&invalid); + + if pass.is_empty() || (uid.is_empty() && gid.is_empty()) || (uid.is_empty() && alias.is_empty()) + { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(StatusCode::BAD_REQUEST.canonical_reason().unwrap().into())?); + } + let vnstat = params.get("vnstat").map(|p| p.eq("1")).unwrap_or(false); + let mut weight = 0_u64; + if let Some(w) = params.get("weight") { + weight = w.parse::().unwrap_or_default(); + } + + // auth + let mut auth_ok = false; + if let Some(cfg) = G_CONFIG.get() { + if gid.is_empty() { + auth_ok = cfg.auth(uid, pass) + } else { + auth_ok = cfg.group_auth(gid, pass) + } + } + if !auth_ok { + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(UNAUTHORIZED.into())?); + } + + let req_header = req.headers(); + let mut domain = "localhost".to_string(); + let mut scheme = "http".to_string(); + let mut server_url = "".to_string(); + let mut workspace = "".to_string(); + + // load deploy config + if let Some(cfg) = G_CONFIG.get() { + server_url = cfg.server_url.to_string(); + workspace = cfg.workspace.to_string(); + } + // build server url + if server_url.is_empty() { + if let Some(v) = req.uri().scheme() { + scheme = v.to_string(); + debug!("Http Scheme => {}", scheme); + } + req_header.get("x-forwarded-proto").map(|v| { + v.to_str().map(|s| { + debug!("x-forwarded-proto => {}", s); + scheme = s.to_string(); + }) + }); + + req_header.get("Host").map(|v| { + v.to_str().map(|host| { + debug!("Http Host => {}", host); + domain = host.to_string(); + }) + }); + req_header.get("x-forwarded-host").map(|v| { + v.to_str().map(|host| { + debug!("x-forwarded-host => {}", host); + domain = host.to_string(); + }) + }); + server_url = format!("{}://{}/report", scheme, domain); + } + + // build client opts + let mut client_opts = format!(r#"-a "{}" -p "{}""#, server_url, pass); + if vnstat { + client_opts.push_str(" -n"); + } + if weight > 0 { + client_opts.push_str(format!(r#" -w {}"#, weight).as_str()); + } + if !gid.is_empty() { + client_opts.push_str(format!(r#" -g "{}""#, gid).as_str()); + client_opts.push_str(format!(r#" --alias "{}""#, alias).as_str()); + } + if !uid.is_empty() { + client_opts.push_str(format!(r#" -u "{}""#, uid).as_str()); + } + + Ok(jinja::render_template( + "http", + "client-init", + context!( + pass => pass, uid => uid, gid => gid, alias => alias, + vnstat => vnstat, weight => weight, + domain => domain, scheme => scheme, + server_url => server_url, workspace => workspace, + client_opts => client_opts, + pkg_version => env!("CARGO_PKG_VERSION"), + ), + false, + ) + .map(|contents| { + Response::builder() + .header(header::CONTENT_TYPE, "text/x-sh") + .header( + header::CONTENT_DISPOSITION, + r#"attachment; filename="ssr-client-init.sh""#, + ) + .body(Body::from(contents)) + })? + .unwrap_or( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR.into())?, + )) +} + +pub fn init_jinja_tpl() -> Result<()> { + let detail_data = Asset::get("/jinja/detail.jinja.html").expect("detail.jinja.html not found"); + let detail_html: String = String::from_utf8(detail_data.data.try_into()?).unwrap(); + jinja::add_template("http", "detail", detail_html); + + let map_data = Asset::get("/jinja/map.jinja.html").expect("map.jinja.html not found"); + let map_html: String = String::from_utf8(map_data.data.try_into()?).unwrap(); + jinja::add_template("http", "map", map_html); + + let detail_ht_data = + Asset::get("/jinja/detail_ht.jinja.html").expect("detail_ht.jinja.html not found"); + let detail_ht_html: String = String::from_utf8(detail_ht_data.data.try_into()?).unwrap(); + jinja::add_template("http", "detail_ht", detail_ht_html); + + let client_init_sh = + Asset::get("/jinja/client-init.jinja.sh").expect("client-init.jinja.sh not found"); + let client_init_sh_s: String = String::from_utf8(client_init_sh.data.try_into()?).unwrap(); + jinja::add_template("http", "client-init", client_init_sh_s); + Ok(()) +} + +// +pub async fn render_jinja_ht_tpl(tag: &'static str, req: Request) -> Result> { + if !is_admin(&req) { + return Ok(Response::builder() + .header(header::WWW_AUTHENTICATE, "Basic realm=\"Restricted\"") + .status(StatusCode::UNAUTHORIZED) + .body(UNAUTHORIZED.into())?); + } + + // for skip_serializing + let resp = G_STATS_MGR.get().unwrap().get_stats(); + let o = resp.lock().unwrap(); + let mut sys_info_list = Vec::new(); + let mut ip_info_list = Vec::new(); + for stat in &*o.servers { + ip_info_list.push(stat.ip_info.as_ref()); + sys_info_list.push(stat.sys_info.as_ref()); + } + + Ok(jinja::render_template( + "http", + tag, + context!(resp => &*o, ip_info_list => ip_info_list, sys_info_list => sys_info_list), + false, + ) + .map(|contents| { + Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(contents)) + })? + .unwrap_or( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR.into())?, + )) +} + +pub async fn get_detail(req: Request) -> Result> { + if !is_admin(&req) { + return Ok(Response::builder() + .header(header::WWW_AUTHENTICATE, "Basic realm=\"Restricted\"") + .status(StatusCode::UNAUTHORIZED) + .body(UNAUTHORIZED.into())?); + } + + let resp = G_STATS_MGR.get().unwrap().get_stats(); + let o = resp.lock().unwrap(); + + let mut table = Table::new(); + table.set_titles(row![ + "#", + "Id", + "节点名", + "位置", + "在线时间", + "IP", + "系统信息", + "IP信息" + ]); + for (idx, host) in o.servers.iter().enumerate() { + let sys_info = host + .sys_info + .as_ref() + .map(|o| { + let mut s = String::new(); + s.push_str(format!("version: {}\n", o.version).as_str()); + s.push_str(format!("host_name: {}\n", o.host_name).as_str()); + s.push_str(format!("os_name: {}\n", o.os_name).as_str()); + s.push_str(format!("os_arch: {}\n", o.os_arch).as_str()); + s.push_str(format!("os_family: {}\n", o.os_family).as_str()); + s.push_str(format!("os_release: {}\n", o.os_release).as_str()); + s.push_str(format!("kernel_version: {}\n", o.kernel_version).as_str()); + s.push_str(format!("cpu_num: {}\n", o.cpu_num).as_str()); + s.push_str(format!("cpu_brand: {}\n", o.cpu_brand).as_str()); + s.push_str(format!("cpu_vender_id: {}", o.cpu_vender_id).as_str()); + s + }) + .unwrap_or_default(); + if let Some(ip_info) = &host.ip_info { + let addrs = vec![ + ip_info.continent.as_str(), + ip_info.country.as_str(), + ip_info.region_name.as_str(), + ip_info.city.as_str(), + ] + .iter() + .map(|s| s.trim()) + .filter(|&s| !s.is_empty()) + .collect::>() + .join("/"); + + let isp = vec![ + ip_info.isp.as_str(), + ip_info.org.as_str(), + ip_info.r#as.as_str(), + ip_info.asname.as_str(), + ] + .iter() + .map(|s| s.trim()) + .filter(|&s| !s.is_empty()) + .collect::>() + .join("\n"); + + table.add_row(row![ + idx.to_string(), + host.name, + host.alias, + host.location, + host.uptime_str, + ip_info.query, + sys_info, + format!("{}\n{}", addrs, isp) + ]); + } else { + table.add_row(row![ + idx.to_string(), + host.name, + host.alias, + host.location, + host.uptime_str, + "xx.xx.xx.xx".to_string(), + sys_info, + "".to_string() + ]); + } + } + // table.printstd(); + + Ok(jinja::render_template( + "http", + "detail", + context!(pretty_content => table.to_string()), + true, + ) + .map(|contents| { + Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(contents)) + })? + .unwrap_or( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR.into())?, + )) +} diff --git a/server/src/jinja.rs b/server/src/jinja.rs index 07d9ff0d..2eeb6748 100644 --- a/server/src/jinja.rs +++ b/server/src/jinja.rs @@ -23,7 +23,12 @@ where .unwrap(); } -pub fn render_template(kind: &'static str, tag: &'static str, ctx: Value) -> Result { +pub fn render_template( + kind: &'static str, + tag: &'static str, + ctx: Value, + trim: bool, +) -> Result { let name = format!("{}.{}", kind, tag); Ok(JINJA_ENV .lock() @@ -31,12 +36,15 @@ pub fn render_template(kind: &'static str, tag: &'static str, ctx: Value) -> Res e.get_template(name.as_str()).map(|tmpl| { tmpl.render(ctx) .map(|content| { + if trim { + return content + .split('\n') + .map(|t| t.trim()) + .filter(|&t| !t.is_empty()) + .collect::>() + .join("\n"); + } content - .split('\n') - .map(|t| t.trim()) - .filter(|&t| !t.is_empty()) - .collect::>() - .join("\n") }) .unwrap_or_else(|err| { error!("tmpl.render err => {:?}", err); diff --git a/server/src/main.rs b/server/src/main.rs index fea26392..ddee5943 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -8,7 +8,6 @@ extern crate prettytable; use bytes::Buf; use clap::Parser; use http_auth_basic::Credentials; -use minijinja::context; use once_cell::sync::OnceCell; use prost::Message; use rust_embed::RustEmbed; @@ -23,6 +22,7 @@ use tokio::runtime::Handle; mod config; mod grpc; +mod http; mod jinja; mod notifier; mod payload; @@ -35,7 +35,6 @@ type Result = std::result::Result; static NOTFOUND: &[u8] = b"Not Found"; static UNAUTHORIZED: &[u8] = b"Unauthorized"; -static INTERNAL_SERVER_ERROR: &[u8] = b"Internal Server Error"; static G_CONFIG: OnceCell = OnceCell::new(); static G_STATS_MGR: OnceCell = OnceCell::new(); @@ -63,11 +62,20 @@ async fn stats_report(req: Request) -> Result> { let req_header = req.headers(); // auth let mut auth_ok = false; + let mut group_auth = false; + if let Some(ssr_auth) = req_header.get("ssr-auth") { + group_auth = "group".eq(ssr_auth); + } + if let Some(auth) = req_header.get(hyper::header::AUTHORIZATION) { let auth_header_value = auth.to_str()?.to_string(); if let Ok(credentials) = Credentials::from_header(auth_header_value) { if let Some(cfg) = G_CONFIG.get() { - auth_ok = cfg.auth(&credentials.user_id, &credentials.password); + if group_auth { + auth_ok = cfg.group_auth(&credentials.user_id, &credentials.password); + } else { + auth_ok = cfg.auth(&credentials.user_id, &credentials.password); + } } } } @@ -119,189 +127,15 @@ async fn get_stats_json() -> Result> { .body(Body::from(G_STATS_MGR.get().unwrap().get_stats_json()))?) } -// admin auth -fn is_admin(req: &Request) -> bool { - if let Some(auth) = req.headers().get(hyper::header::AUTHORIZATION) { - let auth_header_value = auth.to_str().unwrap().to_string(); - if let Ok(credentials) = Credentials::from_header(auth_header_value) { - if let Some(cfg) = G_CONFIG.get() { - return cfg.admin_auth(&credentials.user_id, &credentials.password); - } - } - } - false -} - -fn init_jinja_tpl() -> Result<()> { - let detail_data = Asset::get("/jinja/detail.jinja.html").expect("detail.jinja.html not found"); - let detail_html: String = String::from_utf8(detail_data.data.try_into()?).unwrap(); - jinja::add_template("main", "detail", detail_html); - - let map_data = Asset::get("/jinja/map.jinja.html").expect("map.jinja.html not found"); - let map_html: String = String::from_utf8(map_data.data.try_into()?).unwrap(); - jinja::add_template("main", "map", map_html); - - let detail_ht_data = - Asset::get("/jinja/detail_ht.jinja.html").expect("detail_ht.jinja.html not found"); - let detail_ht_html: String = String::from_utf8(detail_ht_data.data.try_into()?).unwrap(); - jinja::add_template("main", "detail_ht", detail_ht_html); - - Ok(()) -} - -// -async fn render_jinja_ht_tpl(tag: &'static str, req: Request) -> Result> { - if !is_admin(&req) { - return Ok(Response::builder() - .header(header::WWW_AUTHENTICATE, "Basic realm=\"Restricted\"") - .status(StatusCode::UNAUTHORIZED) - .body(UNAUTHORIZED.into())?); - } - - // skip_serializing - let resp = G_STATS_MGR.get().unwrap().get_stats(); - let o = resp.lock().unwrap(); - let mut sys_info_list = Vec::new(); - let mut ip_info_list = Vec::new(); - for stat in &*o.servers { - ip_info_list.push(stat.ip_info.as_ref()); - sys_info_list.push(stat.sys_info.as_ref()); - } - - Ok(jinja::render_template( - "main", - tag, - context!(resp => &*o, ip_info_list => ip_info_list, sys_info_list => sys_info_list), - ) - .map(|contents| { - Response::builder() - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(contents)) - })? - .unwrap_or( - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(INTERNAL_SERVER_ERROR.into())?, - )) -} - -use prettytable::Table; -async fn get_detail(req: Request) -> Result> { - if !is_admin(&req) { - return Ok(Response::builder() - .header(header::WWW_AUTHENTICATE, "Basic realm=\"Restricted\"") - .status(StatusCode::UNAUTHORIZED) - .body(UNAUTHORIZED.into())?); - } - - let resp = G_STATS_MGR.get().unwrap().get_stats(); - let o = resp.lock().unwrap(); - - let mut table = Table::new(); - table.set_titles(row![ - "#", - "Id", - "节点名", - "位置", - "在线时间", - "IP", - "系统信息", - "IP信息" - ]); - for (idx, host) in o.servers.iter().enumerate() { - let sys_info = host - .sys_info - .as_ref() - .map(|o| { - let mut s = String::new(); - s.push_str(format!("version: {}\n", o.version).as_str()); - s.push_str(format!("host_name: {}\n", o.host_name).as_str()); - s.push_str(format!("os_name: {}\n", o.os_name).as_str()); - s.push_str(format!("os_arch: {}\n", o.os_arch).as_str()); - s.push_str(format!("os_family: {}\n", o.os_family).as_str()); - s.push_str(format!("os_release: {}\n", o.os_release).as_str()); - s.push_str(format!("kernel_version: {}\n", o.kernel_version).as_str()); - s.push_str(format!("cpu_num: {}\n", o.cpu_num).as_str()); - s.push_str(format!("cpu_brand: {}\n", o.cpu_brand).as_str()); - s.push_str(format!("cpu_vender_id: {}", o.cpu_vender_id).as_str()); - s - }) - .unwrap_or_default(); - if let Some(ip_info) = &host.ip_info { - let addrs = vec![ - ip_info.continent.as_str(), - ip_info.country.as_str(), - ip_info.region_name.as_str(), - ip_info.city.as_str(), - ] - .iter() - .map(|s| s.trim()) - .filter(|&s| !s.is_empty()) - .collect::>() - .join("/"); - - let isp = vec![ - ip_info.isp.as_str(), - ip_info.org.as_str(), - ip_info.r#as.as_str(), - ip_info.asname.as_str(), - ] - .iter() - .map(|s| s.trim()) - .filter(|&s| !s.is_empty()) - .collect::>() - .join("\n"); - - table.add_row(row![ - idx.to_string(), - host.name, - host.alias, - host.location, - host.uptime_str, - ip_info.query, - sys_info, - format!("{}\n{}", addrs, isp) - ]); - } else { - table.add_row(row![ - idx.to_string(), - host.name, - host.alias, - host.location, - host.uptime_str, - "xx.xx.xx.xx".to_string(), - sys_info, - "".to_string() - ]); - } - } - // table.printstd(); - - Ok(jinja::render_template( - "main", - "detail", - context!(pretty_content => table.to_string()), - ) - .map(|contents| { - Response::builder() - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(contents)) - })? - .unwrap_or( - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(INTERNAL_SERVER_ERROR.into())?, - )) -} - async fn main_service_func(req: Request) -> Result> { let req_path = req.uri().path(); match (req.method(), req_path) { (&Method::POST, "/report") => stats_report(req).await, (&Method::GET, "/json/stats.json") => get_stats_json().await, - (&Method::GET, "/detail") => get_detail(req).await, - (&Method::GET, "/detail_ht") => render_jinja_ht_tpl("detail_ht", req).await, - (&Method::GET, "/map") => render_jinja_ht_tpl("map", req).await, + (&Method::GET, "/detail") => http::get_detail(req).await, + (&Method::GET, "/detail_ht") => http::render_jinja_ht_tpl("detail_ht", req).await, + (&Method::GET, "/map") => http::render_jinja_ht_tpl("map", req).await, + (&Method::GET, "/i") => http::init_client(req).await, (&Method::GET, "/") | (&Method::GET, "/index.html") => { let body = Body::from(Asset::get("/index.html").unwrap().data); Ok(Response::builder() @@ -345,6 +179,8 @@ async fn main() -> Result<()> { pretty_env_logger::init(); let args = Args::parse(); + eprintln!("✨ {} {}", env!("CARGO_BIN_NAME"), env!("APP_VERSION")); + // config test if args.config_test { config::test_from_file(&args.config).unwrap(); @@ -374,7 +210,7 @@ async fn main() -> Result<()> { } // init tpl - init_jinja_tpl().unwrap(); + http::init_jinja_tpl().unwrap(); // init notifier *notifier::NOTIFIER_HANDLE.lock().unwrap() = Some(Handle::current()); diff --git a/server/src/notifier/email.rs b/server/src/notifier/email.rs index 5eaf08ac..0ccf5f8e 100644 --- a/server/src/notifier/email.rs +++ b/server/src/notifier/email.rs @@ -111,6 +111,7 @@ impl crate::notifier::Notifier for Email { self.kind(), get_tag(e), context!(host => stat, config => self.config), + true, ) .map(|content| match *e { Event::NodeUp | Event::NodeDown => self.send_notify(content).unwrap(), diff --git a/server/src/notifier/tgbot.rs b/server/src/notifier/tgbot.rs index 54939f7f..ea618990 100644 --- a/server/src/notifier/tgbot.rs +++ b/server/src/notifier/tgbot.rs @@ -96,6 +96,7 @@ impl crate::notifier::Notifier for TGBot { self.kind(), get_tag(e), context!(host => stat, config => self.config), + true, ) .map(|content| match *e { Event::NodeUp | Event::NodeDown => self.send_notify(content).unwrap(), diff --git a/server/src/notifier/wechat.rs b/server/src/notifier/wechat.rs index 6a2ac6f0..744788fa 100644 --- a/server/src/notifier/wechat.rs +++ b/server/src/notifier/wechat.rs @@ -133,6 +133,7 @@ impl crate::notifier::Notifier for WeChat { self.kind(), get_tag(e), context!(host => stat, config => self.config), + true, ) .map(|content| match *e { Event::NodeUp | Event::NodeDown => self.send_notify(content).unwrap(), diff --git a/server/src/payload.rs b/server/src/payload.rs index e39b624f..a4774336 100644 --- a/server/src/payload.rs +++ b/server/src/payload.rs @@ -10,7 +10,7 @@ fn default_as_true() -> bool { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HostStat { pub name: String, - #[serde(default = "Default::default", skip_deserializing)] + #[serde(default = "Default::default")] pub alias: String, #[serde(rename = "type", skip_deserializing)] pub host_type: String, @@ -75,6 +75,12 @@ pub struct HostStat { #[serde(skip_serializing)] pub sys_info: Option, + // group + #[serde(default = "Default::default")] + pub gid: String, + #[serde(default = "Default::default")] + pub weight: u64, + // user data #[serde(skip_deserializing)] pub latest_ts: u64, diff --git a/server/src/stats.rs b/server/src/stats.rs index 53b9c737..89c524a4 100644 --- a/server/src/stats.rs +++ b/server/src/stats.rs @@ -18,6 +18,7 @@ use std::thread; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::config::Host; use crate::notifier::{Event, Notifier}; use crate::payload::{HostStat, StatsResp}; @@ -38,77 +39,130 @@ impl StatsMgr { } } + fn load_last_network(&mut self, hosts_map: &mut HashMap) { + let contents = fs::read_to_string("stats.json").unwrap_or_default(); + if contents.is_empty() { + return; + } + + if let Ok(stats_json) = serde_json::from_str::(contents.as_str()) { + if let Some(servers) = stats_json["servers"].as_array() { + for v in servers { + if let (Some(name), Some(last_network_in), Some(last_network_out)) = ( + v["name"].as_str(), + v["last_network_in"].as_u64(), + v["last_network_out"].as_u64(), + ) { + if let Some(srv) = hosts_map.get_mut(name) { + srv.last_network_in = last_network_in; + srv.last_network_out = last_network_out; + + trace!( + "{} => last in/out ({}/{}))", + &name, + last_network_in, + last_network_out + ); + } + } else { + error!("invalid json => {:?}", v); + } + } + trace!("load stats.json succ!"); + } + } else { + warn!("ignore invalid stats.json"); + } + } + pub fn init( &mut self, cfg: &'static crate::config::Config, notifies: Arc>>>, ) -> Result<()> { - let mut hosts_map = cfg.hosts_map.clone(); + let hosts_map_base = Arc::new(Mutex::new(cfg.hosts_map.clone())); // load last_network_in/out - if let Ok(contents) = fs::read_to_string("stats.json") { - if let Ok(stats_json) = serde_json::from_str::(contents.as_str()) { - if let Some(servers) = stats_json["servers"].as_array() { - for v in servers { - if let (Some(name), Some(last_network_in), Some(last_network_out)) = ( - v["name"].as_str(), - v["last_network_in"].as_u64(), - v["last_network_out"].as_u64(), - ) { - if let Some(srv) = hosts_map.get_mut(name) { - srv.last_network_in = last_network_in; - srv.last_network_out = last_network_out; - - trace!( - "{} => last in/out ({}/{}))", - &name, - last_network_in, - last_network_out - ); - } - } else { - error!("invalid json => {:?}", v); - } - } - trace!("load stats.json succ!"); - } - } else { - warn!("ignore invalid stats.json"); - } + if let Ok(mut hosts_map) = hosts_map_base.lock() { + self.load_last_network(&mut *hosts_map); } let (stat_tx, stat_rx) = sync_channel(512); STAT_SENDER.set(stat_tx).unwrap(); let (notifier_tx, notifier_rx) = sync_channel(512); - let stat_dict: Arc>>> = + let stat_map: Arc>>> = Arc::new(Mutex::new(HashMap::new())); // stat_rx thread - let stat_dict_1 = stat_dict.clone(); + let hosts_group_map = cfg.hosts_group_map.clone(); + let hosts_map_1 = hosts_map_base.clone(); + let stat_map_1 = stat_map.clone(); let notifier_tx_1 = notifier_tx.clone(); thread::spawn(move || loop { while let Ok(stat) = stat_rx.recv() { trace!("recv stat `{:?}", stat); - if let Some(info) = hosts_map.get_mut(&stat.name) { + + let mut stat_c = stat; + let mut stat_t = stat_c.to_mut(); + + // group mode + if !stat_t.gid.is_empty() { + if stat_t.alias.is_empty() { + stat_t.alias = stat_t.name.to_string(); + } + + if let Ok(mut hosts_map) = hosts_map_1.lock() { + let host = hosts_map.get(&stat_t.name); + if host.is_none() || !host.unwrap().gid.eq(&stat_t.gid) { + if let Some(group) = hosts_group_map.get(&stat_t.gid) { + // 名称不变,换组了,更新组配置 & last in/out + let mut inst = group.inst_host(&stat_t.name); + if let Some(o) = host { + inst.last_network_in = o.last_network_in; + inst.last_network_out = o.last_network_out; + }; + hosts_map.insert(stat_t.name.to_string(), inst); + } else { + continue; + } + } + } + } + + // + if let Ok(mut hosts_map) = hosts_map_1.lock() { + let host_info = hosts_map.get_mut(&stat_t.name); + if host_info.is_none() { + error!("invalid stat `{:?}", stat_t); + continue; + } + let info = host_info.unwrap(); + if info.disabled { continue; } - let local_now = Local::now(); // 补齐 - let mut stat_c = stat; - let mut stat_t = stat_c.to_mut(); stat_t.location = info.location.to_string(); - stat_t.host_type = info.host_type.to_owned(); + stat_t.host_type = info.r#type.to_owned(); stat_t.pos = info.pos; - stat_t.alias = info.alias.to_owned(); stat_t.disabled = info.disabled; - stat_t.latest_ts = SystemTime::now() + stat_t.weight += info.weight; + + // !group + if !info.alias.is_empty() { + stat_t.alias = info.alias.to_owned(); + } + + info.latest_ts = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); + stat_t.latest_ts = info.latest_ts; + // last_network_in/out + let local_now = Local::now(); if !stat_t.vnstat { if info.last_network_in == 0 || (stat_t.network_in != 0 && info.last_network_in > stat_t.network_in) @@ -138,8 +192,8 @@ impl StatsMgr { } info!("update stat `{:?}", stat_t); - if let Ok(mut host_stat_map) = stat_dict_1.lock() { - if let Some(pre_stat) = host_stat_map.get(&info.name) { + if let Ok(mut host_stat_map) = stat_map_1.lock() { + if let Some(pre_stat) = host_stat_map.get(&stat_t.name) { if stat_t.ip_info.is_none() { stat_t.ip_info = pre_stat.ip_info.to_owned(); } @@ -151,11 +205,9 @@ impl StatsMgr { notifier_tx_1.send((Event::NodeUp, stat_c.to_owned())); } } - host_stat_map.insert(info.name.to_string(), stat_c); + host_stat_map.insert(stat_c.name.to_string(), stat_c); //trace!("{:?}", host_stat_map); } - } else { - error!("invalid stat `{:?}", stat); } } }); @@ -163,16 +215,33 @@ impl StatsMgr { // timer thread let resp_json = self.resp_json.clone(); let stats_data = self.stats_data.clone(); - let stat_dict_2 = stat_dict.clone(); + let hosts_map_2 = hosts_map_base.clone(); + let stat_map_2 = stat_map.clone(); let notifier_tx_2 = notifier_tx.clone(); - let mut latest_notify_ts: u64 = 0; - let mut latest_save_ts: u64 = 0; + let mut latest_notify_ts = 0_u64; + let mut latest_save_ts = 0_u64; + let mut latest_group_gc = 0_u64; thread::spawn(move || loop { thread::sleep(Duration::from_millis(500)); let mut resp = StatsResp::new(); + let now = resp.updated; let mut notified = false; - if let Ok(mut host_stat_map) = stat_dict_2.lock() { + + // gc for group + if latest_group_gc + cfg.group_gc < now { + latest_group_gc = now; + // + if let Ok(mut hosts_map) = hosts_map_2.lock() { + hosts_map.retain(|_, o| o.gid.is_empty() || o.latest_ts + cfg.group_gc >= now); + } + // + if let Ok(mut stat_map) = stat_map_2.lock() { + stat_map.retain(|_, o| o.gid.is_empty() || o.latest_ts + cfg.group_gc >= now); + } + } + + if let Ok(mut host_stat_map) = stat_map_2.lock() { for (_, stat) in host_stat_map.iter_mut() { if stat.disabled { resp.servers.push(stat.to_owned().into_owned()); @@ -181,15 +250,16 @@ impl StatsMgr { let stat_c = stat.borrow_mut(); let o = stat_c.to_mut(); // 30s 下线 - if o.latest_ts + cfg.offline_threshold < resp.updated { + if o.latest_ts + cfg.offline_threshold < now { o.online4 = false; o.online6 = false; } + // client notify if let Some(info) = cfg.get_host(o.name.as_str()) { if info.notify { // notify check /30 s - if latest_notify_ts + cfg.notify_interval < resp.updated { + if latest_notify_ts + cfg.notify_interval < now { if o.online4 || o.online6 { notifier_tx_2.send((Event::Custom, stat_c.to_owned())); } else { @@ -204,15 +274,24 @@ impl StatsMgr { resp.servers.push(stat_c.to_owned().into_owned()); } if notified { - latest_notify_ts = resp.updated; + latest_notify_ts = now; } } - resp.servers.sort_by(|a, b| a.pos.cmp(&b.pos)); + resp.servers.sort_by(|a, b| { + if a.weight != b.weight { + return a.weight.cmp(&b.weight).reverse(); + } + if a.pos != b.pos { + return a.pos.cmp(&b.pos); + } + // same group + a.alias.cmp(&b.alias) + }); // last_network_in/out save /60s - if latest_save_ts + SAVE_INTERVAL < resp.updated { - latest_save_ts = resp.updated; + if latest_save_ts + SAVE_INTERVAL < now { + latest_save_ts = now; if !resp.servers.is_empty() { if let Ok(mut file) = File::create("stats.json") { file.write(serde_json::to_string(&resp).unwrap().as_bytes()); diff --git a/web/jinja/client-init.jinja.sh b/web/jinja/client-init.jinja.sh new file mode 100644 index 00000000..1669bb96 --- /dev/null +++ b/web/jinja/client-init.jinja.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# ServerStatus-Rust client init script + +export SSR_PASS={{pass}} +export SSR_UID={{uid}} +export SSR_GID={{gid}} +export SSR_ALIAS={{alias}} +export SSR_SCHEME={{scheme}} +export SSR_DOMAIN={{domain}} +export SSR_SRVEL_URL={{server_url}} +export SSR_VNSTAT={{vnstat}} +export SSR_WEIGHT={{weight}} +export SSR_PKG_VERSION={{pkg_version}} +export SSR_CLIENT_OPTS='{{client_opts}}' +export SSR_WORKSPACE={{workspace}} + +Info="\033[32m[info]\033[0m" +Error="\033[31m[err]\033[0m" + +mkdir -p ${SSR_WORKSPACE} +cd ${SSR_WORKSPACE} + +if [ "${DBG}" = "1" ]; then + set -x +fi + +function say() { + printf "${Info} ssr-client-init: %s\n" "$1" +} + +function err() { + printf "${Error} ssr-client-init: %s\n" "$1" >&2 + exit 1 +} + +function check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +function need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +# check arch +function check_arch() { + need_cmd uname + + case $(uname -m) in + x86_64) + arch=x86_64 + ;; + aarch64 | aarch64_be | arm64 | armv8b | armv8l) + arch=aarch64 + ;; + *) + err "暂不支持该系统架构" + exit 1 + ;; + esac + + say "os arch: ${arch}" +} + +function download_client() { + need_cmd rm + need_cmd unzip + need_cmd wget + need_cmd chmod + + if [ "${CN}" = true ]; then + MIRROR="https://gh-proxy.com/" + say "using mirror: ${MIRROR}" + fi + + cd ${SSR_WORKSPACE} + rm -rf client-*.zip stat_* | true + + say "start downloading the stat_client" + wget --no-check-certificate -qO "client-${arch}-unknown-linux-musl.zip" "${MIRROR}https://github.com/zdz/ServerStatus-Rust/releases/download/v{{pkg_version}}/client-${arch}-unknown-linux-musl.zip" + + say "download stat_client succ" + + say "try stop stat_client.service" + systemctl stop stat_client > /dev/null | true + + say "unzip client-${arch}-unknown-linux-musl.zip" + unzip -o client-${arch}-unknown-linux-musl.zip + rm -rf stat_client.service | true + + chmod +x ${SSR_WORKSPACE}/stat_client +} + +function install_client_service() { + need_cmd cat + need_cmd systemctl + need_cmd sleep + + say "start install stat_client.service" + + cat > /etc/systemd/system/stat_client.service <<-EOF +[Unit] +Description=ServerStatus-Rust Client +After=network.target + +[Service] +User=root +Group=root +Environment="RUST_BACKTRACE=1" +WorkingDirectory={{workspace}} +ExecStart={{workspace}}/stat_client {{client_opts}} +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure + +[Install] +WantedBy=multi-user.target + +EOF + + say "systemctl daemon-reload" + systemctl daemon-reload + say "start stat_client.service" + systemctl start stat_client + say "enable stat_client.service" + systemctl enable stat_client + + sleep 2 + say "status stat_client.service" + systemctl status stat_client + +} + +check_arch +download_client +install_client_service