Supports customizing the API server address of the Web frontend through the --api-host parameter (#913)

This commit is contained in:
Mg Pig
2025-06-02 06:46:12 +08:00
committed by GitHub
parent 0a38a8ef4a
commit b469f8197a
7 changed files with 150 additions and 55 deletions
+1
View File
@@ -5,6 +5,7 @@
<link rel="icon" type="image/png" href="/easytier.png" /> <link rel="icon" type="image/png" href="/easytier.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EasyTier Dashboard</title> <title>EasyTier Dashboard</title>
<script src="/api_meta.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+11 -4
View File
@@ -1,10 +1,17 @@
const defaultApiHost = 'https://config-server.easytier.cn';
interface ApiHost { interface ApiHost {
value: string; value: string;
usedAt: number; usedAt: number;
} }
let apiMeta: {
api_host: string;
} | undefined = (window as any).apiMeta;
// remove trailing slashes from the URL
const cleanUrl = (url: string) => url.replace(/\/+$/, '');
const defaultApiHost = cleanUrl(apiMeta?.api_host ?? `${location.origin}${location.pathname}`);
const isValidHttpUrl = (s: string): boolean => { const isValidHttpUrl = (s: string): boolean => {
let url; let url;
@@ -45,7 +52,7 @@ const saveApiHost = (host: string) => {
} }
let hosts = cleanAndLoadApiHosts(); let hosts = cleanAndLoadApiHosts();
const newHost: ApiHost = {value: host, usedAt: Date.now()}; const newHost: ApiHost = { value: host, usedAt: Date.now() };
hosts = hosts.filter((h) => h.value !== host); hosts = hosts.filter((h) => h.value !== host);
hosts.push(newHost); hosts.push(newHost);
localStorage.setItem('apiHosts', JSON.stringify(hosts)); localStorage.setItem('apiHosts', JSON.stringify(hosts));
@@ -61,4 +68,4 @@ const getInitialApiHost = (): string => {
} }
}; };
export {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} export { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost }
+11
View File
@@ -3,9 +3,20 @@ import vue from '@vitejs/plugin-vue'
// import { viteSingleFile } from "vite-plugin-singlefile" // import { viteSingleFile } from "vite-plugin-singlefile"
const WEB_BASE_URL = process.env.WEB_BASE_URL || ''; const WEB_BASE_URL = process.env.WEB_BASE_URL || '';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:11211';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: WEB_BASE_URL, base: WEB_BASE_URL,
plugins: [vue(),/* viteSingleFile() */], plugins: [vue(),/* viteSingleFile() */],
server: {
proxy: {
"/api": {
target: API_BASE_URL,
},
"/api_meta.js": {
target: API_BASE_URL,
},
}
}
}) })
+4 -1
View File
@@ -27,4 +27,7 @@ cli:
zh-CN: "web dashboard 服务器的监听端口, 默认为与 api 服务器端口相同" zh-CN: "web dashboard 服务器的监听端口, 默认为与 api 服务器端口相同"
no_web: no_web:
en: "Do not run the web dashboard server" en: "Do not run the web dashboard server"
zh-CN: "不运行 web dashboard 服务器" zh-CN: "不运行 web dashboard 服务器"
api_host:
en: "The URL of the API server, used by the web frontend to connect to"
zh-CN: "API 服务器的 URL,用于 web 前端连接"
+42 -20
View File
@@ -12,7 +12,9 @@ use easytier::{
constants::EASYTIER_VERSION, constants::EASYTIER_VERSION,
error::Error, error::Error,
}, },
tunnel::{tcp::TcpTunnelListener, udp::UdpTunnelListener, websocket::WSTunnelListener, TunnelListener}, tunnel::{
tcp::TcpTunnelListener, udp::UdpTunnelListener, websocket::WSTunnelListener, TunnelListener,
},
utils::{init_logger, setup_panic_handler}, utils::{init_logger, setup_panic_handler},
}; };
@@ -89,6 +91,13 @@ struct Cli {
default_value = "false" default_value = "false"
)] )]
no_web: bool, no_web: bool,
#[cfg(feature = "embed")]
#[arg(
long,
help = t!("cli.api_host").to_string()
)]
api_host: Option<url::Url>,
} }
pub fn get_listener_by_url(l: &url::Url) -> Result<Box<dyn TunnelListener>, Error> { pub fn get_listener_by_url(l: &url::Url) -> Result<Box<dyn TunnelListener>, Error> {
@@ -137,36 +146,49 @@ async fn main() {
let mgr = Arc::new(mgr); let mgr = Arc::new(mgr);
#[cfg(feature = "embed")] #[cfg(feature = "embed")]
let restful_also_serve_web = !cli.no_web let (web_router_restful, web_router_static) = if cli.no_web {
&& (cli.web_server_port.is_none() || cli.web_server_port == Some(cli.api_server_port)); (None, None)
} else {
let web_router = web::build_router(cli.api_host.clone());
if cli.web_server_port.is_none() || cli.web_server_port == Some(cli.api_server_port) {
(Some(web_router), None)
} else {
(None, Some(web_router))
}
};
#[cfg(not(feature = "embed"))] #[cfg(not(feature = "embed"))]
let restful_also_serve_web = false; let web_router_restful = None;
let mut restful_server = restful::RestfulServer::new( let _restful_server_tasks = restful::RestfulServer::new(
format!("0.0.0.0:{}", cli.api_server_port).parse().unwrap(), format!("0.0.0.0:{}", cli.api_server_port).parse().unwrap(),
mgr.clone(), mgr.clone(),
db, db,
restful_also_serve_web, web_router_restful,
) )
.await .await
.unwrap()
.start()
.await
.unwrap(); .unwrap();
restful_server.start().await.unwrap();
#[cfg(feature = "embed")] #[cfg(feature = "embed")]
let mut web_server = web::WebServer::new( let _web_server_task = if let Some(web_router) = web_router_static {
format!("0.0.0.0:{}", cli.web_server_port.unwrap_or(0)) Some(
.parse() web::WebServer::new(
format!("0.0.0.0:{}", cli.web_server_port.unwrap_or(0))
.parse()
.unwrap(),
web_router,
)
.await
.unwrap()
.start()
.await
.unwrap(), .unwrap(),
) )
.await } else {
.unwrap(); None
};
#[cfg(feature = "embed")]
if !cli.no_web && !restful_also_serve_web {
web_server.start().await.unwrap();
}
tokio::signal::ctrl_c().await.unwrap(); tokio::signal::ctrl_c().await.unwrap();
} }
+24 -20
View File
@@ -39,12 +39,11 @@ pub struct RestfulServer {
client_mgr: Arc<ClientManager>, client_mgr: Arc<ClientManager>,
db: Db, db: Db,
serve_task: Option<ScopedTask<()>>, // serve_task: Option<ScopedTask<()>>,
delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>, // delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
network_api: NetworkApi, network_api: NetworkApi,
enable_web_embed: bool, web_router: Option<Router>,
} }
type AppStateInner = Arc<ClientManager>; type AppStateInner = Arc<ClientManager>;
@@ -94,7 +93,7 @@ impl RestfulServer {
bind_addr: SocketAddr, bind_addr: SocketAddr,
client_mgr: Arc<ClientManager>, client_mgr: Arc<ClientManager>,
db: Db, db: Db,
enable_web_embed: bool, web_router: Option<Router>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
assert!(client_mgr.is_running()); assert!(client_mgr.is_running());
@@ -104,10 +103,10 @@ impl RestfulServer {
bind_addr, bind_addr,
client_mgr, client_mgr,
db, db,
serve_task: None, // serve_task: None,
delete_task: None, // delete_task: None,
network_api, network_api,
enable_web_embed, web_router,
}) })
} }
@@ -159,7 +158,15 @@ impl RestfulServer {
} }
} }
pub async fn start(&mut self) -> Result<(), anyhow::Error> { pub async fn start(
mut self,
) -> Result<
(
ScopedTask<()>,
ScopedTask<tower_sessions::session_store::Result<()>>,
),
anyhow::Error,
> {
let listener = TcpListener::bind(self.bind_addr).await?; let listener = TcpListener::bind(self.bind_addr).await?;
// Session layer. // Session layer.
@@ -169,14 +176,13 @@ impl RestfulServer {
let session_store = SqliteStore::new(self.db.inner()); let session_store = SqliteStore::new(self.db.inner());
session_store.migrate().await?; session_store.migrate().await?;
self.delete_task.replace( let delete_task: ScopedTask<tower_sessions::session_store::Result<()>> =
tokio::task::spawn( tokio::task::spawn(
session_store session_store
.clone() .clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)), .continuously_delete_expired(tokio::time::Duration::from_secs(60)),
) )
.into(), .into();
);
// Generate a cryptographic key to sign the session cookie. // Generate a cryptographic key to sign the session cookie.
let key = Key::generate(); let key = Key::generate();
@@ -216,19 +222,17 @@ impl RestfulServer {
.layer(compression_layer); .layer(compression_layer);
#[cfg(feature = "embed")] #[cfg(feature = "embed")]
let app = if self.enable_web_embed { let app = if let Some(web_router) = self.web_router.take() {
use axum_embed::ServeEmbed; app.merge(web_router)
let service = ServeEmbed::<Assets>::new();
app.fallback_service(service)
} else { } else {
app app
}; };
let task = tokio::spawn(async move { let serve_task: ScopedTask<()> = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); })
self.serve_task = Some(task.into()); .into();
Ok(()) Ok((serve_task, delete_task))
} }
} }
+57 -10
View File
@@ -1,8 +1,13 @@
use axum::Router; use axum::{
extract::State,
http::header,
response::{IntoResponse, Response},
routing, Router,
};
use axum_embed::ServeEmbed;
use easytier::common::scoped_task::ScopedTask; use easytier::common::scoped_task::ScopedTask;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use std::net::SocketAddr; use std::net::SocketAddr;
use axum_embed::ServeEmbed;
use tokio::net::TcpListener; use tokio::net::TcpListener;
/// Embed assets for web dashboard, build frontend first /// Embed assets for web dashboard, build frontend first
@@ -10,30 +15,72 @@ use tokio::net::TcpListener;
#[folder = "frontend/dist/"] #[folder = "frontend/dist/"]
struct Assets; struct Assets;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ApiMetaResponse {
api_host: String,
}
async fn handle_api_meta(State(api_host): State<url::Url>) -> impl IntoResponse {
Response::builder()
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
.body(format!(
"window.apiMeta = {}",
serde_json::to_string(&ApiMetaResponse {
api_host: api_host.to_string()
})
.unwrap(),
))
.unwrap()
}
pub fn build_router(api_host: Option<url::Url>) -> Router {
let service = ServeEmbed::<Assets>::new();
let router = Router::new();
let router = if let Some(api_host) = api_host {
let sub_router = Router::new()
.route("/api_meta.js", routing::get(handle_api_meta))
.with_state(api_host);
router.merge(sub_router)
} else {
router
};
let router = router.fallback_service(service);
router
}
pub struct WebServer { pub struct WebServer {
bind_addr: SocketAddr, bind_addr: SocketAddr,
router: Router,
serve_task: Option<ScopedTask<()>>, serve_task: Option<ScopedTask<()>>,
} }
impl WebServer { impl WebServer {
pub async fn new(bind_addr: SocketAddr) -> anyhow::Result<Self> { pub async fn new(bind_addr: SocketAddr, router: Router) -> anyhow::Result<Self> {
Ok(WebServer { Ok(WebServer {
bind_addr, bind_addr,
router,
serve_task: None, serve_task: None,
}) })
} }
pub async fn start(&mut self) -> Result<(), anyhow::Error> { pub async fn start(self) -> Result<ScopedTask<()>, anyhow::Error> {
let listener = TcpListener::bind(self.bind_addr).await?; let listener = TcpListener::bind(self.bind_addr).await?;
let service = ServeEmbed::<Assets>::new(); let app = self.router;
let app = Router::new().fallback_service(service);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); })
.into();
self.serve_task = Some(task.into()); Ok(task)
Ok(())
} }
} }