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
+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),
)
}