mirror of
https://mirror.suhoan.cn/https://github.com/EasyTier/EasyTier.git
synced 2025-12-12 20:57:26 +08:00
add geo info for in web device list (#1052)
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -2133,6 +2133,8 @@ dependencies = [
|
||||
"easytier",
|
||||
"image 0.24.9",
|
||||
"imageproc",
|
||||
"maxminddb",
|
||||
"once_cell",
|
||||
"password-auth",
|
||||
"rand 0.8.5",
|
||||
"rust-embed",
|
||||
@@ -4340,6 +4342,18 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"log",
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -6582,6 +6596,7 @@ version = "8.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,9 @@ dashmap = "6.1"
|
||||
url = "2.2"
|
||||
async-trait = "0.1"
|
||||
|
||||
maxminddb = "0.24"
|
||||
once_cell = "1.18"
|
||||
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum-login = { version = "0.16" }
|
||||
password-auth = { version = "1.0.0" }
|
||||
@@ -34,7 +37,7 @@ sea-orm-migration = { version = "1.1" }
|
||||
|
||||
|
||||
# for captcha
|
||||
rust-embed = { version = "8.5.0", features = ["debug-embed"] }
|
||||
rust-embed = { version = "8.5.0", features = ["debug-embed", "include-exclude"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
image = { version = "0.24", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -262,6 +262,7 @@ web:
|
||||
last_report: 最后在线
|
||||
version: 版本
|
||||
machine_id: 机器ID
|
||||
unknown_location: 未知位置
|
||||
|
||||
device_management:
|
||||
edit_network: 编辑网络
|
||||
|
||||
@@ -262,6 +262,7 @@ web:
|
||||
last_report: Last Seen
|
||||
version: Version
|
||||
machine_id: Machine ID
|
||||
unknown_location: Unknown Location
|
||||
|
||||
device_management:
|
||||
edit_network: Edit Network
|
||||
|
||||
@@ -53,6 +53,12 @@ export function UuidToStr(uuid: UUID): string {
|
||||
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
region: string | undefined;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
hostname: string;
|
||||
public_ip: string;
|
||||
@@ -61,6 +67,7 @@ export interface DeviceInfo {
|
||||
easytier_version: string;
|
||||
running_network_instances?: Array<string>;
|
||||
machine_id: string;
|
||||
location: Location | undefined;
|
||||
}
|
||||
|
||||
export function buildDeviceInfo(device: any): DeviceInfo {
|
||||
@@ -72,6 +79,7 @@ export function buildDeviceInfo(device: any): DeviceInfo {
|
||||
report_time: device.info?.report_time,
|
||||
easytier_version: device.info?.easytier_version,
|
||||
machine_id: UuidToStr(device.info?.machine_id),
|
||||
location: device.location,
|
||||
};
|
||||
|
||||
return dev_info;
|
||||
|
||||
@@ -42,15 +42,7 @@ const loadDevices = async () => {
|
||||
const resp = await api?.list_machines();
|
||||
let devices: Array<Utils.DeviceInfo> = [];
|
||||
for (const device of (resp || [])) {
|
||||
devices.push({
|
||||
hostname: device.info?.hostname,
|
||||
public_ip: device.client_url,
|
||||
running_network_instances: device.info?.running_network_instances.map((instance: any) => Utils.UuidToStr(instance)),
|
||||
running_network_count: device.info?.running_network_instances.length,
|
||||
report_time: new Date(device.info?.report_time).toLocaleString(),
|
||||
easytier_version: device.info?.easytier_version,
|
||||
machine_id: Utils.UuidToStr(device.info?.machine_id),
|
||||
});
|
||||
devices.push(Utils.buildDeviceInfo(device));
|
||||
}
|
||||
console.debug("device list", deviceList.value);
|
||||
deviceList.value = devices;
|
||||
@@ -666,6 +658,37 @@ const handleResize = () => {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
/* 位置样式 */
|
||||
.location-icon {
|
||||
color: var(--pink-500);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
opacity: 0.9;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.location-separator {
|
||||
opacity: 0.5;
|
||||
font-weight: 300;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.location-text {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
color: var(--pink-400);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
@@ -737,9 +760,26 @@ const handleResize = () => {
|
||||
|
||||
<!-- 下部区域:IP地址和操作按钮 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<!-- IP地址 -->
|
||||
<div class="text-sm truncate card-subtitle max-w-[60%]" :title="device.public_ip">
|
||||
{{ device.public_ip }}
|
||||
<!-- IP地址和位置信息 -->
|
||||
<div class="text-sm truncate card-subtitle max-w-[60%] flex items-center gap-2"
|
||||
:title="device.location ? `${device.location.country}${device.location.region ? ' · ' + device.location.region : ''}${device.location.city ? ' · ' + device.location.city : ''}` : t('web.device.unknown_location')">
|
||||
<i class="pi pi-map-marker location-icon"></i>
|
||||
<span class="location-text">
|
||||
<template v-if="device.location">
|
||||
{{ device.location.country }}
|
||||
<template v-if="device.location.region">
|
||||
<span class="location-separator">·</span>
|
||||
{{ device.location.region }}
|
||||
</template>
|
||||
<template v-if="device.location.city">
|
||||
<span class="location-separator">·</span>
|
||||
{{ device.location.city }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('web.device.unknown_location') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
|
||||
@@ -31,3 +31,6 @@ cli:
|
||||
api_host:
|
||||
en: "The URL of the API server, used by the web frontend to connect to"
|
||||
zh-CN: "API 服务器的 URL,用于 web 前端连接"
|
||||
geoip_db:
|
||||
en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb"
|
||||
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
|
||||
BIN
easytier-web/resources/geoip2-cn.mmdb
Normal file
BIN
easytier-web/resources/geoip2-cn.mmdb
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -8,12 +8,38 @@ use std::sync::{
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier::{proto::web::HeartbeatRequest, tunnel::TunnelListener};
|
||||
use session::Session;
|
||||
use maxminddb::geoip2;
|
||||
use session::{Location, Session};
|
||||
use storage::{Storage, StorageToken};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::db::{Db, UserIdInDb};
|
||||
|
||||
#[derive(rust_embed::Embed)]
|
||||
#[folder = "resources/"]
|
||||
#[include = "geoip2-cn.mmdb"]
|
||||
struct GeoipDb;
|
||||
|
||||
fn load_geoip_db(geoip_db: Option<String>) -> Option<maxminddb::Reader<Vec<u8>>> {
|
||||
if let Some(path) = geoip_db {
|
||||
match maxminddb::Reader::open_readfile(&path) {
|
||||
Ok(reader) => {
|
||||
tracing::info!("Successfully loaded GeoIP2 database from {}", path);
|
||||
return Some(reader);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("Failed to load GeoIP2 database from {}: {}", path, err);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let db = GeoipDb::get("geoip2-cn.mmdb").unwrap();
|
||||
let reader = maxminddb::Reader::from_source(db.data.to_vec()).ok()?;
|
||||
tracing::info!("Successfully loaded GeoIP2 database from embedded file");
|
||||
Some(reader)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClientManager {
|
||||
tasks: JoinSet<()>,
|
||||
@@ -22,10 +48,12 @@ pub struct ClientManager {
|
||||
|
||||
client_sessions: Arc<DashMap<url::Url, Arc<Session>>>,
|
||||
storage: Storage,
|
||||
|
||||
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ClientManager {
|
||||
pub fn new(db: Db) -> Self {
|
||||
pub fn new(db: Db, geoip_db: Option<String>) -> Self {
|
||||
let client_sessions = Arc::new(DashMap::new());
|
||||
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
||||
let mut tasks = JoinSet::new();
|
||||
@@ -42,6 +70,7 @@ impl ClientManager {
|
||||
|
||||
client_sessions,
|
||||
storage: Storage::new(db),
|
||||
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +83,18 @@ impl ClientManager {
|
||||
let sessions = self.client_sessions.clone();
|
||||
let storage = self.storage.weak_ref();
|
||||
let listeners_cnt = self.listeners_cnt.clone();
|
||||
let geoip_db = self.geoip_db.clone();
|
||||
self.tasks.spawn(async move {
|
||||
while let Ok(tunnel) = listener.accept().await {
|
||||
let info = tunnel.info().unwrap();
|
||||
let client_url: url::Url = info.remote_addr.unwrap().into();
|
||||
println!("New session from {:?}", tunnel.info());
|
||||
let mut session = Session::new(storage.clone(), client_url.clone());
|
||||
let location = Self::lookup_location(&client_url, geoip_db.clone());
|
||||
tracing::info!(
|
||||
"New session from {:?}, location: {:?}",
|
||||
client_url,
|
||||
location
|
||||
);
|
||||
let mut session = Session::new(storage.clone(), client_url.clone(), location);
|
||||
session.serve(tunnel).await;
|
||||
sessions.insert(client_url, Arc::new(session));
|
||||
}
|
||||
@@ -112,9 +147,104 @@ impl ClientManager {
|
||||
s.data().read().await.req()
|
||||
}
|
||||
|
||||
pub async fn get_machine_location(&self, client_url: &url::Url) -> Option<Location> {
|
||||
let s = self.client_sessions.get(client_url)?.clone();
|
||||
s.data().read().await.location().cloned()
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &Db {
|
||||
self.storage.db()
|
||||
}
|
||||
|
||||
fn lookup_location(
|
||||
client_url: &url::Url,
|
||||
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
||||
) -> Option<Location> {
|
||||
let host = client_url.host_str()?;
|
||||
let ip: std::net::IpAddr = if let Ok(ip) = host.parse() {
|
||||
ip
|
||||
} else {
|
||||
tracing::debug!("Failed to parse host as IP address: {}", host);
|
||||
return None;
|
||||
};
|
||||
|
||||
// Skip lookup for private/special IPs
|
||||
let is_private = match ip {
|
||||
std::net::IpAddr::V4(ipv4) => {
|
||||
ipv4.is_private() || ipv4.is_loopback() || ipv4.is_unspecified()
|
||||
}
|
||||
std::net::IpAddr::V6(ipv6) => ipv6.is_loopback() || ipv6.is_unspecified(),
|
||||
};
|
||||
|
||||
if is_private {
|
||||
tracing::debug!("Skipping GeoIP lookup for special IP: {}", ip);
|
||||
let location = Location {
|
||||
country: "本地网络".to_string(),
|
||||
city: None,
|
||||
region: None,
|
||||
};
|
||||
return Some(location);
|
||||
}
|
||||
|
||||
let location = if let Some(db) = &*geoip_db {
|
||||
match db.lookup::<geoip2::City>(ip) {
|
||||
Ok(city) => {
|
||||
let country = city
|
||||
.country
|
||||
.and_then(|c| c.names)
|
||||
.and_then(|n| {
|
||||
n.get("zh-CN")
|
||||
.or_else(|| n.get("en"))
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "海外".to_string());
|
||||
|
||||
let city_name = city.city.and_then(|c| c.names).and_then(|n| {
|
||||
n.get("zh-CN")
|
||||
.or_else(|| n.get("en"))
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let region = city.subdivisions.map(|r| {
|
||||
r.iter()
|
||||
.map(|x| x.names.as_ref())
|
||||
.flatten()
|
||||
.map(|x| x.get("zh-CN").or_else(|| x.get("en")))
|
||||
.flatten()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
});
|
||||
|
||||
Location {
|
||||
country,
|
||||
city: city_name,
|
||||
region,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("GeoIP lookup failed for {}: {}", ip, err);
|
||||
Location {
|
||||
country: "海外".to_string(),
|
||||
city: None,
|
||||
region: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"GeoIP database not available, using default location for {}",
|
||||
ip
|
||||
);
|
||||
Location {
|
||||
country: "海外".to_string(),
|
||||
city: None,
|
||||
region: None,
|
||||
}
|
||||
};
|
||||
|
||||
Some(location)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -135,7 +265,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_client() {
|
||||
let listener = UdpTunnelListener::new("udp://0.0.0.0:54333".parse().unwrap());
|
||||
let mut mgr = ClientManager::new(Db::memory_db().await);
|
||||
let mut mgr = ClientManager::new(Db::memory_db().await, None);
|
||||
mgr.add_listener(Box::new(listener)).await.unwrap();
|
||||
|
||||
mgr.db()
|
||||
|
||||
@@ -20,6 +20,13 @@ use crate::db::ListNetworkProps;
|
||||
|
||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Location {
|
||||
pub country: String,
|
||||
pub city: Option<String>,
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SessionData {
|
||||
storage: WeakRefStorage,
|
||||
@@ -28,10 +35,11 @@ pub struct SessionData {
|
||||
storage_token: Option<StorageToken>,
|
||||
notifier: broadcast::Sender<HeartbeatRequest>,
|
||||
req: Option<HeartbeatRequest>,
|
||||
location: Option<Location>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
fn new(storage: WeakRefStorage, client_url: url::Url) -> Self {
|
||||
fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
||||
let (tx, _rx1) = broadcast::channel(2);
|
||||
|
||||
SessionData {
|
||||
@@ -40,6 +48,7 @@ impl SessionData {
|
||||
storage_token: None,
|
||||
notifier: tx,
|
||||
req: None,
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +59,10 @@ impl SessionData {
|
||||
pub fn heartbeat_waiter(&self) -> broadcast::Receiver<HeartbeatRequest> {
|
||||
self.notifier.subscribe()
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Option<&Location> {
|
||||
self.location.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionData {
|
||||
@@ -165,8 +178,8 @@ impl Debug for Session {
|
||||
type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
|
||||
|
||||
impl Session {
|
||||
pub fn new(storage: WeakRefStorage, client_url: url::Url) -> Self {
|
||||
let session_data = SessionData::new(storage, client_url);
|
||||
pub fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
||||
let session_data = SessionData::new(storage, client_url, location);
|
||||
let data = Arc::new(RwLock::new(session_data));
|
||||
|
||||
let rpc_mgr =
|
||||
|
||||
@@ -77,6 +77,12 @@ struct Cli {
|
||||
)]
|
||||
api_server_port: u16,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help = t!("cli.geoip_db").to_string(),
|
||||
)]
|
||||
geoip_db: Option<String>,
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
#[arg(
|
||||
long,
|
||||
@@ -164,7 +170,7 @@ async fn main() {
|
||||
|
||||
// let db = db::Db::new(":memory:").await.unwrap();
|
||||
let db = db::Db::new(cli.db).await.unwrap();
|
||||
let mut mgr = client_manager::ClientManager::new(db.clone());
|
||||
let mut mgr = client_manager::ClientManager::new(db.clone(), cli.geoip_db);
|
||||
let (v6_listener, v4_listener) =
|
||||
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
||||
.await
|
||||
|
||||
@@ -10,7 +10,7 @@ use easytier::proto::common::Void;
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
use easytier::proto::web::*;
|
||||
|
||||
use crate::client_manager::session::Session;
|
||||
use crate::client_manager::session::{Location, Session};
|
||||
use crate::client_manager::ClientManager;
|
||||
use crate::db::{ListNetworkProps, UserIdInDb};
|
||||
|
||||
@@ -66,6 +66,7 @@ struct ListNetworkInstanceIdsJsonResp {
|
||||
struct ListMachineItem {
|
||||
client_url: Option<url::Url>,
|
||||
info: Option<HeartbeatRequest>,
|
||||
location: Option<Location>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -308,9 +309,11 @@ impl NetworkApi {
|
||||
for item in client_urls.iter() {
|
||||
let client_url = item.clone();
|
||||
let session = client_mgr.get_heartbeat_requests(&client_url).await;
|
||||
let location = client_mgr.get_machine_location(&client_url).await;
|
||||
machines.push(ListMachineItem {
|
||||
client_url: Some(client_url),
|
||||
info: session,
|
||||
location,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user