diff --git a/README.md b/README.md
index 56816f50..8e1a76b3 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,17 @@
+
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