mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-03 17:45:44 +00:00
Create easytier-core as the portable owner of configuration, connectivity, tunnels, peer and routing state, gateways, management, the data plane, and instance lifecycle. Keep operating-system integration, native protocol engines, process startup, and presentation in easytier behind explicit Host capability adapters. Create easytier-proto to own schemas, generated RPC types, descriptors, and feature-scoped protocol slices. Remove runtime protobuf reflection from core while preserving unknown route-peer fields across forwarding. Normalize instance construction through CoreInstance, CoreHostAdapters, CoreProcessRuntime, and InstanceManager. Make the runtime config store the only authoritative mutable configuration after startup. Move the portable TCP/UDP data plane into core and extract a generic OperationBroker for completion, cancellation, disposal, and capacity accounting. Expose the session-based FFI v2 completion API and keep the WASI guest ABI, wire schemas, and adapters with core. Migrate CLI, GUI, web, FFI, Android JNI, OHOS, uptime, and mobile consumers to the shared manager and core state. Add explicit user/web config ownership and revision-aware web reconciliation. Preserve configuration, wire, and management behavior while fixing regressions discovered by the full platform and integration matrix: - inherit advertised relay capabilities in foreign networks; - refresh OSPF peer state immediately after runtime config changes; - restore CLI GlobalCtx event output without forcing GUI logging; - retain legacy encryption names and standalone RPC tunnel metadata; - restore ICMP host composition and fragmented UDP handling; - use portable 64-bit atomics on 32-bit MIPS targets; and - retain discarded operations until late cancellation completes. Validate the refactor across 45 GitHub checks, including Linux, macOS, Windows, FreeBSD, web, GUI, Android, OHOS, feature profiles, and three-node and subnet-proxy integration tests. BREAKING CHANGE: internal Rust module paths are not preserved. Legacy native data-plane APIs are replaced by the session-based FFI v2 API. The dedicated Android data-plane wrapper is removed.
350 lines
11 KiB
Rust
350 lines
11 KiB
Rust
mod auth;
|
|
pub(crate) mod captcha;
|
|
mod network;
|
|
pub(crate) mod oidc;
|
|
mod rpc;
|
|
mod users;
|
|
|
|
use std::{net::SocketAddr, sync::Arc};
|
|
|
|
use axum::extract::Path;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use axum::middleware::{self as axum_mw, Next};
|
|
use axum::response::Response;
|
|
use axum::routing::{delete, post};
|
|
use axum::{Extension, Json, Router, extract::State, routing::get};
|
|
use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
|
|
use axum_login::{AuthManagerLayerBuilder, AuthUser, login_required};
|
|
use axum_messages::MessagesManagerLayer;
|
|
use easytier::common::config::{ConfigLoader, NetworkConfig, NetworkConfigExt, TomlConfigLoader};
|
|
use easytier::proto::rpc_types;
|
|
use network::NetworkApi;
|
|
use sea_orm::DbErr;
|
|
use tokio::net::TcpListener;
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
use tower_sessions::Expiry;
|
|
use tower_sessions::cookie::time::Duration;
|
|
use tower_sessions::cookie::{Key, SameSite};
|
|
use tower_sessions_sqlx_store::SqliteStore;
|
|
use users::{AuthSession, Backend};
|
|
|
|
use crate::FeatureFlags;
|
|
use crate::client_manager::ClientManager;
|
|
use crate::client_manager::storage::StorageToken;
|
|
use crate::db::{Db, UserIdInDb};
|
|
use crate::webhook::SharedWebhookConfig;
|
|
|
|
/// Embed assets for web dashboard, build frontend first
|
|
#[cfg(feature = "embed")]
|
|
#[derive(rust_embed::RustEmbed, Clone)]
|
|
#[folder = "frontend/dist/"]
|
|
struct Assets;
|
|
|
|
pub struct RestfulServer {
|
|
bind_addr: SocketAddr,
|
|
client_mgr: Arc<ClientManager>,
|
|
feature_flags: Arc<FeatureFlags>,
|
|
webhook_config: SharedWebhookConfig,
|
|
db: Db,
|
|
oidc_config: oidc::OidcConfig,
|
|
web_router: Option<Router>,
|
|
}
|
|
|
|
type AppStateInner = Arc<ClientManager>;
|
|
type AppState = State<AppStateInner>;
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct ListSessionJsonResp(Vec<StorageToken>);
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct GetSummaryJsonResp {
|
|
device_count: u32,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct GenerateConfigRequest {
|
|
config: NetworkConfig,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct GenerateConfigResponse {
|
|
error: Option<String>,
|
|
toml_config: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct ParseConfigRequest {
|
|
toml_config: String,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct ParseConfigResponse {
|
|
error: Option<String>,
|
|
config: Option<NetworkConfig>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
pub struct Error {
|
|
message: String,
|
|
}
|
|
type RpcError = rpc_types::error::Error;
|
|
type HttpHandleError = (StatusCode, Json<Error>);
|
|
|
|
pub fn other_error<T: ToString>(error_message: T) -> Error {
|
|
Error {
|
|
message: error_message.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn convert_db_error(e: DbErr) -> HttpHandleError {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
other_error(format!("DB Error: {:#}", e)).into(),
|
|
)
|
|
}
|
|
|
|
impl RestfulServer {
|
|
pub async fn new(
|
|
bind_addr: SocketAddr,
|
|
client_mgr: Arc<ClientManager>,
|
|
db: Db,
|
|
web_router: Option<Router>,
|
|
feature_flags: Arc<FeatureFlags>,
|
|
oidc_config: oidc::OidcConfig,
|
|
webhook_config: SharedWebhookConfig,
|
|
) -> anyhow::Result<Self> {
|
|
assert!(client_mgr.is_running());
|
|
|
|
Ok(RestfulServer {
|
|
bind_addr,
|
|
client_mgr,
|
|
feature_flags,
|
|
webhook_config,
|
|
db,
|
|
oidc_config,
|
|
web_router,
|
|
})
|
|
}
|
|
|
|
async fn handle_list_all_sessions(
|
|
auth_session: AuthSession,
|
|
State(client_mgr): AppState,
|
|
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
|
|
let Some(user) = auth_session.user else {
|
|
return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into()));
|
|
};
|
|
let ret = client_mgr.list_sessions_by_user_id(user.id()).await;
|
|
Ok(ListSessionJsonResp(ret).into())
|
|
}
|
|
|
|
async fn handle_get_summary(
|
|
auth_session: AuthSession,
|
|
State(client_mgr): AppState,
|
|
) -> Result<Json<GetSummaryJsonResp>, HttpHandleError> {
|
|
let Some(user) = auth_session.user else {
|
|
return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into()));
|
|
};
|
|
|
|
let machines = client_mgr.list_machine_by_user_id(user.id()).await;
|
|
|
|
Ok(GetSummaryJsonResp {
|
|
device_count: machines.len() as u32,
|
|
}
|
|
.into())
|
|
}
|
|
|
|
async fn handle_generate_config(
|
|
Json(req): Json<GenerateConfigRequest>,
|
|
) -> Result<Json<GenerateConfigResponse>, HttpHandleError> {
|
|
let config = req.config.gen_config();
|
|
match config {
|
|
Ok(c) => Ok(GenerateConfigResponse {
|
|
error: None,
|
|
toml_config: Some(c.dump()),
|
|
}
|
|
.into()),
|
|
Err(e) => Ok(GenerateConfigResponse {
|
|
error: Some(format!("{:?}", e)),
|
|
toml_config: None,
|
|
}
|
|
.into()),
|
|
}
|
|
}
|
|
|
|
async fn handle_parse_config(
|
|
Json(req): Json<ParseConfigRequest>,
|
|
) -> Result<Json<ParseConfigResponse>, HttpHandleError> {
|
|
let config = TomlConfigLoader::new_from_str(&req.toml_config)
|
|
.and_then(|config| NetworkConfig::new_from_config(&config));
|
|
match config {
|
|
Ok(c) => Ok(ParseConfigResponse {
|
|
error: None,
|
|
config: Some(c),
|
|
}
|
|
.into()),
|
|
Err(e) => Ok(ParseConfigResponse {
|
|
error: Some(format!("{:?}", e)),
|
|
config: None,
|
|
}
|
|
.into()),
|
|
}
|
|
}
|
|
|
|
#[allow(unused_mut)]
|
|
pub async fn start(
|
|
mut self,
|
|
) -> Result<
|
|
(
|
|
AbortOnDropHandle<()>,
|
|
AbortOnDropHandle<tower_sessions::session_store::Result<()>>,
|
|
),
|
|
anyhow::Error,
|
|
> {
|
|
let listener = TcpListener::bind(self.bind_addr).await?;
|
|
|
|
// Session layer.
|
|
//
|
|
// This uses `tower-sessions` to establish a layer that will provide the session
|
|
// as a request extension.
|
|
let session_store = SqliteStore::new(self.db.inner());
|
|
session_store.migrate().await?;
|
|
|
|
let delete_task = AbortOnDropHandle::new(tokio::task::spawn(
|
|
session_store
|
|
.clone()
|
|
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
|
|
));
|
|
|
|
// Generate a cryptographic key to sign the session cookie.
|
|
let key = Key::generate();
|
|
|
|
let session_layer = SessionManagerLayer::new(session_store)
|
|
.with_secure(false)
|
|
.with_same_site(SameSite::Lax)
|
|
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
|
.with_signed(key);
|
|
|
|
// Auth service.
|
|
//
|
|
// This combines the session layer with our backend to establish the auth
|
|
// service which will provide the auth session as a request extension.
|
|
let backend = Backend::new(self.db.clone());
|
|
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
|
|
let compression_layer = tower_http::compression::CompressionLayer::new()
|
|
.br(true)
|
|
.deflate(true)
|
|
.gzip(true)
|
|
.zstd(true)
|
|
.quality(tower_http::compression::CompressionLevel::Default);
|
|
|
|
// 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/users/:user-id/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())
|
|
.merge(rpc::router())
|
|
.route_layer(login_required!(Backend))
|
|
.merge(auth::router().layer(Extension(self.feature_flags.clone())))
|
|
.merge(oidc::router())
|
|
.with_state(self.client_mgr.clone())
|
|
.route(
|
|
"/api/v1/generate-config",
|
|
post(Self::handle_generate_config),
|
|
)
|
|
.route("/api/v1/parse-config", post(Self::handle_parse_config))
|
|
.layer(Extension(self.oidc_config.clone()))
|
|
.layer(MessagesManagerLayer)
|
|
.layer(auth_layer)
|
|
.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)
|
|
} else {
|
|
app
|
|
};
|
|
|
|
let serve_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
}));
|
|
|
|
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_all_sessions().await;
|
|
Ok(ListSessionJsonResp(ret).into())
|
|
}
|
|
|
|
async fn handle_disconnect_session_internal(
|
|
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
|
State(client_mgr): AppState,
|
|
) -> Result<StatusCode, HttpHandleError> {
|
|
if client_mgr
|
|
.disconnect_session_by_machine_id(user_id, &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(),
|
|
}
|
|
}
|