goose remote access (#5251)
This commit is contained in:
@@ -39,7 +39,11 @@ reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "m
|
||||
tokio-util = "0.7.15"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
serde_path_to_error = "0.1.20"
|
||||
winreg = { version = "0.55.0", optional = true }
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
|
||||
url = "2.5.7"
|
||||
rand = "0.9.2"
|
||||
hex = "0.4.3"
|
||||
socket2 = "0.6.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = { version = "0.55.0" }
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn run() -> Result<()> {
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = crate::routes::configure(app_state, secret_key.clone())
|
||||
let app = crate::routes::configure(app_state.clone(), secret_key.clone())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
secret_key.clone(),
|
||||
check_token,
|
||||
@@ -59,6 +59,11 @@ pub async fn run() -> Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?;
|
||||
info!("listening on {}", listener.local_addr()?);
|
||||
|
||||
let tunnel_manager = app_state.tunnel_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
tunnel_manager.check_auto_start().await;
|
||||
});
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
pub mod auth;
|
||||
pub mod configuration;
|
||||
pub mod error;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
pub mod tunnel;
|
||||
|
||||
// Re-export commonly used items
|
||||
pub use openapi::*;
|
||||
|
||||
@@ -5,6 +5,7 @@ mod logging;
|
||||
mod openapi;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod tunnel;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use goose::config::paths::Paths;
|
||||
|
||||
@@ -391,6 +391,9 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::recipe::parse_recipe,
|
||||
super::routes::setup::start_openrouter_setup,
|
||||
super::routes::setup::start_tetrate_setup,
|
||||
super::routes::tunnel::start_tunnel,
|
||||
super::routes::tunnel::stop_tunnel,
|
||||
super::routes::tunnel::get_tunnel_status,
|
||||
),
|
||||
components(schemas(
|
||||
super::routes::config_management::UpsertConfigQuery,
|
||||
@@ -513,6 +516,8 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::agent::AddExtensionRequest,
|
||||
super::routes::agent::RemoveExtensionRequest,
|
||||
super::routes::setup::SetupResponse,
|
||||
super::tunnel::TunnelInfo,
|
||||
super::tunnel::TunnelState,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod setup;
|
||||
pub mod status;
|
||||
pub mod tunnel;
|
||||
pub mod utils;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -28,5 +29,6 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
|
||||
.merge(session::routes(state.clone()))
|
||||
.merge(schedule::routes(state.clone()))
|
||||
.merge(setup::routes(state.clone()))
|
||||
.merge(tunnel::routes(state.clone()))
|
||||
.merge(mcp_ui_proxy::routes(secret_key))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Start the tunnel
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/tunnel/start",
|
||||
responses(
|
||||
(status = 200, description = "Tunnel started successfully", body = TunnelInfo),
|
||||
(status = 400, description = "Bad request", body = ErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn start_tunnel(State(state): State<Arc<AppState>>) -> Response {
|
||||
match state.tunnel_manager.start().await {
|
||||
Ok(info) => (StatusCode::OK, Json(info)).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to start tunnel: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the tunnel
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/tunnel/stop",
|
||||
responses(
|
||||
(status = 200, description = "Tunnel stopped successfully"),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn stop_tunnel(State(state): State<Arc<AppState>>) -> Response {
|
||||
state.tunnel_manager.stop(true).await;
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
/// Get tunnel info
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/tunnel/status",
|
||||
responses(
|
||||
(status = 200, description = "Tunnel info", body = TunnelInfo)
|
||||
)
|
||||
)]
|
||||
pub async fn get_tunnel_status(State(state): State<Arc<AppState>>) -> Response {
|
||||
let info = state.tunnel_manager.get_info().await;
|
||||
(StatusCode::OK, Json(info)).into_response()
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/tunnel/start", post(start_tunnel))
|
||||
.route("/tunnel/stop", post(stop_tunnel))
|
||||
.route("/tunnel/status", get(get_tunnel_status))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::tunnel::TunnelManager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub(crate) agent_manager: Arc<AgentManager>,
|
||||
@@ -13,16 +16,20 @@ pub struct AppState {
|
||||
pub session_counter: Arc<AtomicUsize>,
|
||||
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
|
||||
recipe_session_tracker: Arc<Mutex<HashSet<String>>>,
|
||||
pub tunnel_manager: Arc<TunnelManager>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new() -> anyhow::Result<Arc<AppState>> {
|
||||
let agent_manager = AgentManager::instance().await?;
|
||||
let tunnel_manager = Arc::new(TunnelManager::new());
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
agent_manager,
|
||||
recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
session_counter: Arc::new(AtomicUsize::new(0)),
|
||||
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
|
||||
tunnel_manager,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
use super::TunnelInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use reqwest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socket2::{SockRef, TcpKeepalive};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{error, info, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Constant-time comparison using hash to prevent timing attacks
|
||||
fn secure_compare(a: &str, b: &str) -> bool {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher_a = DefaultHasher::new();
|
||||
a.hash(&mut hasher_a);
|
||||
let hash_a = hasher_a.finish();
|
||||
|
||||
let mut hasher_b = DefaultHasher::new();
|
||||
b.hash(&mut hasher_b);
|
||||
let hash_b = hasher_b.finish();
|
||||
|
||||
hash_a == hash_b
|
||||
}
|
||||
|
||||
const WORKER_URL: &str = "https://cloudflare-tunnel-proxy.michael-neale.workers.dev";
|
||||
const IDLE_TIMEOUT_SECS: u64 = 300;
|
||||
const CONNECTION_TIMEOUT_SECS: u64 = 30;
|
||||
const MAX_WS_SIZE: usize = 900_000;
|
||||
|
||||
fn get_worker_url() -> String {
|
||||
std::env::var("GOOSE_TUNNEL_WORKER_URL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| WORKER_URL.to_string())
|
||||
}
|
||||
|
||||
type WebSocketSender = Arc<
|
||||
RwLock<
|
||||
Option<
|
||||
futures::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TunnelMessage {
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: String,
|
||||
method: String,
|
||||
path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TunnelResponse {
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: String,
|
||||
status: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "chunkIndex")]
|
||||
chunk_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "totalChunks")]
|
||||
total_chunks: Option<usize>,
|
||||
#[serde(rename = "isChunked")]
|
||||
is_chunked: bool,
|
||||
#[serde(rename = "isStreaming")]
|
||||
is_streaming: bool,
|
||||
#[serde(rename = "isFirstChunk")]
|
||||
is_first_chunk: bool,
|
||||
#[serde(rename = "isLastChunk")]
|
||||
is_last_chunk: bool,
|
||||
}
|
||||
|
||||
fn validate_and_build_request(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
message: &TunnelMessage,
|
||||
tunnel_secret: &str,
|
||||
server_secret: &str,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
let incoming_secret = message
|
||||
.headers
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("x-secret-key"))
|
||||
.map(|(_, v)| v)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing tunnel secret header"))?;
|
||||
|
||||
if !secure_compare(incoming_secret, tunnel_secret) {
|
||||
anyhow::bail!("Invalid tunnel secret");
|
||||
}
|
||||
|
||||
let mut request_builder = match message.method.as_str() {
|
||||
"GET" => client.get(url),
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
"DELETE" => client.delete(url),
|
||||
"PATCH" => client.patch(url),
|
||||
_ => client.get(url),
|
||||
};
|
||||
|
||||
if let Some(headers) = &message.headers {
|
||||
for (key, value) in headers {
|
||||
if key.eq_ignore_ascii_case("x-secret-key") {
|
||||
continue;
|
||||
}
|
||||
request_builder = request_builder.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
request_builder = request_builder.header("X-Secret-Key", server_secret);
|
||||
|
||||
if let Some(body) = &message.body {
|
||||
if message.method != "GET" && message.method != "HEAD" {
|
||||
request_builder = request_builder.body(body.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(request_builder)
|
||||
}
|
||||
|
||||
async fn handle_streaming_response(
|
||||
response: reqwest::Response,
|
||||
status: u16,
|
||||
headers_map: HashMap<String, String>,
|
||||
request_id: String,
|
||||
message_path: String,
|
||||
ws_tx: WebSocketSender,
|
||||
) -> Result<()> {
|
||||
info!("← {} {} [{}] (streaming)", status, message_path, request_id);
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut chunk_index = 0;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
let chunk_str = String::from_utf8_lossy(&chunk).to_string();
|
||||
let tunnel_response = TunnelResponse {
|
||||
request_id: request_id.clone(),
|
||||
status,
|
||||
headers: if is_first_chunk {
|
||||
Some(headers_map.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
body: Some(chunk_str),
|
||||
error: None,
|
||||
chunk_index: Some(chunk_index),
|
||||
total_chunks: None,
|
||||
is_chunked: false,
|
||||
is_streaming: true,
|
||||
is_first_chunk,
|
||||
is_last_chunk: false,
|
||||
};
|
||||
send_response(ws_tx.clone(), tunnel_response).await?;
|
||||
chunk_index += 1;
|
||||
is_first_chunk = false;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading stream chunk: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tunnel_response = TunnelResponse {
|
||||
request_id: request_id.clone(),
|
||||
status,
|
||||
headers: None,
|
||||
body: Some(String::new()),
|
||||
error: None,
|
||||
chunk_index: Some(chunk_index),
|
||||
total_chunks: None,
|
||||
is_chunked: false,
|
||||
is_streaming: true,
|
||||
is_first_chunk: false,
|
||||
is_last_chunk: true,
|
||||
};
|
||||
send_response(ws_tx, tunnel_response).await?;
|
||||
info!(
|
||||
"← {} {} [{}] (complete, {} chunks)",
|
||||
status, message_path, request_id, chunk_index
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_chunked_response(
|
||||
body: String,
|
||||
status: u16,
|
||||
headers_map: HashMap<String, String>,
|
||||
request_id: String,
|
||||
message_path: String,
|
||||
ws_tx: WebSocketSender,
|
||||
) -> Result<()> {
|
||||
let total_chunks = body.len().div_ceil(MAX_WS_SIZE);
|
||||
info!(
|
||||
"← {} {} [{}] ({} bytes, {} chunks)",
|
||||
status,
|
||||
message_path,
|
||||
request_id,
|
||||
body.len(),
|
||||
total_chunks
|
||||
);
|
||||
|
||||
for (i, chunk) in body.as_bytes().chunks(MAX_WS_SIZE).enumerate() {
|
||||
let chunk_str = String::from_utf8_lossy(chunk).to_string();
|
||||
let tunnel_response = TunnelResponse {
|
||||
request_id: request_id.clone(),
|
||||
status,
|
||||
headers: if i == 0 {
|
||||
Some(headers_map.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
body: Some(chunk_str),
|
||||
error: None,
|
||||
chunk_index: Some(i),
|
||||
total_chunks: Some(total_chunks),
|
||||
is_chunked: true,
|
||||
is_streaming: false,
|
||||
is_first_chunk: false,
|
||||
is_last_chunk: false,
|
||||
};
|
||||
send_response(ws_tx.clone(), tunnel_response).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
message: TunnelMessage,
|
||||
port: u16,
|
||||
ws_tx: WebSocketSender,
|
||||
tunnel_secret: String,
|
||||
server_secret: String,
|
||||
) -> Result<()> {
|
||||
let request_id = message.request_id.clone();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("http://127.0.0.1:{}{}", port, message.path);
|
||||
|
||||
let request_builder =
|
||||
match validate_and_build_request(&client, &url, &message, &tunnel_secret, &server_secret) {
|
||||
Ok(builder) => builder,
|
||||
Err(e) => {
|
||||
error!("✗ Authentication error [{}]: {}", request_id, e);
|
||||
let error_response = TunnelResponse {
|
||||
request_id,
|
||||
status: 401,
|
||||
headers: None,
|
||||
body: None,
|
||||
error: Some(e.to_string()),
|
||||
chunk_index: None,
|
||||
total_chunks: None,
|
||||
is_chunked: false,
|
||||
is_streaming: false,
|
||||
is_first_chunk: false,
|
||||
is_last_chunk: false,
|
||||
};
|
||||
send_response(ws_tx, error_response).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let response = match request_builder.send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
error!("✗ Request error [{}]: {}", request_id, e);
|
||||
let error_response = TunnelResponse {
|
||||
request_id,
|
||||
status: 500,
|
||||
headers: None,
|
||||
body: None,
|
||||
error: Some(e.to_string()),
|
||||
chunk_index: None,
|
||||
total_chunks: None,
|
||||
is_chunked: false,
|
||||
is_streaming: false,
|
||||
is_first_chunk: false,
|
||||
is_last_chunk: false,
|
||||
};
|
||||
send_response(ws_tx, error_response).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status().as_u16();
|
||||
// Normalize header names to lowercase per RFC 7230 (HTTP headers are case-insensitive)
|
||||
let headers_map: HashMap<String, String> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.as_str().to_lowercase(),
|
||||
v.to_str().unwrap_or("").to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let is_streaming = headers_map
|
||||
.get("content-type")
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_streaming {
|
||||
handle_streaming_response(
|
||||
response,
|
||||
status,
|
||||
headers_map,
|
||||
request_id,
|
||||
message.path,
|
||||
ws_tx,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if body.len() > MAX_WS_SIZE {
|
||||
handle_chunked_response(body, status, headers_map, request_id, message.path, ws_tx)
|
||||
.await?;
|
||||
} else {
|
||||
let tunnel_response = TunnelResponse {
|
||||
request_id: request_id.clone(),
|
||||
status,
|
||||
headers: Some(headers_map),
|
||||
body: Some(body),
|
||||
error: None,
|
||||
chunk_index: None,
|
||||
total_chunks: None,
|
||||
is_chunked: false,
|
||||
is_streaming: false,
|
||||
is_first_chunk: false,
|
||||
is_last_chunk: false,
|
||||
};
|
||||
send_response(ws_tx, tunnel_response).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_response(ws_tx: WebSocketSender, response: TunnelResponse) -> Result<()> {
|
||||
let json = serde_json::to_string(&response)?;
|
||||
if let Some(tx) = ws_tx.write().await.as_mut() {
|
||||
tx.send(Message::Text(json.into()))
|
||||
.await
|
||||
.context("Failed to send response")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_tcp_keepalive(
|
||||
stream: &tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
) {
|
||||
let tcp_stream = stream.get_ref().get_ref();
|
||||
let socket_ref = SockRef::from(tcp_stream);
|
||||
|
||||
let keepalive = TcpKeepalive::new()
|
||||
.with_time(Duration::from_secs(30))
|
||||
.with_interval(Duration::from_secs(30));
|
||||
|
||||
if let Err(e) = socket_ref.set_tcp_keepalive(&keepalive) {
|
||||
warn!("Failed to set TCP keep-alive: {}", e);
|
||||
} else {
|
||||
info!("✓ TCP keep-alive enabled (30s interval)");
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_websocket_messages(
|
||||
mut read: futures::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
>,
|
||||
ws_tx: WebSocketSender,
|
||||
port: u16,
|
||||
tunnel_secret: String,
|
||||
server_secret: String,
|
||||
last_activity: Arc<RwLock<Instant>>,
|
||||
active_tasks: Arc<RwLock<Vec<JoinHandle<()>>>>,
|
||||
) {
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
*last_activity.write().await = Instant::now();
|
||||
|
||||
match serde_json::from_str::<TunnelMessage>(&text) {
|
||||
Ok(tunnel_msg) => {
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let tunnel_secret_clone = tunnel_secret.clone();
|
||||
let server_secret_clone = server_secret.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = handle_request(
|
||||
tunnel_msg,
|
||||
port,
|
||||
ws_tx_clone,
|
||||
tunnel_secret_clone,
|
||||
server_secret_clone,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error handling request: {}", e);
|
||||
}
|
||||
});
|
||||
{
|
||||
let mut tasks = active_tasks.write().await;
|
||||
tasks.retain(|t| !t.is_finished());
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error parsing tunnel message: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("✗ Connection closed by server");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {
|
||||
*last_activity.write().await = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("✗ WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_connection(
|
||||
ws_tx: WebSocketSender,
|
||||
active_tasks: Arc<RwLock<Vec<JoinHandle<()>>>>,
|
||||
) {
|
||||
if let Some(mut tx) = ws_tx.write().await.take() {
|
||||
let _ = tx.close().await;
|
||||
}
|
||||
|
||||
let tasks = active_tasks.write().await.drain(..).collect::<Vec<_>>();
|
||||
info!("Aborting {} active request tasks", tasks.len());
|
||||
for task in tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_single_connection(
|
||||
port: u16,
|
||||
agent_id: String,
|
||||
tunnel_secret: String,
|
||||
server_secret: String,
|
||||
restart_tx: mpsc::Sender<()>,
|
||||
) {
|
||||
let worker_url = get_worker_url();
|
||||
let ws_url = worker_url
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://");
|
||||
|
||||
let url = format!("{}/connect?agent_id={}", ws_url, agent_id);
|
||||
|
||||
info!("Connecting to {}...", url);
|
||||
|
||||
let ws_stream = match tokio::time::timeout(
|
||||
Duration::from_secs(CONNECTION_TIMEOUT_SECS),
|
||||
connect_async(url.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok((stream, _))) => {
|
||||
configure_tcp_keepalive(&stream);
|
||||
stream
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("✗ WebSocket connection error: {}", e);
|
||||
let _ = restart_tx.send(()).await;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
error!(
|
||||
"✗ WebSocket connection timeout after {}s",
|
||||
CONNECTION_TIMEOUT_SECS
|
||||
);
|
||||
let _ = restart_tx.send(()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("✓ Connected as agent: {}", agent_id);
|
||||
info!("✓ Proxying to: http://127.0.0.1:{}", port);
|
||||
let public_url = format!("{}/tunnel/{}", worker_url, agent_id);
|
||||
info!("✓ Public URL: {}", public_url);
|
||||
|
||||
let (write, read) = ws_stream.split();
|
||||
let ws_tx: WebSocketSender = Arc::new(RwLock::new(Some(write)));
|
||||
let last_activity = Arc::new(RwLock::new(Instant::now()));
|
||||
let active_tasks: Arc<RwLock<Vec<JoinHandle<()>>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
|
||||
let last_activity_clone = last_activity.clone();
|
||||
let idle_task = async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
let elapsed = last_activity_clone.read().await.elapsed();
|
||||
if elapsed > Duration::from_secs(IDLE_TIMEOUT_SECS) {
|
||||
warn!(
|
||||
"No activity for {} minutes, forcing reconnect",
|
||||
IDLE_TIMEOUT_SECS / 60
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = idle_task => {
|
||||
info!("✗ Idle timeout triggered");
|
||||
}
|
||||
_ = handle_websocket_messages(
|
||||
read,
|
||||
ws_tx.clone(),
|
||||
port,
|
||||
tunnel_secret.clone(),
|
||||
server_secret.clone(),
|
||||
last_activity,
|
||||
active_tasks.clone()
|
||||
) => {
|
||||
info!("✗ Connection ended");
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_connection(ws_tx, active_tasks).await;
|
||||
|
||||
let _ = restart_tx.send(()).await;
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
port: u16,
|
||||
tunnel_secret: String,
|
||||
server_secret: String,
|
||||
agent_id: String,
|
||||
handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
restart_tx: mpsc::Sender<()>,
|
||||
) -> Result<TunnelInfo> {
|
||||
let worker_url = get_worker_url();
|
||||
|
||||
let agent_id_clone = agent_id.clone();
|
||||
let tunnel_secret_clone = tunnel_secret.clone();
|
||||
let server_secret_clone = server_secret;
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
run_single_connection(
|
||||
port,
|
||||
agent_id_clone,
|
||||
tunnel_secret_clone,
|
||||
server_secret_clone,
|
||||
restart_tx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
*handle.write().await = Some(task);
|
||||
|
||||
let public_url = format!("{}/tunnel/{}", worker_url, agent_id);
|
||||
let hostname = Url::parse(&worker_url)?
|
||||
.host_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(TunnelInfo {
|
||||
state: super::TunnelState::Running,
|
||||
url: public_url,
|
||||
hostname,
|
||||
secret: tunnel_secret,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>) {
|
||||
if let Some(task) = handle.write().await.take() {
|
||||
task.abort();
|
||||
info!("Lapstone tunnel stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Integration tests for the Lapstone HTTP tunnel
|
||||
//!
|
||||
//! These tests verify the full tunnel flow:
|
||||
//! 1. Start a local HTTP server
|
||||
//! 2. Start the tunnel (connects to real Cloudflare worker via WebSocket)
|
||||
//! 3. Make requests to the public HTTPS URL
|
||||
//! 4. Verify they proxy through to the local server
|
||||
|
||||
use super::lapstone;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
const TEST_TUNNEL_SECRET: &str = "test-tunnel-secret-12345";
|
||||
const TEST_SERVER_SECRET: &str = "test-server-secret-67890";
|
||||
|
||||
async fn find_available_port() -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("Failed to bind to port 0");
|
||||
let addr = listener.local_addr().expect("Failed to get local address");
|
||||
addr.port()
|
||||
}
|
||||
|
||||
async fn health_handler() -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"message": "Test server is running"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn echo_handler(req: Request) -> Json<Value> {
|
||||
let headers: Vec<(String, String)> = req
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
|
||||
let body = axum::body::to_bytes(req.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let body_str = String::from_utf8_lossy(&body).to_string();
|
||||
|
||||
Json(json!({
|
||||
"headers": headers,
|
||||
"body": body_str
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_test_server() -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health_handler))
|
||||
.route("/echo", post(echo_handler))
|
||||
}
|
||||
|
||||
async fn start_test_http_server(port: u16) -> tokio::task::JoinHandle<()> {
|
||||
let app = create_test_server();
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tunnel_end_to_end() {
|
||||
let port = find_available_port().await;
|
||||
let server_handle = start_test_http_server(port).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
let handle = Arc::new(RwLock::new(None));
|
||||
let (restart_tx, _restart_rx) = mpsc::channel(1);
|
||||
|
||||
let tunnel_secret = TEST_TUNNEL_SECRET.to_string();
|
||||
let server_secret = TEST_SERVER_SECRET.to_string();
|
||||
let agent_id = super::generate_agent_id();
|
||||
|
||||
let tunnel_info = lapstone::start(
|
||||
port,
|
||||
tunnel_secret.clone(),
|
||||
server_secret.clone(),
|
||||
agent_id.clone(),
|
||||
handle.clone(),
|
||||
restart_tx,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start tunnel");
|
||||
|
||||
let public_url = &tunnel_info.url;
|
||||
println!("Tunnel public URL: {}", public_url);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/health", public_url))
|
||||
.header("X-Secret-Key", &tunnel_secret)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to make request to public URL");
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"Response status: {}",
|
||||
response.status()
|
||||
);
|
||||
let body: Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert_eq!(body["message"], "Test server is running");
|
||||
|
||||
lapstone::stop(handle).await;
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tunnel_post_request() {
|
||||
let port = find_available_port().await;
|
||||
let server_handle = start_test_http_server(port).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
let handle = Arc::new(RwLock::new(None));
|
||||
let (restart_tx, _restart_rx) = mpsc::channel(1);
|
||||
|
||||
let tunnel_secret = TEST_TUNNEL_SECRET.to_string();
|
||||
let server_secret = TEST_SERVER_SECRET.to_string();
|
||||
let agent_id = super::generate_agent_id();
|
||||
|
||||
let tunnel_info = lapstone::start(
|
||||
port,
|
||||
tunnel_secret.clone(),
|
||||
server_secret.clone(),
|
||||
agent_id.clone(),
|
||||
handle.clone(),
|
||||
restart_tx,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start tunnel");
|
||||
|
||||
let public_url = &tunnel_info.url;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let test_body = json!({"test": "data", "number": 42});
|
||||
let response = client
|
||||
.post(format!("{}/echo", public_url))
|
||||
.header("X-Secret-Key", &tunnel_secret)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&test_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to make POST request");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
let body: Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert!(body["body"].as_str().unwrap().contains("test"));
|
||||
assert!(body["body"].as_str().unwrap().contains("data"));
|
||||
assert!(body["body"].as_str().unwrap().contains("42"));
|
||||
|
||||
lapstone::stop(handle).await;
|
||||
server_handle.abort();
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
pub mod lapstone;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lapstone_test;
|
||||
|
||||
use crate::configuration::Settings;
|
||||
use goose::config::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
fn get_server_port() -> anyhow::Result<u16> {
|
||||
let settings = Settings::new()?;
|
||||
Ok(settings.port)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TunnelState {
|
||||
#[default]
|
||||
Idle,
|
||||
Starting,
|
||||
Running,
|
||||
Error,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TunnelInfo {
|
||||
pub state: TunnelState,
|
||||
pub url: String,
|
||||
pub hostname: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
pub struct TunnelManager {
|
||||
state: Arc<RwLock<TunnelState>>,
|
||||
info: Arc<RwLock<Option<TunnelInfo>>>,
|
||||
lapstone_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
restart_tx: Arc<RwLock<Option<mpsc::Sender<()>>>>,
|
||||
watchdog_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl Default for TunnelManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelManager {
|
||||
pub fn new() -> Self {
|
||||
TunnelManager {
|
||||
state: Arc::new(RwLock::new(TunnelState::Idle)),
|
||||
info: Arc::new(RwLock::new(None)),
|
||||
lapstone_handle: Arc::new(RwLock::new(None)),
|
||||
restart_tx: Arc::new(RwLock::new(None)),
|
||||
watchdog_handle: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_auto_start() -> bool {
|
||||
Config::global()
|
||||
.get_param("tunnel_auto_start")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_secret() -> Option<String> {
|
||||
Config::global().get_secret("tunnel_secret").ok()
|
||||
}
|
||||
|
||||
fn get_agent_id() -> Option<String> {
|
||||
Config::global().get_secret("tunnel_agent_id").ok()
|
||||
}
|
||||
|
||||
pub async fn check_auto_start(&self) {
|
||||
let auto_start = Self::get_auto_start();
|
||||
let state = self.state.read().await.clone();
|
||||
|
||||
if auto_start && state == TunnelState::Idle {
|
||||
tracing::info!("Auto-starting tunnel");
|
||||
match self.start().await {
|
||||
Ok(info) => {
|
||||
tracing::info!("Tunnel auto-started successfully: {:?}", info.url);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to auto-start tunnel: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tunnel_disabled() -> bool {
|
||||
if let Ok(val) = std::env::var("GOOSE_TUNNEL") {
|
||||
let val = val.to_lowercase();
|
||||
val == "no" || val == "none"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_info(&self) -> TunnelInfo {
|
||||
if Self::is_tunnel_disabled() {
|
||||
return TunnelInfo {
|
||||
state: TunnelState::Disabled,
|
||||
url: String::new(),
|
||||
hostname: String::new(),
|
||||
secret: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let state = self.state.read().await.clone();
|
||||
let info = self.info.read().await.clone();
|
||||
|
||||
match info {
|
||||
Some(mut tunnel_info) => {
|
||||
tunnel_info.state = state;
|
||||
tunnel_info
|
||||
}
|
||||
None => TunnelInfo {
|
||||
state,
|
||||
url: String::new(),
|
||||
hostname: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_auto_start(auto_start: bool) -> anyhow::Result<()> {
|
||||
Config::global()
|
||||
.set_param("tunnel_auto_start", auto_start)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save tunnel config: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_secret(secret: &str) -> anyhow::Result<()> {
|
||||
Config::global()
|
||||
.set_secret("tunnel_secret", &secret.to_string())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save tunnel secret: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_agent_id(agent_id: &str) -> anyhow::Result<()> {
|
||||
Config::global()
|
||||
.set_secret("tunnel_agent_id", &agent_id.to_string())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save tunnel agent_id: {}", e))
|
||||
}
|
||||
|
||||
async fn start_tunnel_internal(&self) -> anyhow::Result<(TunnelInfo, mpsc::Receiver<()>)> {
|
||||
let server_port = get_server_port()?;
|
||||
let tunnel_secret = Self::get_secret().unwrap_or_else(generate_secret);
|
||||
let server_secret =
|
||||
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());
|
||||
let agent_id = Self::get_agent_id().unwrap_or_else(generate_agent_id);
|
||||
|
||||
Self::set_secret(&tunnel_secret)?;
|
||||
Self::set_agent_id(&agent_id)?;
|
||||
|
||||
let (restart_tx, restart_rx) = mpsc::channel::<()>(1);
|
||||
*self.restart_tx.write().await = Some(restart_tx.clone());
|
||||
|
||||
let result = lapstone::start(
|
||||
server_port,
|
||||
tunnel_secret,
|
||||
server_secret,
|
||||
agent_id,
|
||||
self.lapstone_handle.clone(),
|
||||
restart_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(info) => Ok((info, restart_rx)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> anyhow::Result<TunnelInfo> {
|
||||
if Self::is_tunnel_disabled() {
|
||||
anyhow::bail!("Tunnel is disabled via GOOSE_TUNNEL environment variable");
|
||||
}
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
if *state != TunnelState::Idle {
|
||||
anyhow::bail!("Tunnel is already running or starting");
|
||||
}
|
||||
*state = TunnelState::Starting;
|
||||
drop(state);
|
||||
|
||||
match self.start_tunnel_internal().await {
|
||||
Ok((info, mut restart_rx)) => {
|
||||
*self.state.write().await = TunnelState::Running;
|
||||
*self.info.write().await = Some(info.clone());
|
||||
let _ = Self::set_auto_start(true);
|
||||
|
||||
let state = self.state.clone();
|
||||
let lapstone_handle = self.lapstone_handle.clone();
|
||||
let watchdog_handle_arc = self.watchdog_handle.clone();
|
||||
let manager = Arc::new(self.clone_for_watchdog());
|
||||
|
||||
let watchdog = tokio::spawn(async move {
|
||||
while restart_rx.recv().await.is_some() {
|
||||
let auto_start = Self::get_auto_start();
|
||||
if !auto_start {
|
||||
tracing::info!("Tunnel connection lost but auto_start is disabled");
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::warn!("Tunnel connection lost, initiating restart...");
|
||||
lapstone::stop(lapstone_handle.clone()).await;
|
||||
*state.write().await = TunnelState::Idle;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
*state.write().await = TunnelState::Starting;
|
||||
|
||||
match manager.start_tunnel_internal().await {
|
||||
Ok((_, new_restart_rx)) => {
|
||||
*state.write().await = TunnelState::Running;
|
||||
tracing::info!("Tunnel restarted successfully");
|
||||
restart_rx = new_restart_rx;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to restart tunnel: {}", e);
|
||||
*state.write().await = TunnelState::Error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*watchdog_handle_arc.write().await = Some(watchdog);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
Err(e) => {
|
||||
*self.state.write().await = TunnelState::Error;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_for_watchdog(&self) -> Self {
|
||||
TunnelManager {
|
||||
state: self.state.clone(),
|
||||
info: self.info.clone(),
|
||||
lapstone_handle: self.lapstone_handle.clone(),
|
||||
restart_tx: self.restart_tx.clone(),
|
||||
watchdog_handle: self.watchdog_handle.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self, clear_auto_start: bool) {
|
||||
if let Some(handle) = self.watchdog_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
*self.restart_tx.write().await = None;
|
||||
|
||||
lapstone::stop(self.lapstone_handle.clone()).await;
|
||||
|
||||
*self.state.write().await = TunnelState::Idle;
|
||||
*self.info.write().await = None;
|
||||
|
||||
if clear_auto_start {
|
||||
let _ = Self::set_auto_start(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_secret() -> String {
|
||||
let bytes: [u8; 32] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn generate_agent_id() -> String {
|
||||
let bytes: [u8; 32] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
Reference in New Issue
Block a user