mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 02:09:06 +00:00
feat(web): add webhook-managed machine access and multi-instance CLI support (#1989)
* feat: add webhook-managed access and multi-instance CLI support * fix(foreign): verify credential of foreign credential peer
This commit is contained in:
@@ -215,7 +215,7 @@ pub unsafe extern "C" fn collect_network_infos(
|
|||||||
if index >= max_length {
|
if index >= max_length {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(key) = INSTANCE_MANAGER.get_network_instance_name(instance_id) else {
|
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// convert value to json string
|
// convert value to json string
|
||||||
@@ -228,7 +228,7 @@ pub unsafe extern "C" fn collect_network_infos(
|
|||||||
};
|
};
|
||||||
|
|
||||||
infos[index] = KeyValuePair {
|
infos[index] = KeyValuePair {
|
||||||
key: std::ffi::CString::new(key.clone()).unwrap().into_raw(),
|
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||||
};
|
};
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export enum NetworkingMethod {
|
|||||||
Standalone = 2,
|
Standalone = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecureModeConfig {
|
||||||
|
enabled: boolean
|
||||||
|
// Keep protocol compatibility with backend/import-export flows even though the GUI
|
||||||
|
// does not render secure-mode or credential inputs.
|
||||||
|
local_private_key?: string
|
||||||
|
local_public_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkConfig {
|
export interface NetworkConfig {
|
||||||
instance_id: string
|
instance_id: string
|
||||||
|
|
||||||
@@ -14,7 +22,9 @@ export interface NetworkConfig {
|
|||||||
network_length: number
|
network_length: number
|
||||||
hostname?: string
|
hostname?: string
|
||||||
network_name: string
|
network_name: string
|
||||||
network_secret: string
|
network_secret?: string
|
||||||
|
credential_file?: string
|
||||||
|
secure_mode?: SecureModeConfig
|
||||||
|
|
||||||
networking_method: NetworkingMethod
|
networking_method: NetworkingMethod
|
||||||
|
|
||||||
@@ -83,6 +93,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
|||||||
network_length: 24,
|
network_length: 24,
|
||||||
network_name: 'easytier',
|
network_name: 'easytier',
|
||||||
network_secret: '',
|
network_secret: '',
|
||||||
|
credential_file: '',
|
||||||
|
|
||||||
networking_method: NetworkingMethod.PublicServer,
|
networking_method: NetworkingMethod.PublicServer,
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use maxminddb::geoip2;
|
|||||||
use session::{Location, Session};
|
use session::{Location, Session};
|
||||||
use storage::{Storage, StorageToken};
|
use storage::{Storage, StorageToken};
|
||||||
|
|
||||||
|
use crate::webhook::SharedWebhookConfig;
|
||||||
use crate::FeatureFlags;
|
use crate::FeatureFlags;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
@@ -59,12 +60,18 @@ pub struct ClientManager {
|
|||||||
storage: Storage,
|
storage: Storage,
|
||||||
|
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
|
|
||||||
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientManager {
|
impl ClientManager {
|
||||||
pub fn new(db: Db, geoip_db: Option<String>, feature_flags: Arc<FeatureFlags>) -> Self {
|
pub fn new(
|
||||||
|
db: Db,
|
||||||
|
geoip_db: Option<String>,
|
||||||
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
|
) -> Self {
|
||||||
let client_sessions = Arc::new(DashMap::new());
|
let client_sessions = Arc::new(DashMap::new());
|
||||||
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
@@ -82,6 +89,7 @@ impl ClientManager {
|
|||||||
client_sessions,
|
client_sessions,
|
||||||
storage: Storage::new(db),
|
storage: Storage::new(db),
|
||||||
feature_flags,
|
feature_flags,
|
||||||
|
webhook_config,
|
||||||
|
|
||||||
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,7 @@ impl ClientManager {
|
|||||||
let listeners_cnt = self.listeners_cnt.clone();
|
let listeners_cnt = self.listeners_cnt.clone();
|
||||||
let geoip_db = self.geoip_db.clone();
|
let geoip_db = self.geoip_db.clone();
|
||||||
let feature_flags = self.feature_flags.clone();
|
let feature_flags = self.feature_flags.clone();
|
||||||
|
let webhook_config = self.webhook_config.clone();
|
||||||
self.tasks.spawn(async move {
|
self.tasks.spawn(async move {
|
||||||
while let Ok(tunnel) = listener.accept().await {
|
while let Ok(tunnel) = listener.accept().await {
|
||||||
let (tunnel, secure) = match security::accept_or_upgrade_server_tunnel(tunnel).await {
|
let (tunnel, secure) = match security::accept_or_upgrade_server_tunnel(tunnel).await {
|
||||||
@@ -121,6 +130,7 @@ impl ClientManager {
|
|||||||
client_url.clone(),
|
client_url.clone(),
|
||||||
location,
|
location,
|
||||||
feature_flags.clone(),
|
feature_flags.clone(),
|
||||||
|
webhook_config.clone(),
|
||||||
);
|
);
|
||||||
session.serve(tunnel).await;
|
session.serve(tunnel).await;
|
||||||
sessions.insert(client_url, Arc::new(session));
|
sessions.insert(client_url, Arc::new(session));
|
||||||
@@ -165,6 +175,36 @@ impl ClientManager {
|
|||||||
.map(|item| item.value().clone())
|
.map(|item| item.value().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a session by machine_id regardless of user_id.
|
||||||
|
pub fn get_session_by_machine_id_global(
|
||||||
|
&self,
|
||||||
|
machine_id: &uuid::Uuid,
|
||||||
|
) -> Option<Arc<Session>> {
|
||||||
|
self.storage
|
||||||
|
.get_client_url_by_machine_id_global(machine_id)
|
||||||
|
.and_then(|url| {
|
||||||
|
self.client_sessions
|
||||||
|
.get(&url)
|
||||||
|
.map(|item| item.value().clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user_id associated with a machine_id.
|
||||||
|
pub fn get_user_id_by_machine_id_global(&self, machine_id: &uuid::Uuid) -> Option<UserIdInDb> {
|
||||||
|
self.storage.get_user_id_by_machine_id_global(machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disconnect_session_by_machine_id_global(&self, machine_id: &uuid::Uuid) -> bool {
|
||||||
|
let Some(client_url) = self.storage.get_client_url_by_machine_id_global(machine_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((_, session)) = self.client_sessions.remove(&client_url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
session.stop().await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_machine_by_user_id(&self, user_id: UserIdInDb) -> Vec<url::Url> {
|
pub async fn list_machine_by_user_id(&self, user_id: UserIdInDb) -> Vec<url::Url> {
|
||||||
self.storage.list_user_clients(user_id)
|
self.storage.list_user_clients(user_id)
|
||||||
}
|
}
|
||||||
@@ -321,6 +361,9 @@ mod tests {
|
|||||||
Db::memory_db().await,
|
Db::memory_db().await,
|
||||||
None,
|
None,
|
||||||
Arc::new(FeatureFlags::default()),
|
Arc::new(FeatureFlags::default()),
|
||||||
|
Arc::new(crate::webhook::WebhookConfig::new(
|
||||||
|
None, None, None, None, None,
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
mgr.add_listener(Box::new(listener)).await.unwrap();
|
mgr.add_listener(Box::new(listener)).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use easytier::{
|
|||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
|
||||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||||
|
use crate::webhook::SharedWebhookConfig;
|
||||||
use crate::FeatureFlags;
|
use crate::FeatureFlags;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -31,9 +32,11 @@ pub struct Location {
|
|||||||
pub struct SessionData {
|
pub struct SessionData {
|
||||||
storage: WeakRefStorage,
|
storage: WeakRefStorage,
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
client_url: url::Url,
|
client_url: url::Url,
|
||||||
|
|
||||||
storage_token: Option<StorageToken>,
|
storage_token: Option<StorageToken>,
|
||||||
|
binding_version: Option<u64>,
|
||||||
notifier: broadcast::Sender<HeartbeatRequest>,
|
notifier: broadcast::Sender<HeartbeatRequest>,
|
||||||
req: Option<HeartbeatRequest>,
|
req: Option<HeartbeatRequest>,
|
||||||
location: Option<Location>,
|
location: Option<Location>,
|
||||||
@@ -45,14 +48,17 @@ impl SessionData {
|
|||||||
client_url: url::Url,
|
client_url: url::Url,
|
||||||
location: Option<Location>,
|
location: Option<Location>,
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (tx, _rx1) = broadcast::channel(2);
|
let (tx, _rx1) = broadcast::channel(2);
|
||||||
|
|
||||||
SessionData {
|
SessionData {
|
||||||
storage,
|
storage,
|
||||||
feature_flags,
|
feature_flags,
|
||||||
|
webhook_config,
|
||||||
client_url,
|
client_url,
|
||||||
storage_token: None,
|
storage_token: None,
|
||||||
|
binding_version: None,
|
||||||
notifier: tx,
|
notifier: tx,
|
||||||
req: None,
|
req: None,
|
||||||
location,
|
location,
|
||||||
@@ -77,6 +83,23 @@ impl Drop for SessionData {
|
|||||||
if let Ok(storage) = Storage::try_from(self.storage.clone()) {
|
if let Ok(storage) = Storage::try_from(self.storage.clone()) {
|
||||||
if let Some(token) = self.storage_token.as_ref() {
|
if let Some(token) = self.storage_token.as_ref() {
|
||||||
storage.remove_client(token);
|
storage.remove_client(token);
|
||||||
|
|
||||||
|
// Notify the webhook receiver when a node disconnects.
|
||||||
|
if self.webhook_config.is_enabled() {
|
||||||
|
let webhook = self.webhook_config.clone();
|
||||||
|
let machine_id = token.machine_id.to_string();
|
||||||
|
let web_instance_id = webhook.web_instance_id.clone();
|
||||||
|
let binding_version = self.binding_version;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
webhook
|
||||||
|
.notify_node_disconnected(&crate::webhook::NodeDisconnectedRequest {
|
||||||
|
machine_id,
|
||||||
|
web_instance_id,
|
||||||
|
binding_version,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +113,58 @@ struct SessionRpcService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRpcService {
|
impl SessionRpcService {
|
||||||
|
async fn persist_webhook_network_config(
|
||||||
|
storage: &Storage,
|
||||||
|
user_id: i32,
|
||||||
|
machine_id: uuid::Uuid,
|
||||||
|
network_config: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut network_config = network_config;
|
||||||
|
let network_name = network_config
|
||||||
|
.get("network_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("webhook response missing network_name"))?
|
||||||
|
.to_string();
|
||||||
|
let existing_configs = storage
|
||||||
|
.db()
|
||||||
|
.list_network_configs((user_id, machine_id), ListNetworkProps::All)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to list existing network configs: {:?}", e))?;
|
||||||
|
let inst_id = existing_configs
|
||||||
|
.iter()
|
||||||
|
.find_map(|cfg| {
|
||||||
|
let value = serde_json::from_str::<serde_json::Value>(&cfg.network_config).ok()?;
|
||||||
|
let cfg_network_name = value.get("network_name")?.as_str()?;
|
||||||
|
if cfg_network_name == network_name {
|
||||||
|
uuid::Uuid::parse_str(&cfg.network_instance_id).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(uuid::Uuid::new_v4);
|
||||||
|
|
||||||
|
let config_obj = network_config
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("webhook network_config must be a JSON object"))?;
|
||||||
|
config_obj.insert(
|
||||||
|
"instance_id".to_string(),
|
||||||
|
serde_json::Value::String(inst_id.to_string()),
|
||||||
|
);
|
||||||
|
config_obj
|
||||||
|
.entry("instance_name".to_string())
|
||||||
|
.or_insert_with(|| serde_json::Value::String(network_name.clone()));
|
||||||
|
|
||||||
|
let config = serde_json::from_value::<NetworkConfig>(network_config)?;
|
||||||
|
storage
|
||||||
|
.db()
|
||||||
|
.insert_or_update_user_network_config((user_id, machine_id), inst_id, config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to persist webhook network config: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_heartbeat(
|
async fn handle_heartbeat(
|
||||||
&self,
|
&self,
|
||||||
req: HeartbeatRequest,
|
req: HeartbeatRequest,
|
||||||
@@ -106,6 +181,54 @@ impl SessionRpcService {
|
|||||||
req.machine_id
|
req.machine_id
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
|
let (user_id, webhook_network_config, webhook_validated, binding_version) = if data
|
||||||
|
.webhook_config
|
||||||
|
.is_enabled()
|
||||||
|
{
|
||||||
|
let webhook_req = crate::webhook::ValidateTokenRequest {
|
||||||
|
token: req.user_token.clone(),
|
||||||
|
machine_id: machine_id.to_string(),
|
||||||
|
hostname: req.hostname.clone(),
|
||||||
|
version: req.easytier_version.clone(),
|
||||||
|
web_instance_id: data.webhook_config.web_instance_id.clone(),
|
||||||
|
web_instance_api_base_url: data.webhook_config.web_instance_api_base_url.clone(),
|
||||||
|
};
|
||||||
|
let resp = data
|
||||||
|
.webhook_config
|
||||||
|
.validate_token(&webhook_req)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Webhook token validation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if resp.valid {
|
||||||
|
let user_id = match storage
|
||||||
|
.db()
|
||||||
|
.get_user_id_by_token(req.user_token.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
|
||||||
|
{
|
||||||
|
Some(id) => id,
|
||||||
|
None => storage
|
||||||
|
.auto_create_user(&req.user_token)
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Failed to auto-create webhook user: {:?}", req.user_token)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
resp.network_config,
|
||||||
|
true,
|
||||||
|
Some(resp.binding_version),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Webhook rejected token for machine {:?}: {:?}",
|
||||||
|
machine_id,
|
||||||
|
req.user_token
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let user_id = match storage
|
let user_id = match storage
|
||||||
.db()
|
.db()
|
||||||
.get_user_id_by_token(req.user_token.clone())
|
.get_user_id_by_token(req.user_token.clone())
|
||||||
@@ -127,6 +250,22 @@ impl SessionRpcService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
(user_id, None, false, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
if webhook_validated {
|
||||||
|
if let Some(network_config) = webhook_network_config {
|
||||||
|
Self::persist_webhook_network_config(&storage, user_id, machine_id, network_config)
|
||||||
|
.await
|
||||||
|
.map_err(rpc_types::error::Error::from)?;
|
||||||
|
}
|
||||||
|
} else if webhook_network_config.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unexpected webhook network_config for non-webhook token {:?}",
|
||||||
|
req.user_token
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
if data.req.replace(req.clone()).is_none() {
|
if data.req.replace(req.clone()).is_none() {
|
||||||
assert!(data.storage_token.is_none());
|
assert!(data.storage_token.is_none());
|
||||||
@@ -136,6 +275,23 @@ impl SessionRpcService {
|
|||||||
machine_id,
|
machine_id,
|
||||||
user_id,
|
user_id,
|
||||||
});
|
});
|
||||||
|
data.binding_version = binding_version;
|
||||||
|
|
||||||
|
// Notify the webhook receiver on the first successful heartbeat.
|
||||||
|
if data.webhook_config.is_enabled() {
|
||||||
|
let webhook = data.webhook_config.clone();
|
||||||
|
let connect_req = crate::webhook::NodeConnectedRequest {
|
||||||
|
machine_id: machine_id.to_string(),
|
||||||
|
token: req.user_token.clone(),
|
||||||
|
hostname: req.hostname.clone(),
|
||||||
|
version: req.easytier_version.clone(),
|
||||||
|
web_instance_id: webhook.web_instance_id.clone(),
|
||||||
|
binding_version,
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
webhook.notify_node_connected(&connect_req).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(report_time) = chrono::DateTime::<chrono::Local>::from_str(&req.report_time) else {
|
let Ok(report_time) = chrono::DateTime::<chrono::Local>::from_str(&req.report_time) else {
|
||||||
@@ -203,8 +359,10 @@ impl Session {
|
|||||||
client_url: url::Url,
|
client_url: url::Url,
|
||||||
location: Option<Location>,
|
location: Option<Location>,
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let session_data = SessionData::new(storage, client_url, location, feature_flags);
|
let session_data =
|
||||||
|
SessionData::new(storage, client_url, location, feature_flags, webhook_config);
|
||||||
let data = Arc::new(RwLock::new(session_data));
|
let data = Arc::new(RwLock::new(session_data));
|
||||||
|
|
||||||
let rpc_mgr =
|
let rpc_mgr =
|
||||||
@@ -335,6 +493,10 @@ impl Session {
|
|||||||
self.rpc_mgr.is_running()
|
self.rpc_mgr.is_running()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
self.rpc_mgr.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> SharedSessionData {
|
pub fn data(&self) -> SharedSessionData {
|
||||||
self.data.clone()
|
self.data.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct ClientInfo {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct StorageInner {
|
pub struct StorageInner {
|
||||||
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
|
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
|
||||||
|
global_machine_map: DashMap<uuid::Uuid, ClientInfo>,
|
||||||
pub db: Db,
|
pub db: Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,22 +42,19 @@ impl Storage {
|
|||||||
pub fn new(db: Db) -> Self {
|
pub fn new(db: Db) -> Self {
|
||||||
Storage(Arc::new(StorageInner {
|
Storage(Arc::new(StorageInner {
|
||||||
user_clients_map: DashMap::new(),
|
user_clients_map: DashMap::new(),
|
||||||
|
global_machine_map: DashMap::new(),
|
||||||
db,
|
db,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_mid_to_client_info_map(
|
fn remove_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, stoken: &StorageToken) {
|
||||||
map: &DashMap<uuid::Uuid, ClientInfo>,
|
map.remove_if(&stoken.machine_id, |_, v| {
|
||||||
machine_id: &uuid::Uuid,
|
v.storage_token.client_url == stoken.client_url
|
||||||
client_url: &url::Url,
|
&& v.storage_token.user_id == stoken.user_id
|
||||||
) {
|
});
|
||||||
map.remove_if(machine_id, |_, v| v.storage_token.client_url == *client_url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_mid_to_client_info_map(
|
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
|
||||||
map: &DashMap<uuid::Uuid, ClientInfo>,
|
|
||||||
client_info: &ClientInfo,
|
|
||||||
) {
|
|
||||||
map.entry(client_info.storage_token.machine_id)
|
map.entry(client_info.storage_token.machine_id)
|
||||||
.and_modify(|e| {
|
.and_modify(|e| {
|
||||||
if e.report_time < client_info.report_time {
|
if e.report_time < client_info.report_time {
|
||||||
@@ -78,14 +76,16 @@ impl Storage {
|
|||||||
report_time,
|
report_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::update_mid_to_client_info_map(&inner, &client_info);
|
Self::update_client_info_map(&inner, &client_info);
|
||||||
|
Self::update_client_info_map(&self.0.global_machine_map, &client_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_client(&self, stoken: &StorageToken) {
|
pub fn remove_client(&self, stoken: &StorageToken) {
|
||||||
|
Self::remove_client_info_map(&self.0.global_machine_map, stoken);
|
||||||
self.0
|
self.0
|
||||||
.user_clients_map
|
.user_clients_map
|
||||||
.remove_if(&stoken.user_id, |_, set| {
|
.remove_if(&stoken.user_id, |_, set| {
|
||||||
Self::remove_mid_to_client_info_map(set, &stoken.machine_id, &stoken.client_url);
|
Self::remove_client_info_map(set, stoken);
|
||||||
set.is_empty()
|
set.is_empty()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,22 @@ impl Storage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find client_url by machine_id across all users.
|
||||||
|
pub fn get_client_url_by_machine_id_global(&self, machine_id: &uuid::Uuid) -> Option<url::Url> {
|
||||||
|
self.0
|
||||||
|
.global_machine_map
|
||||||
|
.get(machine_id)
|
||||||
|
.map(|info| info.storage_token.client_url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find user_id by machine_id across all users.
|
||||||
|
pub fn get_user_id_by_machine_id_global(&self, machine_id: &uuid::Uuid) -> Option<UserIdInDb> {
|
||||||
|
self.0
|
||||||
|
.global_machine_map
|
||||||
|
.get(machine_id)
|
||||||
|
.map(|info| info.storage_token.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_user_clients(&self, user_id: UserIdInDb) -> Vec<url::Url> {
|
pub fn list_user_clients(&self, user_id: UserIdInDb) -> Vec<url::Url> {
|
||||||
self.0
|
self.0
|
||||||
.user_clients_map
|
.user_clients_map
|
||||||
@@ -129,3 +145,57 @@ impl Storage {
|
|||||||
Ok(new_user.id)
|
Ok(new_user.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_storage_token(
|
||||||
|
user_id: UserIdInDb,
|
||||||
|
machine_id: uuid::Uuid,
|
||||||
|
client_url: &str,
|
||||||
|
) -> StorageToken {
|
||||||
|
StorageToken {
|
||||||
|
token: format!("token-{machine_id}"),
|
||||||
|
client_url: client_url.parse().unwrap(),
|
||||||
|
machine_id,
|
||||||
|
user_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn global_machine_index_uses_latest_report_and_ignores_stale_removal() {
|
||||||
|
let storage = Storage::new(Db::memory_db().await);
|
||||||
|
let machine_id = uuid::Uuid::new_v4();
|
||||||
|
|
||||||
|
let old_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
|
||||||
|
let new_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
|
||||||
|
|
||||||
|
storage.update_client(old_token.clone(), 10);
|
||||||
|
storage.update_client(new_token.clone(), 20);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.get_client_url_by_machine_id_global(&machine_id),
|
||||||
|
Some(new_token.client_url.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
storage.get_user_id_by_machine_id_global(&machine_id),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.remove_client(&old_token);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.get_client_url_by_machine_id_global(&machine_id),
|
||||||
|
Some(new_token.client_url.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.remove_client(&new_token);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.get_client_url_by_machine_id_global(&machine_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(storage.get_user_id_by_machine_id_global(&machine_id), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod client_manager;
|
|||||||
mod db;
|
mod db;
|
||||||
mod migrator;
|
mod migrator;
|
||||||
mod restful;
|
mod restful;
|
||||||
|
mod webhook;
|
||||||
|
|
||||||
#[cfg(feature = "embed")]
|
#[cfg(feature = "embed")]
|
||||||
mod web;
|
mod web;
|
||||||
@@ -132,6 +133,34 @@ struct Cli {
|
|||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
oidc: restful::oidc::OidcOptions,
|
oidc: restful::oidc::OidcOptions,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
webhook: WebhookOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, clap::Args)]
|
||||||
|
pub struct WebhookOptions {
|
||||||
|
/// Base URL of the webhook endpoint for token validation and event delivery.
|
||||||
|
/// When set, incoming tokens are validated via this webhook before local fallback.
|
||||||
|
#[arg(long)]
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
|
||||||
|
/// Shared secret used to authenticate outbound webhook calls.
|
||||||
|
#[arg(long)]
|
||||||
|
pub webhook_secret: Option<String>,
|
||||||
|
|
||||||
|
/// Token for X-Internal-Auth header. When set, API requests with this header
|
||||||
|
/// bypass session authentication.
|
||||||
|
#[arg(long)]
|
||||||
|
pub internal_auth_token: Option<String>,
|
||||||
|
|
||||||
|
/// Stable identifier for this easytier-web instance when routing webhook callbacks.
|
||||||
|
#[arg(long)]
|
||||||
|
pub web_instance_id: Option<String>,
|
||||||
|
|
||||||
|
/// Reachable base URL for this easytier-web instance's internal REST API.
|
||||||
|
#[arg(long)]
|
||||||
|
pub web_instance_api_base_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, clap::Args)]
|
#[derive(Debug, Clone, Default, clap::Args)]
|
||||||
@@ -237,8 +266,19 @@ async fn main() {
|
|||||||
// let db = db::Db::new(":memory:").await.unwrap();
|
// let db = db::Db::new(":memory:").await.unwrap();
|
||||||
let db = db::Db::new(cli.db).await.unwrap();
|
let db = db::Db::new(cli.db).await.unwrap();
|
||||||
let feature_flags = Arc::new(cli.feature_flags);
|
let feature_flags = Arc::new(cli.feature_flags);
|
||||||
let mut mgr =
|
let webhook_config = Arc::new(webhook::WebhookConfig::new(
|
||||||
client_manager::ClientManager::new(db.clone(), cli.geoip_db, feature_flags.clone());
|
cli.webhook.webhook_url,
|
||||||
|
cli.webhook.webhook_secret,
|
||||||
|
cli.webhook.internal_auth_token,
|
||||||
|
cli.webhook.web_instance_id,
|
||||||
|
cli.webhook.web_instance_api_base_url,
|
||||||
|
));
|
||||||
|
let mut mgr = client_manager::ClientManager::new(
|
||||||
|
db.clone(),
|
||||||
|
cli.geoip_db,
|
||||||
|
feature_flags.clone(),
|
||||||
|
webhook_config.clone(),
|
||||||
|
);
|
||||||
let (v6_listener, v4_listener) =
|
let (v6_listener, v4_listener) =
|
||||||
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
||||||
.await
|
.await
|
||||||
@@ -292,6 +332,7 @@ async fn main() {
|
|||||||
web_router_restful,
|
web_router_restful,
|
||||||
feature_flags,
|
feature_flags,
|
||||||
oidc_config,
|
oidc_config,
|
||||||
|
webhook_config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ mod users;
|
|||||||
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::extract::Path;
|
||||||
use axum::routing::post;
|
use axum::http::{header, Request, StatusCode};
|
||||||
|
use axum::middleware::{self as axum_mw, Next};
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::routing::{delete, post};
|
||||||
use axum::{extract::State, routing::get, Extension, Json, Router};
|
use axum::{extract::State, routing::get, Extension, Json, Router};
|
||||||
use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
|
use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
|
||||||
use axum_login::{login_required, AuthManagerLayerBuilder, AuthUser, AuthzBackend};
|
use axum_login::{login_required, AuthManagerLayerBuilder, AuthUser, AuthzBackend};
|
||||||
@@ -29,6 +32,7 @@ use users::{AuthSession, Backend};
|
|||||||
use crate::client_manager::storage::StorageToken;
|
use crate::client_manager::storage::StorageToken;
|
||||||
use crate::client_manager::ClientManager;
|
use crate::client_manager::ClientManager;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
use crate::webhook::SharedWebhookConfig;
|
||||||
use crate::FeatureFlags;
|
use crate::FeatureFlags;
|
||||||
|
|
||||||
/// Embed assets for web dashboard, build frontend first
|
/// Embed assets for web dashboard, build frontend first
|
||||||
@@ -41,12 +45,9 @@ pub struct RestfulServer {
|
|||||||
bind_addr: SocketAddr,
|
bind_addr: SocketAddr,
|
||||||
client_mgr: Arc<ClientManager>,
|
client_mgr: Arc<ClientManager>,
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
db: Db,
|
db: Db,
|
||||||
oidc_config: oidc::OidcConfig,
|
oidc_config: oidc::OidcConfig,
|
||||||
|
|
||||||
// serve_task: Option<ScopedTask<()>>,
|
|
||||||
// delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
|
|
||||||
// network_api: NetworkApi<WebClientManager>,
|
|
||||||
web_router: Option<Router>,
|
web_router: Option<Router>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,20 +112,17 @@ impl RestfulServer {
|
|||||||
web_router: Option<Router>,
|
web_router: Option<Router>,
|
||||||
feature_flags: Arc<FeatureFlags>,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
oidc_config: oidc::OidcConfig,
|
oidc_config: oidc::OidcConfig,
|
||||||
|
webhook_config: SharedWebhookConfig,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
assert!(client_mgr.is_running());
|
assert!(client_mgr.is_running());
|
||||||
|
|
||||||
// let network_api = NetworkApi::new();
|
|
||||||
|
|
||||||
Ok(RestfulServer {
|
Ok(RestfulServer {
|
||||||
bind_addr,
|
bind_addr,
|
||||||
client_mgr,
|
client_mgr,
|
||||||
feature_flags,
|
feature_flags,
|
||||||
|
webhook_config,
|
||||||
db,
|
db,
|
||||||
oidc_config,
|
oidc_config,
|
||||||
// serve_task: None,
|
|
||||||
// delete_task: None,
|
|
||||||
// network_api,
|
|
||||||
web_router,
|
web_router,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -245,7 +243,31 @@ impl RestfulServer {
|
|||||||
.zstd(true)
|
.zstd(true)
|
||||||
.quality(tower_http::compression::CompressionLevel::Default);
|
.quality(tower_http::compression::CompressionLevel::Default);
|
||||||
|
|
||||||
let app = Router::new()
|
// Token-authenticated management routes that bypass session auth.
|
||||||
|
let internal_app = if self.webhook_config.has_internal_auth() {
|
||||||
|
let internal_token = self.webhook_config.internal_auth_token.clone().unwrap();
|
||||||
|
let internal_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/internal/sessions",
|
||||||
|
get(Self::handle_list_all_sessions_internal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/internal/sessions/:machine-id",
|
||||||
|
delete(Self::handle_disconnect_session_internal),
|
||||||
|
)
|
||||||
|
.merge(NetworkApi::build_route_internal())
|
||||||
|
.merge(rpc::router_internal())
|
||||||
|
.with_state(self.client_mgr.clone())
|
||||||
|
.layer(axum_mw::from_fn(move |req, next| {
|
||||||
|
let token = internal_token.clone();
|
||||||
|
internal_auth_middleware(token, req, next)
|
||||||
|
}));
|
||||||
|
Some(internal_routes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app = Router::new()
|
||||||
.route("/api/v1/summary", get(Self::handle_get_summary))
|
.route("/api/v1/summary", get(Self::handle_get_summary))
|
||||||
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
||||||
.merge(NetworkApi::build_route())
|
.merge(NetworkApi::build_route())
|
||||||
@@ -265,6 +287,10 @@ impl RestfulServer {
|
|||||||
.layer(tower_http::cors::CorsLayer::very_permissive())
|
.layer(tower_http::cors::CorsLayer::very_permissive())
|
||||||
.layer(compression_layer);
|
.layer(compression_layer);
|
||||||
|
|
||||||
|
if let Some(internal_routes) = internal_app {
|
||||||
|
app = app.merge(internal_routes);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "embed")]
|
#[cfg(feature = "embed")]
|
||||||
let app = if let Some(web_router) = self.web_router.take() {
|
let app = if let Some(web_router) = self.web_router.take() {
|
||||||
app.merge(web_router)
|
app.merge(web_router)
|
||||||
@@ -279,4 +305,52 @@ impl RestfulServer {
|
|||||||
|
|
||||||
Ok((serve_task, delete_task))
|
Ok((serve_task, delete_task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session listing endpoint for token-authenticated management clients.
|
||||||
|
async fn handle_list_all_sessions_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
|
||||||
|
let ret = client_mgr.list_sessions().await;
|
||||||
|
Ok(ListSessionJsonResp(ret).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_disconnect_session_internal(
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
) -> Result<StatusCode, HttpHandleError> {
|
||||||
|
if client_mgr
|
||||||
|
.disconnect_session_by_machine_id_global(&machine_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
other_error("session not found").into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware that validates X-Internal-Auth for token-authenticated routes.
|
||||||
|
async fn internal_auth_middleware(
|
||||||
|
expected_token: String,
|
||||||
|
req: Request<axum::body::Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let auth_header = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Internal-Auth")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
match auth_header {
|
||||||
|
Some(token) if token == expected_token => next.run(req).await,
|
||||||
|
_ => Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(axum::body::Body::from(
|
||||||
|
r#"{"error":"unauthorized: invalid or missing X-Internal-Auth header"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,87 @@ impl NetworkApi {
|
|||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Token-authenticated machine-scoped handlers (no AuthSession) ---
|
||||||
|
|
||||||
|
async fn handle_run_network_instance_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
Json(payload): Json<RunNetworkJsonReq>,
|
||||||
|
) -> Result<Json<Void>, HttpHandleError> {
|
||||||
|
let user_id = Self::get_user_id_from_machine(&client_mgr, &machine_id)?;
|
||||||
|
client_mgr
|
||||||
|
.handle_run_network_instance((user_id, machine_id), payload.config, payload.save)
|
||||||
|
.await
|
||||||
|
.map_err(convert_error)?;
|
||||||
|
Ok(Void::default().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_remove_network_instance_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
|
) -> Result<(), HttpHandleError> {
|
||||||
|
let user_id = Self::get_user_id_from_machine(&client_mgr, &machine_id)?;
|
||||||
|
client_mgr
|
||||||
|
.handle_remove_network_instances((user_id, machine_id), vec![inst_id])
|
||||||
|
.await
|
||||||
|
.map_err(convert_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_list_network_instance_ids_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<ListNetworkInstanceIdsJsonResp>, HttpHandleError> {
|
||||||
|
let user_id = Self::get_user_id_from_machine(&client_mgr, &machine_id)?;
|
||||||
|
Ok(client_mgr
|
||||||
|
.handle_list_network_instance_ids((user_id, machine_id))
|
||||||
|
.await
|
||||||
|
.map_err(convert_error)?
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_collect_network_info_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
Json(payload): Json<CollectNetworkInfoJsonReq>,
|
||||||
|
) -> Result<Json<CollectNetworkInfoResponse>, HttpHandleError> {
|
||||||
|
let user_id = Self::get_user_id_from_machine(&client_mgr, &machine_id)?;
|
||||||
|
Ok(client_mgr
|
||||||
|
.handle_collect_network_info((user_id, machine_id), payload.inst_ids)
|
||||||
|
.await
|
||||||
|
.map_err(convert_error)?
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up user_id from a machine's active session token.
|
||||||
|
fn get_user_id_from_machine(
|
||||||
|
client_mgr: &AppStateInner,
|
||||||
|
machine_id: &uuid::Uuid,
|
||||||
|
) -> Result<UserIdInDb, HttpHandleError> {
|
||||||
|
client_mgr
|
||||||
|
.get_user_id_by_machine_id_global(machine_id)
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
other_error("Machine not found").into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_route_internal() -> Router<AppStateInner> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/internal/machines/:machine-id/networks",
|
||||||
|
post(Self::handle_run_network_instance_internal)
|
||||||
|
.get(Self::handle_list_network_instance_ids_internal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/internal/machines/:machine-id/networks/:inst-id",
|
||||||
|
delete(Self::handle_remove_network_instance_internal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/internal/machines/:machine-id/networks/info",
|
||||||
|
get(Self::handle_collect_network_info_internal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_route() -> Router<AppStateInner> {
|
pub fn build_route() -> Router<AppStateInner> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v1/machines", get(Self::handle_list_machines))
|
.route("/api/v1/machines", get(Self::handle_list_machines))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use axum_login::AuthUser as _;
|
use axum_login::AuthUser as _;
|
||||||
|
use easytier::proto::rpc_types::controller::BaseController;
|
||||||
|
|
||||||
use super::{other_error, AppState, HttpHandleError};
|
use super::{other_error, AppState, HttpHandleError};
|
||||||
|
|
||||||
@@ -19,34 +20,15 @@ macro_rules! match_service {
|
|||||||
($factory:ty, $method_name:expr, $payload:expr, $session:expr) => {{
|
($factory:ty, $method_name:expr, $payload:expr, $session:expr) => {{
|
||||||
let client = $session.scoped_client::<$factory>();
|
let client = $session.scoped_client::<$factory>();
|
||||||
client
|
client
|
||||||
.json_call_method(
|
.json_call_method(BaseController::default(), &$method_name, $payload)
|
||||||
easytier::proto::rpc_types::controller::BaseController::default(),
|
|
||||||
&$method_name,
|
|
||||||
$payload,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_proxy_rpc(
|
async fn handle_proxy_rpc_by_session(
|
||||||
auth_session: super::users::AuthSession,
|
session: &crate::client_manager::session::Session,
|
||||||
State(client_mgr): AppState,
|
req: ProxyRpcRequest,
|
||||||
Path(machine_id): Path<uuid::Uuid>,
|
|
||||||
Json(req): Json<ProxyRpcRequest>,
|
|
||||||
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||||
let user_id = auth_session
|
|
||||||
.user
|
|
||||||
.as_ref()
|
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, other_error("Unauthorized").into()))?
|
|
||||||
.id();
|
|
||||||
|
|
||||||
let session = client_mgr
|
|
||||||
.get_session_by_machine_id(user_id, &machine_id)
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
other_error("Session not found").into(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let ProxyRpcRequest {
|
let ProxyRpcRequest {
|
||||||
service_name,
|
service_name,
|
||||||
method_name,
|
method_name,
|
||||||
@@ -55,97 +37,79 @@ pub async fn handle_proxy_rpc(
|
|||||||
|
|
||||||
let resp = match service_name.as_str() {
|
let resp = match service_name.as_str() {
|
||||||
"api.manage.WebClientService" => match_service!(
|
"api.manage.WebClientService" => match_service!(
|
||||||
easytier::proto::api::manage::WebClientServiceClientFactory<
|
easytier::proto::api::manage::WebClientServiceClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.PeerManageRpcService" => match_service!(
|
"api.instance.PeerManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::PeerManageRpcClientFactory<
|
easytier::proto::api::instance::PeerManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
method_name,
|
||||||
>,
|
payload,
|
||||||
|
session
|
||||||
|
),
|
||||||
|
"api.instance.PeerCenterManageRpcService" => match_service!(
|
||||||
|
easytier::proto::peer_rpc::PeerCenterRpcClientFactory<BaseController>,
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.ConnectorManageRpcService" => match_service!(
|
"api.instance.ConnectorManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::ConnectorManageRpcClientFactory<
|
easytier::proto::api::instance::ConnectorManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.MappedListenerManageRpcService" => match_service!(
|
"api.instance.MappedListenerManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<
|
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.VpnPortalRpcService" => match_service!(
|
"api.instance.VpnPortalRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::VpnPortalRpcClientFactory<
|
easytier::proto::api::instance::VpnPortalRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.TcpProxyRpcService" => match_service!(
|
"api.instance.TcpProxyRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::TcpProxyRpcClientFactory<
|
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.AclManageRpcService" => match_service!(
|
"api.instance.AclManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::AclManageRpcClientFactory<
|
easytier::proto::api::instance::AclManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.PortForwardManageRpcService" => match_service!(
|
"api.instance.PortForwardManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::PortForwardManageRpcClientFactory<
|
easytier::proto::api::instance::PortForwardManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.StatsRpcService" => match_service!(
|
"api.instance.StatsRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::StatsRpcClientFactory<
|
easytier::proto::api::instance::StatsRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.instance.CredentialManageRpcService" => match_service!(
|
"api.instance.CredentialManageRpcService" => match_service!(
|
||||||
easytier::proto::api::instance::CredentialManageRpcClientFactory<
|
easytier::proto::api::instance::CredentialManageRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.logger.LoggerRpcService" => match_service!(
|
"api.logger.LoggerRpcService" => match_service!(
|
||||||
easytier::proto::api::logger::LoggerRpcClientFactory<
|
easytier::proto::api::logger::LoggerRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
),
|
),
|
||||||
"api.config.ConfigRpcService" => match_service!(
|
"api.config.ConfigRpcService" => match_service!(
|
||||||
easytier::proto::api::config::ConfigRpcClientFactory<
|
easytier::proto::api::config::ConfigRpcClientFactory<BaseController>,
|
||||||
easytier::proto::rpc_types::controller::BaseController,
|
|
||||||
>,
|
|
||||||
method_name,
|
method_name,
|
||||||
payload,
|
payload,
|
||||||
session
|
session
|
||||||
@@ -167,9 +131,52 @@ pub async fn handle_proxy_rpc(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn handle_proxy_rpc(
|
||||||
|
auth_session: super::users::AuthSession,
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<ProxyRpcRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||||
|
let user_id = auth_session
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, other_error("Unauthorized").into()))?
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let session = client_mgr
|
||||||
|
.get_session_by_machine_id(user_id, &machine_id)
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
other_error("Session not found").into(),
|
||||||
|
))?;
|
||||||
|
handle_proxy_rpc_by_session(session.as_ref(), req).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<super::AppStateInner> {
|
pub fn router() -> Router<super::AppStateInner> {
|
||||||
Router::new().route(
|
Router::new().route(
|
||||||
"/api/v1/machines/:machine-id/proxy-rpc",
|
"/api/v1/machines/:machine-id/proxy-rpc",
|
||||||
post(handle_proxy_rpc),
|
post(handle_proxy_rpc),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Internal proxy-rpc handler: no AuthSession, resolves the active session by machine_id.
|
||||||
|
pub async fn handle_proxy_rpc_internal(
|
||||||
|
State(client_mgr): AppState,
|
||||||
|
Path(machine_id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<ProxyRpcRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||||
|
let session = client_mgr
|
||||||
|
.get_session_by_machine_id_global(&machine_id)
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
other_error("Session not found").into(),
|
||||||
|
))?;
|
||||||
|
handle_proxy_rpc_by_session(session.as_ref(), req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router_internal() -> Router<super::AppStateInner> {
|
||||||
|
Router::new().route(
|
||||||
|
"/api/internal/machines/:machine-id/proxy-rpc",
|
||||||
|
post(handle_proxy_rpc_internal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Webhook configuration for external integrations.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WebhookConfig {
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
pub webhook_secret: Option<String>,
|
||||||
|
pub internal_auth_token: Option<String>,
|
||||||
|
pub web_instance_id: Option<String>,
|
||||||
|
pub web_instance_api_base_url: Option<String>,
|
||||||
|
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebhookConfig {
|
||||||
|
pub fn new(
|
||||||
|
webhook_url: Option<String>,
|
||||||
|
webhook_secret: Option<String>,
|
||||||
|
internal_auth_token: Option<String>,
|
||||||
|
web_instance_id: Option<String>,
|
||||||
|
web_instance_api_base_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
WebhookConfig {
|
||||||
|
webhook_url,
|
||||||
|
webhook_secret,
|
||||||
|
internal_auth_token,
|
||||||
|
web_instance_id,
|
||||||
|
web_instance_api_base_url,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.webhook_url
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|url| !url.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_internal_auth(&self) -> bool {
|
||||||
|
self.internal_auth_token.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request/Response types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ValidateTokenRequest {
|
||||||
|
pub token: String,
|
||||||
|
pub machine_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub version: String,
|
||||||
|
pub web_instance_id: Option<String>,
|
||||||
|
pub web_instance_api_base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ValidateTokenResponse {
|
||||||
|
pub valid: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pre_approved: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub binding_version: u64,
|
||||||
|
pub network_config: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NodeConnectedRequest {
|
||||||
|
pub machine_id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub version: String,
|
||||||
|
pub web_instance_id: Option<String>,
|
||||||
|
pub binding_version: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NodeDisconnectedRequest {
|
||||||
|
pub machine_id: String,
|
||||||
|
pub web_instance_id: Option<String>,
|
||||||
|
pub binding_version: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Webhook client ---
|
||||||
|
|
||||||
|
impl WebhookConfig {
|
||||||
|
fn webhook_base_url(&self) -> anyhow::Result<&str> {
|
||||||
|
self.webhook_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("webhook_url is not configured"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webhook_endpoint(&self, path: &str) -> anyhow::Result<String> {
|
||||||
|
Ok(format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.webhook_base_url()?.trim_end_matches('/'),
|
||||||
|
path.trim_start_matches('/'),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a token through the configured webhook endpoint.
|
||||||
|
pub async fn validate_token(
|
||||||
|
&self,
|
||||||
|
req: &ValidateTokenRequest,
|
||||||
|
) -> anyhow::Result<ValidateTokenResponse> {
|
||||||
|
let url = self.webhook_endpoint("validate-token")?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||||
|
.json(req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("webhook validate-token returned status {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify the webhook receiver that a node has connected.
|
||||||
|
pub async fn notify_node_connected(&self, req: &NodeConnectedRequest) {
|
||||||
|
if !self.is_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(url) = self.webhook_endpoint("webhook/node-connected") else {
|
||||||
|
tracing::warn!("skip node-connected webhook because webhook_url is not configured");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||||
|
.json(req)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify the webhook receiver that a node has disconnected.
|
||||||
|
pub async fn notify_node_disconnected(&self, req: &NodeDisconnectedRequest) {
|
||||||
|
if !self.is_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(url) = self.webhook_endpoint("webhook/node-disconnected") else {
|
||||||
|
tracing::warn!("skip node-disconnected webhook because webhook_url is not configured");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||||
|
.json(req)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webhook_auth_secret(&self) -> &str {
|
||||||
|
self.webhook_secret
|
||||||
|
.as_deref()
|
||||||
|
.or(self.internal_auth_token.as_deref())
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedWebhookConfig = Arc<WebhookConfig>;
|
||||||
@@ -6,6 +6,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::AsyncReadExt as _;
|
use tokio::io::AsyncReadExt as _;
|
||||||
|
|
||||||
@@ -405,6 +406,42 @@ impl From<PortForwardConfig> for PortForwardConfigPb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn process_secure_mode_cfg(mut user_cfg: SecureModeConfig) -> anyhow::Result<SecureModeConfig> {
|
||||||
|
if !user_cfg.enabled {
|
||||||
|
return Ok(user_cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let private_key = if user_cfg.local_private_key.is_none() {
|
||||||
|
// if no private key, generate random one
|
||||||
|
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
user_cfg.local_private_key = Some(BASE64_STANDARD.encode(private.clone().as_bytes()));
|
||||||
|
private
|
||||||
|
} else {
|
||||||
|
// check if private key is valid
|
||||||
|
user_cfg.private_key()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let public = x25519_dalek::PublicKey::from(&private_key);
|
||||||
|
|
||||||
|
match user_cfg.local_public_key {
|
||||||
|
None => {
|
||||||
|
user_cfg.local_public_key = Some(BASE64_STANDARD.encode(public.as_bytes()));
|
||||||
|
}
|
||||||
|
Some(ref user_pub) => {
|
||||||
|
let public = user_cfg.public_key()?;
|
||||||
|
if *user_pub != BASE64_STANDARD.encode(public.as_bytes()) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"local public key {} does not match generated public key {}",
|
||||||
|
user_pub,
|
||||||
|
BASE64_STANDARD.encode(public.as_bytes())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user_cfg)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
struct Config {
|
struct Config {
|
||||||
netns: Option<String>,
|
netns: Option<String>,
|
||||||
|
|||||||
@@ -146,20 +146,39 @@ pub fn init(
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
while let Ok(lf) = recver.recv() {
|
while let Ok(lf) = recver.recv() {
|
||||||
let e = file_filter_reloader.modify(|f| {
|
let parsed_level = match lf.parse::<LevelFilter>() {
|
||||||
if let Ok(nf) = EnvFilter::builder()
|
Ok(level) => level,
|
||||||
.with_default_directive(lf.parse::<LevelFilter>().unwrap().into())
|
Err(e) => {
|
||||||
|
error!("Failed to parse new log level {:?}: {}", lf, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_filter = match EnvFilter::builder()
|
||||||
|
.with_default_directive(parsed_level.into())
|
||||||
.from_env()
|
.from_env()
|
||||||
.with_context(|| "failed to create file filter")
|
.with_context(|| "failed to create file filter")
|
||||||
{
|
{
|
||||||
info!("Reload log filter succeed, new filter level: {:?}", lf);
|
Ok(filter) => Some(filter),
|
||||||
*f = nf;
|
Err(e) => {
|
||||||
|
error!("Failed to build new log filter for {:?}: {:?}", lf, e);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
if e.is_err() {
|
|
||||||
|
match file_filter_reloader.modify(|f| {
|
||||||
|
*f = new_filter
|
||||||
|
.take()
|
||||||
|
.expect("log filter reloader only applies one filter per reload");
|
||||||
|
}) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("Reload log filter succeed, new filter level: {:?}", lf);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
error!("Failed to reload log filter: {:?}", e);
|
error!("Failed to reload log filter: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
info!("Stop log filter reloader");
|
info!("Stop log filter reloader");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ pub fn set_default_machine_id(mid: Option<String>) {
|
|||||||
|
|
||||||
pub fn get_machine_id() -> uuid::Uuid {
|
pub fn get_machine_id() -> uuid::Uuid {
|
||||||
if let Some(default_mid) = use_global_var!(MACHINE_UID) {
|
if let Some(default_mid) = use_global_var!(MACHINE_UID) {
|
||||||
|
if let Ok(mid) = uuid::Uuid::parse_str(default_mid.trim()) {
|
||||||
|
return mid;
|
||||||
|
}
|
||||||
let mut b = [0u8; 16];
|
let mut b = [0u8; 16];
|
||||||
crate::tunnel::generate_digest_from_str("", &default_mid, &mut b);
|
crate::tunnel::generate_digest_from_str("", &default_mid, &mut b);
|
||||||
return uuid::Uuid::from_bytes(b);
|
return uuid::Uuid::from_bytes(b);
|
||||||
@@ -207,4 +210,12 @@ mod tests {
|
|||||||
assert_eq!(weak_js.weak_count(), 0);
|
assert_eq!(weak_js.weak_count(), 0);
|
||||||
assert_eq!(weak_js.strong_count(), 0);
|
assert_eq!(weak_js.strong_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_machine_id_uses_uuid_seed_verbatim() {
|
||||||
|
let raw = "33333333-3333-3333-3333-333333333333".to_string();
|
||||||
|
set_default_machine_id(Some(raw.clone()));
|
||||||
|
assert_eq!(get_machine_id(), uuid::Uuid::parse_str(&raw).unwrap());
|
||||||
|
set_default_machine_id(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-43
@@ -10,9 +10,10 @@ use std::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{
|
common::{
|
||||||
config::{
|
config::{
|
||||||
get_avaliable_encrypt_methods, load_config_from_file, ConfigFileControl, ConfigLoader,
|
get_avaliable_encrypt_methods, load_config_from_file, process_secure_mode_cfg,
|
||||||
ConsoleLoggerConfig, FileLoggerConfig, LoggingConfigLoader, NetworkIdentity,
|
ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, FileLoggerConfig,
|
||||||
PeerConfig, PortForwardConfig, TomlConfigLoader, VpnPortalConfig,
|
LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||||
|
VpnPortalConfig,
|
||||||
},
|
},
|
||||||
constants::EASYTIER_VERSION,
|
constants::EASYTIER_VERSION,
|
||||||
log,
|
log,
|
||||||
@@ -27,10 +28,8 @@ use crate::{
|
|||||||
web_client, ShellType,
|
web_client, ShellType,
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
|
||||||
use cidr::IpCidr;
|
use cidr::IpCidr;
|
||||||
use clap::{CommandFactory, Parser};
|
use clap::{CommandFactory, Parser};
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use rust_i18n::t;
|
use rust_i18n::t;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
@@ -773,42 +772,6 @@ impl NetworkOptions {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_secure_mode_cfg(mut user_cfg: SecureModeConfig) -> anyhow::Result<SecureModeConfig> {
|
|
||||||
if !user_cfg.enabled {
|
|
||||||
return Ok(user_cfg);
|
|
||||||
}
|
|
||||||
|
|
||||||
let private_key = if user_cfg.local_private_key.is_none() {
|
|
||||||
// if no private key, generate random one
|
|
||||||
let private = x25519_dalek::StaticSecret::random_from_rng(OsRng);
|
|
||||||
user_cfg.local_private_key = Some(BASE64_STANDARD.encode(private.clone().as_bytes()));
|
|
||||||
private
|
|
||||||
} else {
|
|
||||||
// check if private key is valid
|
|
||||||
user_cfg.private_key()?
|
|
||||||
};
|
|
||||||
|
|
||||||
let public = x25519_dalek::PublicKey::from(&private_key);
|
|
||||||
|
|
||||||
match user_cfg.local_public_key {
|
|
||||||
None => {
|
|
||||||
user_cfg.local_public_key = Some(BASE64_STANDARD.encode(public.as_bytes()));
|
|
||||||
}
|
|
||||||
Some(ref user_pub) => {
|
|
||||||
let public = user_cfg.public_key()?;
|
|
||||||
if *user_pub != BASE64_STANDARD.encode(public.as_bytes()) {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"local public key {} does not match generated public key {}",
|
|
||||||
user_pub,
|
|
||||||
BASE64_STANDARD.encode(public.as_bytes())
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(user_cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_into(&self, cfg: &TomlConfigLoader) -> anyhow::Result<()> {
|
fn merge_into(&self, cfg: &TomlConfigLoader) -> anyhow::Result<()> {
|
||||||
if self.hostname.is_some() {
|
if self.hostname.is_some() {
|
||||||
cfg.set_hostname(self.hostname.clone());
|
cfg.set_hostname(self.hostname.clone());
|
||||||
@@ -1006,7 +969,7 @@ impl NetworkOptions {
|
|||||||
local_private_key: Some(credential_secret.clone()),
|
local_private_key: Some(credential_secret.clone()),
|
||||||
local_public_key: None,
|
local_public_key: None,
|
||||||
};
|
};
|
||||||
cfg.set_secure_mode(Some(Self::process_secure_mode_cfg(c)?));
|
cfg.set_secure_mode(Some(process_secure_mode_cfg(c)?));
|
||||||
} else if let Some(secure_mode) = self.secure_mode {
|
} else if let Some(secure_mode) = self.secure_mode {
|
||||||
if secure_mode {
|
if secure_mode {
|
||||||
let c = SecureModeConfig {
|
let c = SecureModeConfig {
|
||||||
@@ -1014,7 +977,7 @@ impl NetworkOptions {
|
|||||||
local_private_key: self.local_private_key.clone(),
|
local_private_key: self.local_private_key.clone(),
|
||||||
local_public_key: self.local_public_key.clone(),
|
local_public_key: self.local_public_key.clone(),
|
||||||
};
|
};
|
||||||
cfg.set_secure_mode(Some(Self::process_secure_mode_cfg(c)?));
|
cfg.set_secure_mode(Some(process_secure_mode_cfg(c)?));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1018
-558
File diff suppressed because it is too large
Load Diff
@@ -192,7 +192,13 @@ impl NetworkInstanceManager {
|
|||||||
self.instance_map.iter().map(|item| *item.key()).collect()
|
self.instance_map.iter().map(|item| *item.key()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_network_instance_name(&self, instance_id: &uuid::Uuid) -> Option<String> {
|
pub fn get_instance_name(&self, instance_id: &uuid::Uuid) -> Option<String> {
|
||||||
|
self.instance_map
|
||||||
|
.get(instance_id)
|
||||||
|
.map(|instance| instance.value().get_inst_name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_network_name(&self, instance_id: &uuid::Uuid) -> Option<String> {
|
||||||
self.instance_map
|
self.instance_map
|
||||||
.get(instance_id)
|
.get(instance_id)
|
||||||
.map(|instance| instance.value().get_network_name())
|
.map(|instance| instance.value().get_network_name())
|
||||||
|
|||||||
+111
-4
@@ -1,4 +1,4 @@
|
|||||||
use crate::common::config::{ConfigFileControl, PortForwardConfig};
|
use crate::common::config::{process_secure_mode_cfg, ConfigFileControl, PortForwardConfig};
|
||||||
use crate::proto::api::{self, manage};
|
use crate::proto::api::{self, manage};
|
||||||
use crate::proto::rpc_types::controller::BaseController;
|
use crate::proto::rpc_types::controller::BaseController;
|
||||||
use crate::rpc_service::InstanceRpcService;
|
use crate::rpc_service::InstanceRpcService;
|
||||||
@@ -509,10 +509,29 @@ impl NetworkConfig {
|
|||||||
cfg.set_hostname(self.hostname.clone());
|
cfg.set_hostname(self.hostname.clone());
|
||||||
cfg.set_dhcp(self.dhcp.unwrap_or_default());
|
cfg.set_dhcp(self.dhcp.unwrap_or_default());
|
||||||
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
||||||
|
|
||||||
|
// The web UI does not expose credential inputs directly, but imported/saved
|
||||||
|
// NetworkConfig objects still need to preserve credential-mode instances via
|
||||||
|
// secure_mode.local_private_key + empty network_secret.
|
||||||
|
let credential_secret = if self.network_secret.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.secure_mode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|mode| mode.local_private_key.clone())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
};
|
||||||
|
|
||||||
|
if credential_secret.is_some() {
|
||||||
|
cfg.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
self.network_name.clone().unwrap_or_default(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
cfg.set_network_identity(NetworkIdentity::new(
|
cfg.set_network_identity(NetworkIdentity::new(
|
||||||
self.network_name.clone().unwrap_or_default(),
|
self.network_name.clone().unwrap_or_default(),
|
||||||
self.network_secret.clone().unwrap_or_default(),
|
self.network_secret.clone().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if !cfg.get_dhcp() {
|
if !cfg.get_dhcp() {
|
||||||
let virtual_ipv4 = self.virtual_ipv4.clone().unwrap_or_default();
|
let virtual_ipv4 = self.virtual_ipv4.clone().unwrap_or_default();
|
||||||
@@ -677,7 +696,30 @@ impl NetworkConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.set_secure_mode(self.secure_mode.clone());
|
if let Some(credential_file) = self
|
||||||
|
.credential_file
|
||||||
|
.as_ref()
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
{
|
||||||
|
cfg.set_credential_file(Some(credential_file.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(credential_secret) = credential_secret {
|
||||||
|
cfg.set_secure_mode(Some(process_secure_mode_cfg(
|
||||||
|
crate::proto::common::SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(credential_secret),
|
||||||
|
local_public_key: None,
|
||||||
|
},
|
||||||
|
)?));
|
||||||
|
} else {
|
||||||
|
cfg.set_secure_mode(
|
||||||
|
self.secure_mode
|
||||||
|
.clone()
|
||||||
|
.map(process_secure_mode_cfg)
|
||||||
|
.transpose()?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut flags = gen_default_flags();
|
let mut flags = gen_default_flags();
|
||||||
if let Some(latency_first) = self.latency_first {
|
if let Some(latency_first) = self.latency_first {
|
||||||
@@ -900,7 +942,9 @@ impl NetworkConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.secure_mode = config.get_secure_mode();
|
result.secure_mode = config.get_secure_mode();
|
||||||
|
result.credential_file = config
|
||||||
|
.get_credential_file()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned());
|
||||||
let flags = config.get_flags();
|
let flags = config.get_flags();
|
||||||
result.latency_first = Some(flags.latency_first);
|
result.latency_first = Some(flags.latency_first);
|
||||||
result.dev_name = Some(flags.dev_name.clone());
|
result.dev_name = Some(flags.dev_name.clone());
|
||||||
@@ -947,7 +991,11 @@ impl NetworkConfig {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{common::config::ConfigLoader, proto::common::SecureModeConfig};
|
use crate::{
|
||||||
|
common::config::{process_secure_mode_cfg, ConfigLoader},
|
||||||
|
proto::common::SecureModeConfig,
|
||||||
|
};
|
||||||
|
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
@@ -1195,6 +1243,10 @@ mod tests {
|
|||||||
config.set_flags(flags);
|
config.set_flags(flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(secure_mode) = config.get_secure_mode() {
|
||||||
|
config.set_secure_mode(Some(process_secure_mode_cfg(secure_mode)?));
|
||||||
|
}
|
||||||
|
|
||||||
let network_config = super::NetworkConfig::new_from_config(&config)?;
|
let network_config = super::NetworkConfig::new_from_config(&config)?;
|
||||||
let generated_config = network_config.gen_config()?;
|
let generated_config = network_config.gen_config()?;
|
||||||
generated_config.set_peers(generated_config.get_peers()); // Ensure peers field is not None
|
generated_config.set_peers(generated_config.get_peers()); // Ensure peers field is not None
|
||||||
@@ -1211,4 +1263,59 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_network_config_conversion_credential_mode() -> Result<(), anyhow::Error> {
|
||||||
|
let private_key = x25519_dalek::StaticSecret::from([7u8; 32]);
|
||||||
|
let public_key = x25519_dalek::PublicKey::from(&private_key);
|
||||||
|
let credential_secret = BASE64_STANDARD.encode(private_key.as_bytes());
|
||||||
|
let credential_file = "/tmp/easytier-credentials.json".to_string();
|
||||||
|
|
||||||
|
let config = gen_default_config();
|
||||||
|
config.set_network_identity(crate::common::config::NetworkIdentity::new_credential(
|
||||||
|
"credential-net".to_string(),
|
||||||
|
));
|
||||||
|
config.set_inst_name("credential-net".to_string());
|
||||||
|
config.set_credential_file(Some(credential_file.clone().into()));
|
||||||
|
config.set_secure_mode(Some(SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(credential_secret.clone()),
|
||||||
|
local_public_key: Some(BASE64_STANDARD.encode(public_key.as_bytes())),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let network_config = super::NetworkConfig::new_from_config(&config)?;
|
||||||
|
assert_eq!(
|
||||||
|
network_config.credential_file.as_deref(),
|
||||||
|
Some(credential_file.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(network_config.network_secret, None);
|
||||||
|
assert_eq!(
|
||||||
|
network_config
|
||||||
|
.secure_mode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|mode| mode.local_private_key.as_deref()),
|
||||||
|
Some(credential_secret.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
let generated_config = network_config.gen_config()?;
|
||||||
|
assert_eq!(
|
||||||
|
generated_config.get_network_identity().network_secret,
|
||||||
|
None,
|
||||||
|
"credential mode should not be converted back into network_secret mode"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
generated_config
|
||||||
|
.get_credential_file()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned()),
|
||||||
|
Some(credential_file)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
generated_config
|
||||||
|
.get_secure_mode()
|
||||||
|
.and_then(|mode| mode.local_private_key),
|
||||||
|
Some(credential_secret)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,16 @@ pub struct ForeignNetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ForeignNetworkManager {
|
impl ForeignNetworkManager {
|
||||||
|
async fn is_shared_pubkey_trusted(
|
||||||
|
entry: &ForeignNetworkEntry,
|
||||||
|
remote_static_pubkey: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
remote_static_pubkey.len() == 32
|
||||||
|
&& entry
|
||||||
|
.global_ctx
|
||||||
|
.is_pubkey_trusted(remote_static_pubkey, &entry.network.network_name)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
my_peer_id: PeerId,
|
my_peer_id: PeerId,
|
||||||
global_ctx: ArcGlobalCtx,
|
global_ctx: ArcGlobalCtx,
|
||||||
@@ -655,12 +665,14 @@ impl ForeignNetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
pub async fn add_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||||
tracing::info!(peer_conn = ?peer_conn.get_conn_info(), network = ?peer_conn.get_network_identity(), "add new peer conn in foreign network manager");
|
let conn_info = peer_conn.get_conn_info();
|
||||||
|
let peer_network = peer_conn.get_network_identity();
|
||||||
|
tracing::info!(peer_conn = ?conn_info, network = ?peer_network, "add new peer conn in foreign network manager");
|
||||||
|
|
||||||
let relay_peer_rpc = self.global_ctx.get_flags().relay_all_peer_rpc;
|
let relay_peer_rpc = self.global_ctx.get_flags().relay_all_peer_rpc;
|
||||||
let ret = self
|
let ret = self
|
||||||
.global_ctx
|
.global_ctx
|
||||||
.check_network_in_whitelist(&peer_conn.get_network_identity().network_name)
|
.check_network_in_whitelist(&peer_network.network_name)
|
||||||
.map_err(Into::into);
|
.map_err(Into::into);
|
||||||
if ret.is_err() && !relay_peer_rpc {
|
if ret.is_err() && !relay_peer_rpc {
|
||||||
return ret;
|
return ret;
|
||||||
@@ -669,7 +681,7 @@ impl ForeignNetworkManager {
|
|||||||
let (entry, new_added) = self
|
let (entry, new_added) = self
|
||||||
.data
|
.data
|
||||||
.get_or_insert_entry(
|
.get_or_insert_entry(
|
||||||
&peer_conn.get_network_identity(),
|
&peer_network,
|
||||||
peer_conn.get_my_peer_id(),
|
peer_conn.get_my_peer_id(),
|
||||||
peer_conn.get_peer_id(),
|
peer_conn.get_peer_id(),
|
||||||
ret.is_ok(),
|
ret.is_ok(),
|
||||||
@@ -679,10 +691,14 @@ impl ForeignNetworkManager {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let same_identity = entry.network == peer_network;
|
||||||
|
let shared_peer = peer_conn.get_peer_identity_type() == PeerIdentityType::SharedNode;
|
||||||
|
let shared_peer_trusted = shared_peer
|
||||||
|
&& Self::is_shared_pubkey_trusted(&entry, &conn_info.noise_remote_static_pubkey).await;
|
||||||
|
|
||||||
let _g = entry.lock.lock().await;
|
let _g = entry.lock.lock().await;
|
||||||
|
|
||||||
if (entry.network != peer_conn.get_network_identity()
|
if (!(same_identity || shared_peer_trusted))
|
||||||
&& peer_conn.get_peer_identity_type() != PeerIdentityType::SharedNode)
|
|
||||||
|| entry.my_peer_id != peer_conn.get_my_peer_id()
|
|| entry.my_peer_id != peer_conn.get_my_peer_id()
|
||||||
{
|
{
|
||||||
if new_added {
|
if new_added {
|
||||||
@@ -697,9 +713,11 @@ impl ForeignNetworkManager {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"network secret not match. exp: {:?} real: {:?}",
|
"foreign peer identity not trusted. exp: {:?} real: {:?}, remote_pubkey_len: {}, shared_trusted: {}",
|
||||||
entry.network,
|
entry.network,
|
||||||
peer_conn.get_network_identity()
|
peer_network,
|
||||||
|
conn_info.noise_remote_static_pubkey.len(),
|
||||||
|
shared_peer_trusted,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
tracing::error!(?err, "foreign network entry not match, disconnect peer");
|
tracing::error!(?err, "foreign network entry not match, disconnect peer");
|
||||||
|
|||||||
@@ -1441,9 +1441,9 @@ impl PeerConn {
|
|||||||
let info = self.info.as_ref().unwrap();
|
let info = self.info.as_ref().unwrap();
|
||||||
let mut ret = NetworkIdentity {
|
let mut ret = NetworkIdentity {
|
||||||
network_name: info.network_name.clone(),
|
network_name: info.network_name.clone(),
|
||||||
..Default::default()
|
network_secret: None,
|
||||||
|
network_secret_digest: Some([0u8; 32]),
|
||||||
};
|
};
|
||||||
ret.network_secret_digest = Some([0u8; 32]);
|
|
||||||
ret.network_secret_digest
|
ret.network_secret_digest
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1619,7 +1619,15 @@ pub mod tests {
|
|||||||
assert_eq!(c_peer.get_peer_id(), s_peer_id);
|
assert_eq!(c_peer.get_peer_id(), s_peer_id);
|
||||||
assert_eq!(s_peer.get_peer_id(), c_peer_id);
|
assert_eq!(s_peer.get_peer_id(), c_peer_id);
|
||||||
assert_eq!(c_peer.get_network_identity(), s_peer.get_network_identity());
|
assert_eq!(c_peer.get_network_identity(), s_peer.get_network_identity());
|
||||||
assert_eq!(c_peer.get_network_identity(), NetworkIdentity::default());
|
assert_eq!(
|
||||||
|
c_peer.get_network_identity().network_name,
|
||||||
|
NetworkIdentity::default().network_name
|
||||||
|
);
|
||||||
|
assert_eq!(c_peer.get_network_identity().network_secret, None);
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_network_identity().network_secret_digest,
|
||||||
|
NetworkIdentity::default().network_secret_digest
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ message GenerateCredentialRequest {
|
|||||||
repeated string allowed_proxy_cidrs = 3; // optional: restrict proxy_cidrs
|
repeated string allowed_proxy_cidrs = 3; // optional: restrict proxy_cidrs
|
||||||
int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid)
|
int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid)
|
||||||
optional string credential_id = 5; // optional: user-specified credential id, reused if already exists
|
optional string credential_id = 5; // optional: user-specified credential id, reused if already exists
|
||||||
|
InstanceIdentifier instance = 6; // target network instance
|
||||||
}
|
}
|
||||||
|
|
||||||
message GenerateCredentialResponse {
|
message GenerateCredentialResponse {
|
||||||
@@ -310,13 +311,16 @@ message GenerateCredentialResponse {
|
|||||||
|
|
||||||
message RevokeCredentialRequest {
|
message RevokeCredentialRequest {
|
||||||
string credential_id = 1;
|
string credential_id = 1;
|
||||||
|
InstanceIdentifier instance = 2; // target network instance
|
||||||
}
|
}
|
||||||
|
|
||||||
message RevokeCredentialResponse {
|
message RevokeCredentialResponse {
|
||||||
bool success = 1;
|
bool success = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListCredentialsRequest {}
|
message ListCredentialsRequest {
|
||||||
|
InstanceIdentifier instance = 1; // target network instance
|
||||||
|
}
|
||||||
|
|
||||||
message CredentialInfo {
|
message CredentialInfo {
|
||||||
string credential_id = 1; // UUID
|
string credential_id = 1; // UUID
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ message NetworkConfig {
|
|||||||
optional bool disable_tcp_hole_punching = 54;
|
optional bool disable_tcp_hole_punching = 54;
|
||||||
|
|
||||||
common.SecureModeConfig secure_mode = 55;
|
common.SecureModeConfig secure_mode = 55;
|
||||||
|
reserved 56;
|
||||||
|
optional string credential_file = 57;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PortForwardConfig {
|
message PortForwardConfig {
|
||||||
@@ -124,6 +126,7 @@ message NetworkMeta {
|
|||||||
common.UUID inst_id = 1;
|
common.UUID inst_id = 1;
|
||||||
string network_name = 2;
|
string network_name = 2;
|
||||||
uint32 config_permission = 3;
|
uint32 config_permission = 3;
|
||||||
|
string instance_name = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ValidateConfigRequest { NetworkConfig config = 1; }
|
message ValidateConfigRequest { NetworkConfig config = 1; }
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl CredentialManageRpc for CredentialManageRpcService {
|
|||||||
ctrl: Self::Controller,
|
ctrl: Self::Controller,
|
||||||
req: GenerateCredentialRequest,
|
req: GenerateCredentialRequest,
|
||||||
) -> crate::proto::rpc_types::error::Result<GenerateCredentialResponse> {
|
) -> crate::proto::rpc_types::error::Result<GenerateCredentialResponse> {
|
||||||
super::get_instance_service(&self.instance_manager, &None)?
|
super::get_instance_service(&self.instance_manager, &req.instance)?
|
||||||
.get_credential_manage_service()
|
.get_credential_manage_service()
|
||||||
.generate_credential(ctrl, req)
|
.generate_credential(ctrl, req)
|
||||||
.await
|
.await
|
||||||
@@ -43,7 +43,7 @@ impl CredentialManageRpc for CredentialManageRpcService {
|
|||||||
ctrl: Self::Controller,
|
ctrl: Self::Controller,
|
||||||
req: RevokeCredentialRequest,
|
req: RevokeCredentialRequest,
|
||||||
) -> crate::proto::rpc_types::error::Result<RevokeCredentialResponse> {
|
) -> crate::proto::rpc_types::error::Result<RevokeCredentialResponse> {
|
||||||
super::get_instance_service(&self.instance_manager, &None)?
|
super::get_instance_service(&self.instance_manager, &req.instance)?
|
||||||
.get_credential_manage_service()
|
.get_credential_manage_service()
|
||||||
.revoke_credential(ctrl, req)
|
.revoke_credential(ctrl, req)
|
||||||
.await
|
.await
|
||||||
@@ -54,7 +54,7 @@ impl CredentialManageRpc for CredentialManageRpcService {
|
|||||||
ctrl: Self::Controller,
|
ctrl: Self::Controller,
|
||||||
req: ListCredentialsRequest,
|
req: ListCredentialsRequest,
|
||||||
) -> crate::proto::rpc_types::error::Result<ListCredentialsResponse> {
|
) -> crate::proto::rpc_types::error::Result<ListCredentialsResponse> {
|
||||||
super::get_instance_service(&self.instance_manager, &None)?
|
super::get_instance_service(&self.instance_manager, &req.instance)?
|
||||||
.get_credential_manage_service()
|
.get_credential_manage_service()
|
||||||
.list_credentials(ctrl, req)
|
.list_credentials(ctrl, req)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -276,13 +276,17 @@ impl WebClientService for InstanceManageRpcService {
|
|||||||
let Some(control) = self.manager.get_instance_config_control(&inst_id) else {
|
let Some(control) = self.manager.get_instance_config_control(&inst_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(name) = self.manager.get_network_instance_name(&inst_id) else {
|
let Some(network_name) = self.manager.get_network_name(&inst_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(instance_name) = self.manager.get_instance_name(&inst_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let meta = NetworkMeta {
|
let meta = NetworkMeta {
|
||||||
inst_id: Some(inst_id.into()),
|
inst_id: Some(inst_id.into()),
|
||||||
network_name: name,
|
network_name,
|
||||||
config_permission: control.permission.into(),
|
config_permission: control.permission.into(),
|
||||||
|
instance_name,
|
||||||
};
|
};
|
||||||
metas.push(meta);
|
metas.push(meta);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,13 +50,6 @@ impl LoggerRpc for LoggerRpcService {
|
|||||||
) -> Result<SetLoggerConfigResponse, rpc_types::error::Error> {
|
) -> Result<SetLoggerConfigResponse, rpc_types::error::Error> {
|
||||||
let level_str = Self::log_level_to_string(request.level());
|
let level_str = Self::log_level_to_string(request.level());
|
||||||
|
|
||||||
// 更新当前日志级别
|
|
||||||
if let Some(current_level) = CURRENT_LOG_LEVEL.get() {
|
|
||||||
if let Ok(mut level) = current_level.lock() {
|
|
||||||
*level = level_str.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送新的日志级别到 logger 重载器
|
// 发送新的日志级别到 logger 重载器
|
||||||
if let Some(sender) = LOGGER_LEVEL_SENDER.get() {
|
if let Some(sender) = LOGGER_LEVEL_SENDER.get() {
|
||||||
if let Ok(sender) = sender.lock() {
|
if let Ok(sender) = sender.lock() {
|
||||||
@@ -78,6 +71,13 @@ impl LoggerRpc for LoggerRpcService {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新当前日志级别
|
||||||
|
if let Some(current_level) = CURRENT_LOG_LEVEL.get() {
|
||||||
|
if let Ok(mut level) = current_level.lock() {
|
||||||
|
*level = Self::log_level_to_string(request.level());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SetLoggerConfigResponse {})
|
Ok(SetLoggerConfigResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,12 +234,14 @@ where
|
|||||||
let config = self
|
let config = self
|
||||||
.handle_get_network_config(identify.clone(), instance_id)
|
.handle_get_network_config(identify.clone(), instance_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
let network_name = config.network_name.unwrap_or_default();
|
||||||
metas.insert(
|
metas.insert(
|
||||||
instance_id,
|
instance_id,
|
||||||
NetworkMeta {
|
NetworkMeta {
|
||||||
inst_id: Some(instance_id.into()),
|
inst_id: Some(instance_id.into()),
|
||||||
network_name: config.network_name.unwrap_or_default(),
|
network_name: network_name.clone(),
|
||||||
config_permission: 0,
|
config_permission: 0,
|
||||||
|
instance_name: network_name,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,28 @@ async fn create_credential_config(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: Create credential node config with a random, unknown key
|
||||||
|
fn create_unknown_credential_config(
|
||||||
|
network_name: String,
|
||||||
|
inst_name: &str,
|
||||||
|
ns: Option<&str>,
|
||||||
|
ipv4: &str,
|
||||||
|
ipv6: &str,
|
||||||
|
) -> TomlConfigLoader {
|
||||||
|
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name(inst_name.to_owned());
|
||||||
|
config.set_netns(ns.map(|s| s.to_owned()));
|
||||||
|
config.set_ipv4(Some(ipv4.parse().unwrap()));
|
||||||
|
config.set_ipv6(Some(ipv6.parse().unwrap()));
|
||||||
|
config.set_listeners(vec![]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(network_name));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&random_private)));
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: Create admin node config
|
/// Helper: Create admin node config
|
||||||
fn create_admin_config(
|
fn create_admin_config(
|
||||||
inst_name: &str,
|
inst_name: &str,
|
||||||
@@ -809,6 +831,113 @@ async fn credential_unknown_rejected() {
|
|||||||
drop_insts(vec![admin_inst, cred_inst]).await;
|
drop_insts(vec![admin_inst, cred_inst]).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test: an unknown credential must still be rejected when it first connects via a
|
||||||
|
/// shared node. If this fails, the shared path is incorrectly admitting the node into the target
|
||||||
|
/// network's route domain.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_unknown_via_shared_rejected() {
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
let admin_a_config =
|
||||||
|
create_admin_config("admin_a", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_a_inst = Instance::new(admin_a_config);
|
||||||
|
admin_a_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
let shared_b_config =
|
||||||
|
create_shared_config("shared_b", Some("ns_c1"), "10.144.144.2", "fd00::2/64");
|
||||||
|
let mut shared_b_inst = Instance::new(shared_b_config);
|
||||||
|
shared_b_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
let admin_c_config =
|
||||||
|
create_admin_config("admin_c", Some("ns_c3"), "10.144.144.4", "fd00::4/64");
|
||||||
|
let mut admin_c_inst = Instance::new(admin_c_config);
|
||||||
|
admin_c_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
admin_a_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.2:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
admin_c_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.2:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let admin_c_peer_id = admin_c_inst.peer_id();
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let a_routes = admin_a_inst.get_peer_manager().list_routes().await;
|
||||||
|
let c_routes = admin_c_inst.get_peer_manager().list_routes().await;
|
||||||
|
a_routes.iter().any(|r| r.peer_id == admin_c_peer_id)
|
||||||
|
|| c_routes.iter().any(|r| r.peer_id == admin_a_inst.peer_id())
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let unknown_config = create_unknown_credential_config(
|
||||||
|
admin_a_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
"unknown_d",
|
||||||
|
Some("ns_c2"),
|
||||||
|
"10.144.144.5",
|
||||||
|
"fd00::5/64",
|
||||||
|
);
|
||||||
|
let mut unknown_inst = Instance::new(unknown_config);
|
||||||
|
unknown_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
unknown_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.2:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let unknown_peer_id = unknown_inst.peer_id();
|
||||||
|
|
||||||
|
println!("unknown_peer_id: {:?}", unknown_peer_id);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
let admin_a_routes = admin_a_inst.get_peer_manager().list_routes().await;
|
||||||
|
let admin_c_routes = admin_c_inst.get_peer_manager().list_routes().await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!admin_a_routes.iter().any(|r| r.peer_id == unknown_peer_id),
|
||||||
|
"unknown credential unexpectedly appeared in admin_a routes via shared path: {:?}",
|
||||||
|
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!admin_c_routes.iter().any(|r| r.peer_id == unknown_peer_id),
|
||||||
|
"unknown credential unexpectedly appeared in admin_c routes via shared path: {:?}",
|
||||||
|
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ping_test("ns_adm", "10.144.144.5", None).await,
|
||||||
|
"admin_a unexpectedly reached unknown credential via shared path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ping_test("ns_c3", "10.144.144.5", None).await,
|
||||||
|
"admin_c unexpectedly reached unknown credential via shared path"
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("drop all");
|
||||||
|
|
||||||
|
drop_insts(vec![
|
||||||
|
admin_a_inst,
|
||||||
|
shared_b_inst,
|
||||||
|
admin_c_inst,
|
||||||
|
unknown_inst,
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest::rstest]
|
#[rstest::rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
|
|||||||
Executable
+269
@@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
|
|
||||||
|
CORE_BIN=${CORE_BIN:-"$REPO_ROOT/target/debug/easytier-core"}
|
||||||
|
CLI_BIN=${CLI_BIN:-"$REPO_ROOT/target/debug/easytier-cli"}
|
||||||
|
TMPDIR_PATH=""
|
||||||
|
CORE_PID=""
|
||||||
|
PYTHON_BIN=${PYTHON_BIN:-python3}
|
||||||
|
|
||||||
|
print_section() {
|
||||||
|
printf '\n==> %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_output() {
|
||||||
|
local title="$1"
|
||||||
|
local content="$2"
|
||||||
|
printf -- '---- %s ----\n' "$title"
|
||||||
|
printf '%s\n' "$content"
|
||||||
|
printf -- '---- end %s ----\n' "$title"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_binaries() {
|
||||||
|
print_section "Building easytier-core and easytier-cli"
|
||||||
|
cargo build -p easytier --bin easytier-core --bin easytier-cli
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_binaries() {
|
||||||
|
if [[ "${SKIP_BUILD:-0}" != "1" ]] || [[ ! -x "$CORE_BIN" ]] || [[ ! -x "$CLI_BIN" ]]; then
|
||||||
|
build_binaries
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
make_tmpdir() {
|
||||||
|
"$PYTHON_BIN" - <<'PY'
|
||||||
|
import tempfile
|
||||||
|
print(tempfile.mkdtemp(prefix="easytier-cli-e2e-"))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_tmpdir() {
|
||||||
|
TMPDIR_TO_DELETE="$1" "$PYTHON_BIN" - <<'PY'
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(os.environ["TMPDIR_TO_DELETE"], ignore_errors=True)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
alloc_port() {
|
||||||
|
"$PYTHON_BIN" - <<'PY'
|
||||||
|
import socket
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
print(sock.getsockname()[1])
|
||||||
|
sock.close()
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_cli() {
|
||||||
|
local rpc_port="$1"
|
||||||
|
local attempts=0
|
||||||
|
while (( attempts < 50 )); do
|
||||||
|
if "$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json node >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_cmd() {
|
||||||
|
local __var_name="$1"
|
||||||
|
local title="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
print_section "$title"
|
||||||
|
printf '+'
|
||||||
|
for arg in "$@"; do
|
||||||
|
printf ' %q' "$arg"
|
||||||
|
done
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
local output
|
||||||
|
if ! output=$("$@" 2>&1); then
|
||||||
|
print_output "$title output" "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_output "$title output" "$output"
|
||||||
|
printf -v "$__var_name" '%s' "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_text_output() {
|
||||||
|
local text_output="$1"
|
||||||
|
grep -F '== e2e-inst-a (' <<<"$text_output" >/dev/null
|
||||||
|
grep -F '== e2e-inst-b (' <<<"$text_output" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_multi_instance_json() {
|
||||||
|
local json_payload="$1"
|
||||||
|
JSON_PAYLOAD="$json_payload" "$PYTHON_BIN" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
data = json.loads(os.environ["JSON_PAYLOAD"])
|
||||||
|
assert isinstance(data, list), data
|
||||||
|
assert len(data) == 2, data
|
||||||
|
|
||||||
|
names = {item["instance_name"] for item in data}
|
||||||
|
assert names == {"e2e-inst-a", "e2e-inst-b"}, names
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
assert item["instance_id"], item
|
||||||
|
assert isinstance(item["result"], dict), item
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_instance_json() {
|
||||||
|
local json_payload="$1"
|
||||||
|
JSON_PAYLOAD="$json_payload" "$PYTHON_BIN" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
data = json.loads(os.environ["JSON_PAYLOAD"])
|
||||||
|
assert isinstance(data, dict), data
|
||||||
|
assert data["config"].find('instance_name = "e2e-inst-a"') >= 0, data["config"]
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_whitelist_fanout() {
|
||||||
|
local json_payload="$1"
|
||||||
|
JSON_PAYLOAD="$json_payload" "$PYTHON_BIN" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
data = json.loads(os.environ["JSON_PAYLOAD"])
|
||||||
|
assert len(data) == 2, data
|
||||||
|
for item in data:
|
||||||
|
assert item["result"]["tcp_ports"] == ["80", "443"], item
|
||||||
|
assert item["result"]["udp_ports"] == [], item
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_instance_write() {
|
||||||
|
local json_payload="$1"
|
||||||
|
JSON_PAYLOAD="$json_payload" "$PYTHON_BIN" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
data = {item["instance_name"]: item["result"] for item in json.loads(os.environ["JSON_PAYLOAD"])}
|
||||||
|
assert data["e2e-inst-a"]["tcp_ports"] == ["80", "443"], data
|
||||||
|
assert data["e2e-inst-b"]["tcp_ports"] == [], data
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
command -v "$PYTHON_BIN" >/dev/null 2>&1 || {
|
||||||
|
echo "python interpreter not found: $PYTHON_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_binaries
|
||||||
|
|
||||||
|
TMPDIR_PATH=$(make_tmpdir)
|
||||||
|
print_section "Created temporary test directory"
|
||||||
|
printf '%s\n' "$TMPDIR_PATH"
|
||||||
|
|
||||||
|
local rpc_port
|
||||||
|
rpc_port=$(alloc_port)
|
||||||
|
print_section "Allocated RPC port"
|
||||||
|
printf '%s\n' "$rpc_port"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$CORE_PID" ]] && kill -0 "$CORE_PID" >/dev/null 2>&1; then
|
||||||
|
kill "$CORE_PID" >/dev/null 2>&1 || true
|
||||||
|
wait "$CORE_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$TMPDIR_PATH" ]]; then
|
||||||
|
cleanup_tmpdir "$TMPDIR_PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cat >"$TMPDIR_PATH/inst-a.toml" <<'EOF'
|
||||||
|
instance_name = "e2e-inst-a"
|
||||||
|
listeners = []
|
||||||
|
|
||||||
|
[network_identity]
|
||||||
|
network_name = "e2e-net-a"
|
||||||
|
network_secret = ""
|
||||||
|
|
||||||
|
[flags]
|
||||||
|
no_tun = true
|
||||||
|
enable_ipv6 = false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"$TMPDIR_PATH/inst-b.toml" <<'EOF'
|
||||||
|
instance_name = "e2e-inst-b"
|
||||||
|
listeners = []
|
||||||
|
|
||||||
|
[network_identity]
|
||||||
|
network_name = "e2e-net-b"
|
||||||
|
network_secret = ""
|
||||||
|
|
||||||
|
[flags]
|
||||||
|
no_tun = true
|
||||||
|
enable_ipv6 = false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
"$CORE_BIN" --config-dir "$TMPDIR_PATH" --rpc-portal "127.0.0.1:${rpc_port}" \
|
||||||
|
>"$TMPDIR_PATH/core.log" 2>&1 &
|
||||||
|
CORE_PID=$!
|
||||||
|
print_section "Started easytier-core"
|
||||||
|
printf 'pid=%s\n' "$CORE_PID"
|
||||||
|
|
||||||
|
wait_for_cli "$rpc_port"
|
||||||
|
print_output "easytier-core startup log" "$(cat "$TMPDIR_PATH/core.log")"
|
||||||
|
|
||||||
|
local text_output
|
||||||
|
run_cmd text_output \
|
||||||
|
"Case 1: node fanout in table mode" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" node
|
||||||
|
assert_text_output "$text_output"
|
||||||
|
|
||||||
|
local json_output
|
||||||
|
run_cmd json_output \
|
||||||
|
"Case 2: node fanout in JSON mode" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json node
|
||||||
|
assert_multi_instance_json "$json_output"
|
||||||
|
|
||||||
|
local single_output
|
||||||
|
run_cmd single_output \
|
||||||
|
"Case 3: explicit instance selector stays single-instance" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" --instance-name e2e-inst-a -o json node
|
||||||
|
assert_single_instance_json "$single_output"
|
||||||
|
|
||||||
|
local set_whitelist_output
|
||||||
|
run_cmd set_whitelist_output \
|
||||||
|
"Case 4: whitelist set-tcp fans out to all instances" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" whitelist set-tcp 80,443
|
||||||
|
|
||||||
|
local whitelist_output
|
||||||
|
run_cmd whitelist_output \
|
||||||
|
"Case 5: whitelist show confirms fanout write" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json whitelist show
|
||||||
|
assert_whitelist_fanout "$whitelist_output"
|
||||||
|
|
||||||
|
local clear_whitelist_output
|
||||||
|
run_cmd clear_whitelist_output \
|
||||||
|
"Case 6: explicit selector write only touches one instance" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" --instance-name e2e-inst-b whitelist clear-tcp
|
||||||
|
|
||||||
|
local cleared_output
|
||||||
|
run_cmd cleared_output \
|
||||||
|
"Case 7: whitelist show confirms single-instance write isolation" \
|
||||||
|
"$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json whitelist show
|
||||||
|
assert_single_instance_write "$cleared_output"
|
||||||
|
|
||||||
|
print_section "Result"
|
||||||
|
echo "CLI multi-instance E2E passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user