add geo info for in web device list (#1052)

This commit is contained in:
Sijie.Sun
2025-06-25 09:03:47 +08:00
committed by GitHub
parent ae4a158e36
commit ebab70ca3b
12 changed files with 248 additions and 25 deletions

15
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"] }

View File

@@ -262,6 +262,7 @@ web:
last_report: 最后在线
version: 版本
machine_id: 机器ID
unknown_location: 未知位置
device_management:
edit_network: 编辑网络

View File

@@ -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

View File

@@ -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;

View File

@@ -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>
<!-- 操作按钮组 -->

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -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()

View File

@@ -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 =

View File

@@ -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

View File

@@ -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,
});
}