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:
KKRainbow
2026-03-15 12:08:50 +08:00
committed by GitHub
parent c8f3c5d6aa
commit e6ac31fb20
27 changed files with 2678 additions and 980 deletions
+12 -1
View File
@@ -6,6 +6,14 @@ export enum NetworkingMethod {
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 {
instance_id: string
@@ -14,7 +22,9 @@ export interface NetworkConfig {
network_length: number
hostname?: string
network_name: string
network_secret: string
network_secret?: string
credential_file?: string
secure_mode?: SecureModeConfig
networking_method: NetworkingMethod
@@ -83,6 +93,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
network_length: 24,
network_name: 'easytier',
network_secret: '',
credential_file: '',
networking_method: NetworkingMethod.PublicServer,
+44 -1
View File
@@ -19,6 +19,7 @@ use maxminddb::geoip2;
use session::{Location, Session};
use storage::{Storage, StorageToken};
use crate::webhook::SharedWebhookConfig;
use crate::FeatureFlags;
use tokio::task::JoinSet;
@@ -59,12 +60,18 @@ pub struct ClientManager {
storage: Storage,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
}
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 sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
let mut tasks = JoinSet::new();
@@ -82,6 +89,7 @@ impl ClientManager {
client_sessions,
storage: Storage::new(db),
feature_flags,
webhook_config,
geoip_db: Arc::new(load_geoip_db(geoip_db)),
}
@@ -98,6 +106,7 @@ impl ClientManager {
let listeners_cnt = self.listeners_cnt.clone();
let geoip_db = self.geoip_db.clone();
let feature_flags = self.feature_flags.clone();
let webhook_config = self.webhook_config.clone();
self.tasks.spawn(async move {
while let Ok(tunnel) = listener.accept().await {
let (tunnel, secure) = match security::accept_or_upgrade_server_tunnel(tunnel).await {
@@ -121,6 +130,7 @@ impl ClientManager {
client_url.clone(),
location,
feature_flags.clone(),
webhook_config.clone(),
);
session.serve(tunnel).await;
sessions.insert(client_url, Arc::new(session));
@@ -165,6 +175,36 @@ impl ClientManager {
.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> {
self.storage.list_user_clients(user_id)
}
@@ -321,6 +361,9 @@ mod tests {
Db::memory_db().await,
None,
Arc::new(FeatureFlags::default()),
Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
mgr.add_listener(Box::new(listener)).await.unwrap();
+180 -18
View File
@@ -18,6 +18,7 @@ use easytier::{
use tokio::sync::{broadcast, RwLock};
use super::storage::{Storage, StorageToken, WeakRefStorage};
use crate::webhook::SharedWebhookConfig;
use crate::FeatureFlags;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -31,9 +32,11 @@ pub struct Location {
pub struct SessionData {
storage: WeakRefStorage,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
client_url: url::Url,
storage_token: Option<StorageToken>,
binding_version: Option<u64>,
notifier: broadcast::Sender<HeartbeatRequest>,
req: Option<HeartbeatRequest>,
location: Option<Location>,
@@ -45,14 +48,17 @@ impl SessionData {
client_url: url::Url,
location: Option<Location>,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
) -> Self {
let (tx, _rx1) = broadcast::channel(2);
SessionData {
storage,
feature_flags,
webhook_config,
client_url,
storage_token: None,
binding_version: None,
notifier: tx,
req: None,
location,
@@ -77,6 +83,23 @@ impl Drop for SessionData {
if let Ok(storage) = Storage::try_from(self.storage.clone()) {
if let Some(token) = self.storage_token.as_ref() {
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 {
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(
&self,
req: HeartbeatRequest,
@@ -106,28 +181,92 @@ impl SessionRpcService {
req.machine_id
))?;
let user_id = match storage
.db()
.get_user_id_by_token(req.user_token.clone())
.await
.with_context(|| {
format!(
"Failed to get user id by token from db: {:?}",
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
)
})? {
Some(id) => id,
None if data.feature_flags.allow_auto_create_user => storage
.auto_create_user(&req.user_token)
.await
.with_context(|| format!("Failed to auto-create user: {:?}", req.user_token))?,
None => {
return Err(
anyhow::anyhow!("User not found by token: {:?}", req.user_token).into(),
);
.into());
}
} else {
let user_id = match storage
.db()
.get_user_id_by_token(req.user_token.clone())
.await
.with_context(|| {
format!(
"Failed to get user id by token from db: {:?}",
req.user_token
)
})? {
Some(id) => id,
None if data.feature_flags.allow_auto_create_user => storage
.auto_create_user(&req.user_token)
.await
.with_context(|| format!("Failed to auto-create user: {:?}", req.user_token))?,
None => {
return Err(
anyhow::anyhow!("User not found by token: {:?}", req.user_token).into(),
);
}
};
(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() {
assert!(data.storage_token.is_none());
data.storage_token = Some(StorageToken {
@@ -136,6 +275,23 @@ impl SessionRpcService {
machine_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 {
@@ -203,8 +359,10 @@ impl Session {
client_url: url::Url,
location: Option<Location>,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
) -> 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 rpc_mgr =
@@ -335,6 +493,10 @@ impl Session {
self.rpc_mgr.is_running()
}
pub async fn stop(&self) {
self.rpc_mgr.stop().await;
}
pub fn data(&self) -> SharedSessionData {
self.data.clone()
}
+82 -12
View File
@@ -22,6 +22,7 @@ struct ClientInfo {
#[derive(Debug)]
pub struct StorageInner {
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
global_machine_map: DashMap<uuid::Uuid, ClientInfo>,
pub db: Db,
}
@@ -41,22 +42,19 @@ impl Storage {
pub fn new(db: Db) -> Self {
Storage(Arc::new(StorageInner {
user_clients_map: DashMap::new(),
global_machine_map: DashMap::new(),
db,
}))
}
fn remove_mid_to_client_info_map(
map: &DashMap<uuid::Uuid, ClientInfo>,
machine_id: &uuid::Uuid,
client_url: &url::Url,
) {
map.remove_if(machine_id, |_, v| v.storage_token.client_url == *client_url);
fn remove_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, stoken: &StorageToken) {
map.remove_if(&stoken.machine_id, |_, v| {
v.storage_token.client_url == stoken.client_url
&& v.storage_token.user_id == stoken.user_id
});
}
fn update_mid_to_client_info_map(
map: &DashMap<uuid::Uuid, ClientInfo>,
client_info: &ClientInfo,
) {
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
map.entry(client_info.storage_token.machine_id)
.and_modify(|e| {
if e.report_time < client_info.report_time {
@@ -78,14 +76,16 @@ impl Storage {
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) {
Self::remove_client_info_map(&self.0.global_machine_map, stoken);
self.0
.user_clients_map
.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()
});
}
@@ -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> {
self.0
.user_clients_map
@@ -129,3 +145,57 @@ impl Storage {
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);
}
}
+43 -2
View File
@@ -26,6 +26,7 @@ mod client_manager;
mod db;
mod migrator;
mod restful;
mod webhook;
#[cfg(feature = "embed")]
mod web;
@@ -132,6 +133,34 @@ struct Cli {
#[command(flatten)]
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)]
@@ -237,8 +266,19 @@ async fn main() {
// let db = db::Db::new(":memory:").await.unwrap();
let db = db::Db::new(cli.db).await.unwrap();
let feature_flags = Arc::new(cli.feature_flags);
let mut mgr =
client_manager::ClientManager::new(db.clone(), cli.geoip_db, feature_flags.clone());
let webhook_config = Arc::new(webhook::WebhookConfig::new(
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) =
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
.await
@@ -292,6 +332,7 @@ async fn main() {
web_router_restful,
feature_flags,
oidc_config,
webhook_config,
)
.await
.unwrap()
+86 -12
View File
@@ -7,8 +7,11 @@ mod users;
use std::{net::SocketAddr, sync::Arc};
use axum::http::StatusCode;
use axum::routing::post;
use axum::extract::Path;
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_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
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::ClientManager;
use crate::db::Db;
use crate::webhook::SharedWebhookConfig;
use crate::FeatureFlags;
/// Embed assets for web dashboard, build frontend first
@@ -41,12 +45,9 @@ pub struct RestfulServer {
bind_addr: SocketAddr,
client_mgr: Arc<ClientManager>,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
db: Db,
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>,
}
@@ -111,20 +112,17 @@ impl RestfulServer {
web_router: Option<Router>,
feature_flags: Arc<FeatureFlags>,
oidc_config: oidc::OidcConfig,
webhook_config: SharedWebhookConfig,
) -> anyhow::Result<Self> {
assert!(client_mgr.is_running());
// let network_api = NetworkApi::new();
Ok(RestfulServer {
bind_addr,
client_mgr,
feature_flags,
webhook_config,
db,
oidc_config,
// serve_task: None,
// delete_task: None,
// network_api,
web_router,
})
}
@@ -245,7 +243,31 @@ impl RestfulServer {
.zstd(true)
.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/sessions", get(Self::handle_list_all_sessions))
.merge(NetworkApi::build_route())
@@ -265,6 +287,10 @@ impl RestfulServer {
.layer(tower_http::cors::CorsLayer::very_permissive())
.layer(compression_layer);
if let Some(internal_routes) = internal_app {
app = app.merge(internal_routes);
}
#[cfg(feature = "embed")]
let app = if let Some(web_router) = self.web_router.take() {
app.merge(web_router)
@@ -279,4 +305,52 @@ impl RestfulServer {
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(),
}
}
+81
View File
@@ -295,6 +295,87 @@ impl NetworkApi {
.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> {
Router::new()
.route("/api/v1/machines", get(Self::handle_list_machines))
+66 -59
View File
@@ -5,6 +5,7 @@ use axum::{
Json, Router,
};
use axum_login::AuthUser as _;
use easytier::proto::rpc_types::controller::BaseController;
use super::{other_error, AppState, HttpHandleError};
@@ -19,34 +20,15 @@ macro_rules! match_service {
($factory:ty, $method_name:expr, $payload:expr, $session:expr) => {{
let client = $session.scoped_client::<$factory>();
client
.json_call_method(
easytier::proto::rpc_types::controller::BaseController::default(),
&$method_name,
$payload,
)
.json_call_method(BaseController::default(), &$method_name, $payload)
.await
}};
}
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>,
async fn handle_proxy_rpc_by_session(
session: &crate::client_manager::session::Session,
req: 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(),
))?;
let ProxyRpcRequest {
service_name,
method_name,
@@ -55,97 +37,79 @@ pub async fn handle_proxy_rpc(
let resp = match service_name.as_str() {
"api.manage.WebClientService" => match_service!(
easytier::proto::api::manage::WebClientServiceClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::manage::WebClientServiceClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PeerManageRpcService" => match_service!(
easytier::proto::api::instance::PeerManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::PeerManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PeerCenterManageRpcService" => match_service!(
easytier::proto::peer_rpc::PeerCenterRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.ConnectorManageRpcService" => match_service!(
easytier::proto::api::instance::ConnectorManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::ConnectorManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.MappedListenerManageRpcService" => match_service!(
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.VpnPortalRpcService" => match_service!(
easytier::proto::api::instance::VpnPortalRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::VpnPortalRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.TcpProxyRpcService" => match_service!(
easytier::proto::api::instance::TcpProxyRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.AclManageRpcService" => match_service!(
easytier::proto::api::instance::AclManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::AclManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PortForwardManageRpcService" => match_service!(
easytier::proto::api::instance::PortForwardManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::PortForwardManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.StatsRpcService" => match_service!(
easytier::proto::api::instance::StatsRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::StatsRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.CredentialManageRpcService" => match_service!(
easytier::proto::api::instance::CredentialManageRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::instance::CredentialManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.logger.LoggerRpcService" => match_service!(
easytier::proto::api::logger::LoggerRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::logger::LoggerRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.config.ConfigRpcService" => match_service!(
easytier::proto::api::config::ConfigRpcClientFactory<
easytier::proto::rpc_types::controller::BaseController,
>,
easytier::proto::api::config::ConfigRpcClientFactory<BaseController>,
method_name,
payload,
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> {
Router::new().route(
"/api/v1/machines/:machine-id/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),
)
}
+169
View File
@@ -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>;