feat: self-signed HTTPS for goosed server (#7126)
This commit is contained in:
Generated
+60
-3
@@ -178,6 +178,15 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -339,9 +348,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-rs"
|
name = "aws-lc-rs"
|
||||||
version = "1.15.4"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"untrusted 0.7.1",
|
"untrusted 0.7.1",
|
||||||
@@ -837,6 +846,28 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-server"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"bytes",
|
||||||
|
"either",
|
||||||
|
"fs-err",
|
||||||
|
"http 1.4.0",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"hyper 1.8.1",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustls 0.23.36",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls 0.26.4",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "az"
|
name = "az"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -3725,6 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
|
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4375,7 +4407,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "goose-acp-macros"
|
name = "goose-acp-macros"
|
||||||
version = "1.24.0"
|
version = "1.25.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4490,7 +4522,9 @@ name = "goose-server"
|
|||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"aws-lc-rs",
|
||||||
"axum 0.8.8",
|
"axum 0.8.8",
|
||||||
|
"axum-server",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4506,6 +4540,7 @@ dependencies = [
|
|||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
|
"rcgen",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"rmcp 0.16.0",
|
"rmcp 0.16.0",
|
||||||
"rustls 0.23.36",
|
"rustls 0.23.36",
|
||||||
@@ -7831,6 +7866,19 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "realfft"
|
name = "realfft"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -12561,6 +12609,15 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.7.5"
|
version = "0.7.5"
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ rand = "0.9.2"
|
|||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
socket2 = "0.6.1"
|
socket2 = "0.6.1"
|
||||||
fs2 = { workspace = true }
|
fs2 = { workspace = true }
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["aws_lc_rs"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
once_cell = { workspace = true }
|
once_cell = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
rcgen = "0.13"
|
||||||
|
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
|
||||||
|
aws-lc-rs = "1.16.0"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = { version = "0.55.0" }
|
winreg = { version = "0.55.0" }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub async fn check_token(
|
|||||||
if request.uri().path() == "/status"
|
if request.uri().path() == "/status"
|
||||||
|| request.uri().path() == "/mcp-ui-proxy"
|
|| request.uri().path() == "/mcp-ui-proxy"
|
||||||
|| request.uri().path() == "/mcp-app-proxy"
|
|| request.uri().path() == "/mcp-app-proxy"
|
||||||
|
|| request.uri().path() == "/mcp-app-guest"
|
||||||
{
|
{
|
||||||
return Ok(next.run(request).await);
|
return Ok(next.run(request).await);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ use crate::configuration;
|
|||||||
use crate::state;
|
use crate::state;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
|
use axum_server::Handle;
|
||||||
use goose_server::auth::check_token;
|
use goose_server::auth::check_token;
|
||||||
|
use goose_server::tls::self_signed_config;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
// Graceful shutdown signal
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn shutdown_signal() {
|
async fn shutdown_signal() {
|
||||||
use tokio::signal::unix::{signal, SignalKind};
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
@@ -53,8 +54,17 @@ pub async fn run() -> Result<()> {
|
|||||||
))
|
))
|
||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?;
|
let addr = settings.socket_addr();
|
||||||
info!("listening on {}", listener.local_addr()?);
|
let tls_setup = self_signed_config().await?;
|
||||||
|
|
||||||
|
let handle = Handle::new();
|
||||||
|
let shutdown_handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
shutdown_handle.graceful_shutdown(None);
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("listening on https://{}", addr);
|
||||||
|
|
||||||
let tunnel_manager = app_state.tunnel_manager.clone();
|
let tunnel_manager = app_state.tunnel_manager.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -66,8 +76,9 @@ pub async fn run() -> Result<()> {
|
|||||||
gateway_manager.check_auto_start().await;
|
gateway_manager.check_auto_start().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
axum::serve(listener, app)
|
axum_server::bind_rustls(addr, tls_setup.config)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.handle(handle)
|
||||||
|
.serve(app.into_make_service())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if goose::otel::otlp::is_otlp_initialized() {
|
if goose::otel::otlp::is_otlp_initialized() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod error;
|
|||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod tls;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|
||||||
// Re-export commonly used items
|
// Re-export commonly used items
|
||||||
|
|||||||
@@ -848,6 +848,16 @@ async fn update_working_dir(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_extensions_loaded(state: &AppState, session_id: &str) {
|
||||||
|
if let Some(_results) = state.take_extension_loading_task(session_id).await {
|
||||||
|
tracing::debug!(
|
||||||
|
"Awaited background extension loading for session {} before serving request",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
state.remove_extension_loading_task(session_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/agent/read_resource",
|
path = "/agent/read_resource",
|
||||||
@@ -866,6 +876,8 @@ async fn read_resource(
|
|||||||
) -> Result<Json<ReadResourceResponse>, StatusCode> {
|
) -> Result<Json<ReadResourceResponse>, StatusCode> {
|
||||||
use rmcp::model::ResourceContents;
|
use rmcp::model::ResourceContents;
|
||||||
|
|
||||||
|
ensure_extensions_loaded(&state, &payload.session_id).await;
|
||||||
|
|
||||||
let agent = state
|
let agent = state
|
||||||
.get_agent_for_route(payload.session_id.clone())
|
.get_agent_for_route(payload.session_id.clone())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -879,7 +891,16 @@ async fn read_resource(
|
|||||||
CancellationToken::default(),
|
CancellationToken::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!(
|
||||||
|
"read_resource failed for session={}, uri={}, extension={}: {:?}",
|
||||||
|
payload.session_id,
|
||||||
|
payload.uri,
|
||||||
|
payload.extension_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
let content = read_result
|
let content = read_result
|
||||||
.contents
|
.contents
|
||||||
@@ -936,6 +957,8 @@ async fn call_tool(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(payload): Json<CallToolRequest>,
|
Json(payload): Json<CallToolRequest>,
|
||||||
) -> Result<Json<CallToolResponse>, StatusCode> {
|
) -> Result<Json<CallToolResponse>, StatusCode> {
|
||||||
|
ensure_extensions_loaded(&state, &payload.session_id).await;
|
||||||
|
|
||||||
let agent = state
|
let agent = state
|
||||||
.get_agent_for_route(payload.session_id.clone())
|
.get_agent_for_route(payload.session_id.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -2,10 +2,23 @@ use axum::{
|
|||||||
extract::Query,
|
extract::Query,
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const GUEST_HTML_TTL_SECS: u64 = 300; // 5 minutes
|
||||||
|
const GUEST_HTML_MAX_ENTRIES: usize = 64;
|
||||||
|
|
||||||
|
/// In-memory store for guest HTML content.
|
||||||
|
/// Maps nonce -> (html_content, csp_string, created_at)
|
||||||
|
/// Entries are consumed on first read and evicted after TTL.
|
||||||
|
type GuestHtmlStore = Arc<RwLock<HashMap<String, (String, String, Instant)>>>;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ProxyQuery {
|
struct ProxyQuery {
|
||||||
@@ -18,6 +31,22 @@ struct ProxyQuery {
|
|||||||
frame_domains: Option<String>,
|
frame_domains: Option<String>,
|
||||||
/// Comma-separated list of allowed base URIs (base-uri)
|
/// Comma-separated list of allowed base URIs (base-uri)
|
||||||
base_uri_domains: Option<String>,
|
base_uri_domains: Option<String>,
|
||||||
|
/// Comma-separated list of domains for script-src (external scripts like SDKs)
|
||||||
|
script_domains: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GuestQuery {
|
||||||
|
secret: String,
|
||||||
|
nonce: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StoreGuestBody {
|
||||||
|
secret: String,
|
||||||
|
html: String,
|
||||||
|
/// CSP string to apply to the guest page
|
||||||
|
csp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html");
|
const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html");
|
||||||
@@ -35,6 +64,7 @@ fn build_outer_csp(
|
|||||||
resource_domains: &[String],
|
resource_domains: &[String],
|
||||||
frame_domains: &[String],
|
frame_domains: &[String],
|
||||||
base_uri_domains: &[String],
|
base_uri_domains: &[String],
|
||||||
|
script_domains: &[String],
|
||||||
) -> String {
|
) -> String {
|
||||||
let resources = if resource_domains.is_empty() {
|
let resources = if resource_domains.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -42,16 +72,23 @@ fn build_outer_csp(
|
|||||||
format!(" {}", resource_domains.join(" "))
|
format!(" {}", resource_domains.join(" "))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let scripts = if script_domains.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", script_domains.join(" "))
|
||||||
|
};
|
||||||
|
|
||||||
let connections = if connect_domains.is_empty() {
|
let connections = if connect_domains.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" {}", connect_domains.join(" "))
|
format!(" {}", connect_domains.join(" "))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// frame-src needs 'self' so the proxy can load the guest iframe from /mcp-app-guest
|
||||||
let frame_src = if frame_domains.is_empty() {
|
let frame_src = if frame_domains.is_empty() {
|
||||||
"frame-src 'none'".to_string()
|
"frame-src 'self'".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("frame-src {}", frame_domains.join(" "))
|
format!("frame-src 'self' {}", frame_domains.join(" "))
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_uris = if base_uri_domains.is_empty() {
|
let base_uris = if base_uri_domains.is_empty() {
|
||||||
@@ -62,8 +99,8 @@ fn build_outer_csp(
|
|||||||
|
|
||||||
format!(
|
format!(
|
||||||
"default-src 'none'; \
|
"default-src 'none'; \
|
||||||
script-src 'self' 'unsafe-inline'{resources}; \
|
script-src 'self' 'unsafe-inline'{resources}{scripts}; \
|
||||||
script-src-elem 'self' 'unsafe-inline'{resources}; \
|
script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \
|
||||||
style-src 'self' 'unsafe-inline'{resources}; \
|
style-src 'self' 'unsafe-inline'{resources}; \
|
||||||
style-src-elem 'self' 'unsafe-inline'{resources}; \
|
style-src-elem 'self' 'unsafe-inline'{resources}; \
|
||||||
connect-src 'self'{connections}; \
|
connect-src 'self'{connections}; \
|
||||||
@@ -88,6 +125,12 @@ fn parse_domains(domains: Option<&String>) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
secret_key: String,
|
||||||
|
guest_store: GuestHtmlStore,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/mcp-app-proxy",
|
path = "/mcp-app-proxy",
|
||||||
@@ -96,7 +139,8 @@ fn parse_domains(domains: Option<&String>) -> Vec<String> {
|
|||||||
("connect_domains" = Option<String>, Query, description = "Comma-separated domains for connect-src"),
|
("connect_domains" = Option<String>, Query, description = "Comma-separated domains for connect-src"),
|
||||||
("resource_domains" = Option<String>, Query, description = "Comma-separated domains for resource loading"),
|
("resource_domains" = Option<String>, Query, description = "Comma-separated domains for resource loading"),
|
||||||
("frame_domains" = Option<String>, Query, description = "Comma-separated origins for nested iframes (frame-src)"),
|
("frame_domains" = Option<String>, Query, description = "Comma-separated origins for nested iframes (frame-src)"),
|
||||||
("base_uri_domains" = Option<String>, Query, description = "Comma-separated allowed base URIs (base-uri)")
|
("base_uri_domains" = Option<String>, Query, description = "Comma-separated allowed base URIs (base-uri)"),
|
||||||
|
("script_domains" = Option<String>, Query, description = "Comma-separated domains for script-src")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "MCP App proxy HTML page", content_type = "text/html"),
|
(status = 200, description = "MCP App proxy HTML page", content_type = "text/html"),
|
||||||
@@ -104,10 +148,10 @@ fn parse_domains(domains: Option<&String>) -> Vec<String> {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
async fn mcp_app_proxy(
|
async fn mcp_app_proxy(
|
||||||
axum::extract::State(secret_key): axum::extract::State<String>,
|
axum::extract::State(state): axum::extract::State<AppState>,
|
||||||
Query(params): Query<ProxyQuery>,
|
Query(params): Query<ProxyQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if params.secret != secret_key {
|
if params.secret != state.secret_key {
|
||||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +160,7 @@ async fn mcp_app_proxy(
|
|||||||
let resource_domains = parse_domains(params.resource_domains.as_ref());
|
let resource_domains = parse_domains(params.resource_domains.as_ref());
|
||||||
let frame_domains = parse_domains(params.frame_domains.as_ref());
|
let frame_domains = parse_domains(params.frame_domains.as_ref());
|
||||||
let base_uri_domains = parse_domains(params.base_uri_domains.as_ref());
|
let base_uri_domains = parse_domains(params.base_uri_domains.as_ref());
|
||||||
|
let script_domains = parse_domains(params.script_domains.as_ref());
|
||||||
|
|
||||||
// Build the outer CSP based on declared domains
|
// Build the outer CSP based on declared domains
|
||||||
let csp = build_outer_csp(
|
let csp = build_outer_csp(
|
||||||
@@ -123,6 +168,7 @@ async fn mcp_app_proxy(
|
|||||||
&resource_domains,
|
&resource_domains,
|
||||||
&frame_domains,
|
&frame_domains,
|
||||||
&base_uri_domains,
|
&base_uri_domains,
|
||||||
|
&script_domains,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace the CSP placeholder in the HTML template
|
// Replace the CSP placeholder in the HTML template
|
||||||
@@ -141,8 +187,98 @@ async fn mcp_app_proxy(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store guest HTML and return a nonce for retrieval.
|
||||||
|
/// The proxy page calls this via fetch, then sets the guest iframe src to /mcp-app-guest?nonce=...
|
||||||
|
async fn store_guest_html(
|
||||||
|
axum::extract::State(state): axum::extract::State<AppState>,
|
||||||
|
Json(body): Json<StoreGuestBody>,
|
||||||
|
) -> Response {
|
||||||
|
if body.secret != state.secret_key {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce = Uuid::new_v4().to_string();
|
||||||
|
let csp = body.csp.unwrap_or_default();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut store = state.guest_store.write().await;
|
||||||
|
|
||||||
|
// Evict expired entries
|
||||||
|
let cutoff = Instant::now() - std::time::Duration::from_secs(GUEST_HTML_TTL_SECS);
|
||||||
|
store.retain(|_, (_, _, created)| *created > cutoff);
|
||||||
|
|
||||||
|
// If still at capacity, drop the oldest entry
|
||||||
|
if store.len() >= GUEST_HTML_MAX_ENTRIES {
|
||||||
|
if let Some(oldest_key) = store
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, (_, _, created))| *created)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
{
|
||||||
|
store.remove(&oldest_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.insert(nonce.clone(), (body.html, csp, Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "application/json")],
|
||||||
|
format!(r#"{{"nonce":"{}"}}"#, nonce),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve stored guest HTML with a real HTTPS URL.
|
||||||
|
/// This gives the guest iframe `window.location.protocol === "https:"`,
|
||||||
|
/// which is required by SDKs like Square Web Payments that check for secure context.
|
||||||
|
async fn serve_guest_html(
|
||||||
|
axum::extract::State(state): axum::extract::State<AppState>,
|
||||||
|
Query(params): Query<GuestQuery>,
|
||||||
|
) -> Response {
|
||||||
|
if params.secret != state.secret_key {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the entry (one-time use)
|
||||||
|
let entry = {
|
||||||
|
let mut store = state.guest_store.write().await;
|
||||||
|
store.remove(¶ms.nonce)
|
||||||
|
};
|
||||||
|
|
||||||
|
match entry {
|
||||||
|
Some((html, csp, _created)) => {
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
// Use strict-origin so third-party SDKs (e.g. Square Web Payments)
|
||||||
|
// receive the origin in their requests, which they need for auth.
|
||||||
|
// no-referrer would cause 401s from SDK servers.
|
||||||
|
headers.insert(
|
||||||
|
header::HeaderName::from_static("referrer-policy"),
|
||||||
|
"strict-origin".parse().unwrap(),
|
||||||
|
);
|
||||||
|
if !csp.is_empty() {
|
||||||
|
headers.insert(header::CONTENT_SECURITY_POLICY, csp.parse().unwrap());
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Guest content not found or already consumed",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes(secret_key: String) -> Router {
|
pub fn routes(secret_key: String) -> Router {
|
||||||
|
let state = AppState {
|
||||||
|
secret_key,
|
||||||
|
guest_store: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/mcp-app-proxy", get(mcp_app_proxy))
|
.route("/mcp-app-proxy", get(mcp_app_proxy))
|
||||||
.with_state(secret_key)
|
.route("/mcp-app-guest", get(serve_guest_html))
|
||||||
|
.route("/mcp-app-guest", post(store_guest_html))
|
||||||
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,42 @@
|
|||||||
|
|
||||||
let guestIframe = null;
|
let guestIframe = null;
|
||||||
|
|
||||||
function createGuestIframe(html, permissions) {
|
/**
|
||||||
|
* Extract the secret and base URL from the current page URL.
|
||||||
|
*/
|
||||||
|
function getProxyParams() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
secret: params.get('secret') || '',
|
||||||
|
baseUrl: window.location.origin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store guest HTML on the server and get a nonce for retrieval.
|
||||||
|
* This allows the guest iframe to load from a real HTTPS URL
|
||||||
|
* instead of srcdoc (which has about: protocol).
|
||||||
|
*/
|
||||||
|
async function storeGuestHtml(html) {
|
||||||
|
var proxyParams = getProxyParams();
|
||||||
|
var response = await fetch(proxyParams.baseUrl + '/mcp-app-guest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
secret: proxyParams.secret,
|
||||||
|
html: html
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to store guest HTML: ' + response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await response.json();
|
||||||
|
return data.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGuestIframe(html, permissions) {
|
||||||
if (guestIframe) {
|
if (guestIframe) {
|
||||||
guestIframe.remove();
|
guestIframe.remove();
|
||||||
}
|
}
|
||||||
@@ -43,7 +78,7 @@
|
|||||||
|
|
||||||
// Sandbox permissions for the Guest UI
|
// Sandbox permissions for the Guest UI
|
||||||
// allow-scripts: needed for the app to run
|
// allow-scripts: needed for the app to run
|
||||||
// allow-same-origin: needed for localStorage, cookies, etc.
|
// allow-same-origin: needed for localStorage, cookies, and real URL context
|
||||||
// allow-forms: needed if the app has forms
|
// allow-forms: needed if the app has forms
|
||||||
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
|
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
|
||||||
|
|
||||||
@@ -58,9 +93,22 @@
|
|||||||
guestIframe.setAttribute('allow', allowList.join('; '));
|
guestIframe.setAttribute('allow', allowList.join('; '));
|
||||||
}
|
}
|
||||||
|
|
||||||
guestIframe.srcdoc = html;
|
|
||||||
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
|
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
|
||||||
|
|
||||||
|
// Store the HTML server-side and load via real URL.
|
||||||
|
// This gives the guest iframe a real https:// URL instead of about:srcdoc,
|
||||||
|
// which is required by SDKs (like Square Web Payments) that check
|
||||||
|
// window.location.protocol for secure context verification.
|
||||||
|
try {
|
||||||
|
var nonce = await storeGuestHtml(html);
|
||||||
|
var proxyParams = getProxyParams();
|
||||||
|
guestIframe.src = proxyParams.baseUrl + '/mcp-app-guest?secret=' + encodeURIComponent(proxyParams.secret) + '&nonce=' + encodeURIComponent(nonce);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to srcdoc if the store endpoint is not available
|
||||||
|
console.warn('Failed to use /mcp-app-guest endpoint, falling back to srcdoc:', e);
|
||||||
|
guestIframe.srcdoc = html;
|
||||||
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.body
|
.body
|
||||||
.appendChild(guestIframe);
|
.appendChild(guestIframe);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use aws_lc_rs::digest;
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
|
use rcgen::{CertificateParams, DnType, KeyPair, SanType};
|
||||||
|
|
||||||
|
pub struct TlsSetup {
|
||||||
|
pub config: RustlsConfig,
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and
|
||||||
|
/// return a [`TlsSetup`] containing the rustls config and the SHA-256
|
||||||
|
/// fingerprint of the generated certificate (colon-separated hex).
|
||||||
|
///
|
||||||
|
/// The fingerprint is printed to stdout so the parent process (e.g. Electron)
|
||||||
|
/// can pin it and reject connections from any other certificate.
|
||||||
|
pub async fn self_signed_config() -> Result<TlsSetup> {
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
|
let mut params = CertificateParams::default();
|
||||||
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(DnType::CommonName, "goosed localhost");
|
||||||
|
params.subject_alt_names = vec![
|
||||||
|
SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||||
|
SanType::DnsName("localhost".try_into()?),
|
||||||
|
];
|
||||||
|
|
||||||
|
let key_pair = KeyPair::generate()?;
|
||||||
|
let cert = params.self_signed(&key_pair)?;
|
||||||
|
|
||||||
|
let cert_der = cert.der();
|
||||||
|
let sha256 = digest::digest(&digest::SHA256, cert_der);
|
||||||
|
let fingerprint = sha256
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02X}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":");
|
||||||
|
|
||||||
|
println!("GOOSED_CERT_FINGERPRINT={fingerprint}");
|
||||||
|
|
||||||
|
let cert_pem = cert.pem();
|
||||||
|
let key_pem = key_pair.serialize_pem();
|
||||||
|
|
||||||
|
let config = RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()).await?;
|
||||||
|
|
||||||
|
Ok(TlsSetup {
|
||||||
|
config,
|
||||||
|
fingerprint,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,15 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
/// Shared state for proxying tunnel requests to the local goosed server.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ProxyContext {
|
||||||
|
port: u16,
|
||||||
|
tunnel_secret: String,
|
||||||
|
server_secret: String,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
/// Constant-time comparison using hash to prevent timing attacks
|
/// Constant-time comparison using hash to prevent timing attacks
|
||||||
fn secure_compare(a: &str, b: &str) -> bool {
|
fn secure_compare(a: &str, b: &str) -> bool {
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
@@ -253,38 +262,42 @@ async fn handle_chunked_response(
|
|||||||
|
|
||||||
async fn handle_request(
|
async fn handle_request(
|
||||||
message: TunnelMessage,
|
message: TunnelMessage,
|
||||||
port: u16,
|
ctx: ProxyContext,
|
||||||
ws_tx: WebSocketSender,
|
ws_tx: WebSocketSender,
|
||||||
tunnel_secret: String,
|
scheme: &str,
|
||||||
server_secret: String,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let request_id = message.request_id.clone();
|
let request_id = message.request_id.clone();
|
||||||
|
let client = &ctx.http_client;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let url = format!("{}://127.0.0.1:{}{}", scheme, ctx.port, message.path);
|
||||||
let url = format!("http://127.0.0.1:{}{}", port, message.path);
|
|
||||||
|
|
||||||
let request_builder =
|
let request_builder = match validate_and_build_request(
|
||||||
match validate_and_build_request(&client, &url, &message, &tunnel_secret, &server_secret) {
|
client,
|
||||||
Ok(builder) => builder,
|
&url,
|
||||||
Err(e) => {
|
&message,
|
||||||
error!("✗ Authentication error [{}]: {}", request_id, e);
|
&ctx.tunnel_secret,
|
||||||
let error_response = TunnelResponse {
|
&ctx.server_secret,
|
||||||
request_id,
|
) {
|
||||||
status: 401,
|
Ok(builder) => builder,
|
||||||
headers: None,
|
Err(e) => {
|
||||||
body: None,
|
error!("✗ Authentication error [{}]: {}", request_id, e);
|
||||||
error: Some(e.to_string()),
|
let error_response = TunnelResponse {
|
||||||
chunk_index: None,
|
request_id,
|
||||||
total_chunks: None,
|
status: 401,
|
||||||
is_chunked: false,
|
headers: None,
|
||||||
is_streaming: false,
|
body: None,
|
||||||
is_first_chunk: false,
|
error: Some(e.to_string()),
|
||||||
is_last_chunk: false,
|
chunk_index: None,
|
||||||
};
|
total_chunks: None,
|
||||||
send_response(ws_tx, error_response).await?;
|
is_chunked: false,
|
||||||
return Ok(());
|
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 {
|
let response = match request_builder.send().await {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
@@ -399,11 +412,10 @@ async fn handle_websocket_messages(
|
|||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
ws_tx: WebSocketSender,
|
ws_tx: WebSocketSender,
|
||||||
port: u16,
|
ctx: ProxyContext,
|
||||||
tunnel_secret: String,
|
|
||||||
server_secret: String,
|
|
||||||
last_activity: Arc<RwLock<Instant>>,
|
last_activity: Arc<RwLock<Instant>>,
|
||||||
active_tasks: Arc<RwLock<Vec<JoinHandle<()>>>>,
|
active_tasks: Arc<RwLock<Vec<JoinHandle<()>>>>,
|
||||||
|
scheme: String,
|
||||||
) {
|
) {
|
||||||
while let Some(msg) = read.next().await {
|
while let Some(msg) = read.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
@@ -413,17 +425,12 @@ async fn handle_websocket_messages(
|
|||||||
match serde_json::from_str::<TunnelMessage>(&text) {
|
match serde_json::from_str::<TunnelMessage>(&text) {
|
||||||
Ok(tunnel_msg) => {
|
Ok(tunnel_msg) => {
|
||||||
let ws_tx_clone = ws_tx.clone();
|
let ws_tx_clone = ws_tx.clone();
|
||||||
let tunnel_secret_clone = tunnel_secret.clone();
|
let ctx_clone = ctx.clone();
|
||||||
let server_secret_clone = server_secret.clone();
|
let scheme_clone = scheme.clone();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
if let Err(e) = handle_request(
|
if let Err(e) =
|
||||||
tunnel_msg,
|
handle_request(tunnel_msg, ctx_clone, ws_tx_clone, &scheme_clone)
|
||||||
port,
|
.await
|
||||||
ws_tx_clone,
|
|
||||||
tunnel_secret_clone,
|
|
||||||
server_secret_clone,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
error!("Error handling request: {}", e);
|
error!("Error handling request: {}", e);
|
||||||
}
|
}
|
||||||
@@ -475,9 +482,10 @@ async fn run_single_connection(
|
|||||||
agent_id: String,
|
agent_id: String,
|
||||||
tunnel_secret: String,
|
tunnel_secret: String,
|
||||||
server_secret: String,
|
server_secret: String,
|
||||||
|
scheme: String,
|
||||||
restart_tx: mpsc::Sender<()>,
|
restart_tx: mpsc::Sender<()>,
|
||||||
) {
|
) {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
let worker_url = get_worker_url();
|
let worker_url = get_worker_url();
|
||||||
let ws_url = worker_url
|
let ws_url = worker_url
|
||||||
@@ -514,10 +522,25 @@ async fn run_single_connection(
|
|||||||
};
|
};
|
||||||
|
|
||||||
info!("✓ Connected as agent: {}", agent_id);
|
info!("✓ Connected as agent: {}", agent_id);
|
||||||
info!("✓ Proxying to: http://127.0.0.1:{}", port);
|
info!("✓ Proxying to: {}://127.0.0.1:{}", scheme, port);
|
||||||
let public_url = format!("{}/tunnel/{}", worker_url, agent_id);
|
let public_url = format!("{}/tunnel/{}", worker_url, agent_id);
|
||||||
info!("✓ Public URL: {}", public_url);
|
info!("✓ Public URL: {}", public_url);
|
||||||
|
|
||||||
|
let mut client_builder = reqwest::Client::builder();
|
||||||
|
if scheme == "https" {
|
||||||
|
client_builder = client_builder.danger_accept_invalid_certs(true);
|
||||||
|
}
|
||||||
|
let http_client = client_builder
|
||||||
|
.build()
|
||||||
|
.expect("failed to build reqwest client");
|
||||||
|
|
||||||
|
let ctx = ProxyContext {
|
||||||
|
port,
|
||||||
|
tunnel_secret,
|
||||||
|
server_secret,
|
||||||
|
http_client,
|
||||||
|
};
|
||||||
|
|
||||||
let (write, read) = ws_stream.split();
|
let (write, read) = ws_stream.split();
|
||||||
let ws_tx: WebSocketSender = Arc::new(RwLock::new(Some(write)));
|
let ws_tx: WebSocketSender = Arc::new(RwLock::new(Some(write)));
|
||||||
let last_activity = Arc::new(RwLock::new(Instant::now()));
|
let last_activity = Arc::new(RwLock::new(Instant::now()));
|
||||||
@@ -545,11 +568,10 @@ async fn run_single_connection(
|
|||||||
_ = handle_websocket_messages(
|
_ = handle_websocket_messages(
|
||||||
read,
|
read,
|
||||||
ws_tx.clone(),
|
ws_tx.clone(),
|
||||||
port,
|
ctx,
|
||||||
tunnel_secret.clone(),
|
|
||||||
server_secret.clone(),
|
|
||||||
last_activity,
|
last_activity,
|
||||||
active_tasks.clone()
|
active_tasks.clone(),
|
||||||
|
scheme,
|
||||||
) => {
|
) => {
|
||||||
info!("✗ Connection ended");
|
info!("✗ Connection ended");
|
||||||
}
|
}
|
||||||
@@ -565,6 +587,7 @@ pub async fn start(
|
|||||||
tunnel_secret: String,
|
tunnel_secret: String,
|
||||||
server_secret: String,
|
server_secret: String,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
scheme: &str,
|
||||||
handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||||
restart_tx: mpsc::Sender<()>,
|
restart_tx: mpsc::Sender<()>,
|
||||||
) -> Result<TunnelInfo> {
|
) -> Result<TunnelInfo> {
|
||||||
@@ -573,6 +596,7 @@ pub async fn start(
|
|||||||
let agent_id_clone = agent_id.clone();
|
let agent_id_clone = agent_id.clone();
|
||||||
let tunnel_secret_clone = tunnel_secret.clone();
|
let tunnel_secret_clone = tunnel_secret.clone();
|
||||||
let server_secret_clone = server_secret;
|
let server_secret_clone = server_secret;
|
||||||
|
let scheme = scheme.to_string();
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
run_single_connection(
|
run_single_connection(
|
||||||
@@ -580,6 +604,7 @@ pub async fn start(
|
|||||||
agent_id_clone,
|
agent_id_clone,
|
||||||
tunnel_secret_clone,
|
tunnel_secret_clone,
|
||||||
server_secret_clone,
|
server_secret_clone,
|
||||||
|
scheme,
|
||||||
restart_tx,
|
restart_tx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async fn test_tunnel_end_to_end() {
|
|||||||
tunnel_secret.clone(),
|
tunnel_secret.clone(),
|
||||||
server_secret.clone(),
|
server_secret.clone(),
|
||||||
agent_id.clone(),
|
agent_id.clone(),
|
||||||
|
"http",
|
||||||
handle.clone(),
|
handle.clone(),
|
||||||
restart_tx,
|
restart_tx,
|
||||||
)
|
)
|
||||||
@@ -138,6 +139,7 @@ async fn test_tunnel_post_request() {
|
|||||||
tunnel_secret.clone(),
|
tunnel_secret.clone(),
|
||||||
server_secret.clone(),
|
server_secret.clone(),
|
||||||
agent_id.clone(),
|
agent_id.clone(),
|
||||||
|
"http",
|
||||||
handle.clone(),
|
handle.clone(),
|
||||||
restart_tx,
|
restart_tx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ impl TunnelManager {
|
|||||||
tunnel_secret,
|
tunnel_secret,
|
||||||
server_secret,
|
server_secret,
|
||||||
agent_id,
|
agent_id,
|
||||||
|
"https",
|
||||||
self.lapstone_handle.clone(),
|
self.lapstone_handle.clone(),
|
||||||
restart_tx,
|
restart_tx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -335,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
{/* Navigation Settings */}
|
{/* Navigation Settings */}
|
||||||
<NavigationSettingsCard />
|
<NavigationSettingsCard />
|
||||||
|
|
||||||
|
|
||||||
<TelemetrySettings isWelcome={false} />
|
<TelemetrySettings isWelcome={false} />
|
||||||
|
|
||||||
<Card className="rounded-lg">
|
<Card className="rounded-lg">
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||||
import { Input } from '../../ui/input';
|
import { Input } from '../../ui/input';
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from '../../ui/dialog';
|
|
||||||
import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react';
|
import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react';
|
||||||
import { getApiUrl } from '../../../config';
|
import { getApiUrl } from '../../../config';
|
||||||
|
|
||||||
@@ -138,9 +132,15 @@ export default function GatewaySettingsSection() {
|
|||||||
<TelegramGatewayCard
|
<TelegramGatewayCard
|
||||||
status={telegram}
|
status={telegram}
|
||||||
onStart={(config) =>
|
onStart={(config) =>
|
||||||
doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start')
|
doPost(
|
||||||
|
'/gateway/start',
|
||||||
|
{ gateway_type: 'telegram', platform_config: config, max_sessions: 0 },
|
||||||
|
'Failed to start'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRestart={() =>
|
||||||
|
doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')
|
||||||
}
|
}
|
||||||
onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')}
|
|
||||||
onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')}
|
onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')}
|
||||||
onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')}
|
onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')}
|
||||||
onGenerateCode={async () => {
|
onGenerateCode={async () => {
|
||||||
@@ -238,7 +238,11 @@ function TelegramGatewayCard({
|
|||||||
|
|
||||||
const wrap = (fn: () => Promise<void>) => async () => {
|
const wrap = (fn: () => Promise<void>) => async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { await fn(); } finally { setBusy(false); }
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFirstStart = wrap(async () => {
|
const handleFirstStart = wrap(async () => {
|
||||||
@@ -310,9 +314,11 @@ function TelegramGatewayCard({
|
|||||||
>
|
>
|
||||||
@BotFather
|
@BotFather
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>{' '}
|
||||||
{' '}on your phone, send <code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
|
on your phone, send{' '}
|
||||||
the prompts to name your bot. BotFather will reply with an API token — paste it below.
|
<code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
|
||||||
|
the prompts to name your bot. BotFather will reply with an API token — paste it
|
||||||
|
below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -399,8 +405,8 @@ function PairingCodeModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-text-muted">
|
<p className="text-center text-sm text-text-muted">
|
||||||
Send this code to your{' '}
|
Send this code to your <span className="capitalize font-medium">{gatewayType}</span> bot
|
||||||
<span className="capitalize font-medium">{gatewayType}</span> bot to pair.
|
to pair.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="text-center text-xs text-text-muted">
|
<div className="text-center text-xs text-text-muted">
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export interface GoosedResult {
|
|||||||
errorLog: string[];
|
errorLog: string[];
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
client: Client;
|
client: Client;
|
||||||
|
certFingerprint: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
|
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
|
||||||
@@ -209,12 +210,13 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
logger.info('Not killing external process that is managed externally');
|
logger.info('Not killing external process that is managed externally');
|
||||||
},
|
},
|
||||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||||
|
certFingerprint: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.GOOSE_EXTERNAL_BACKEND) {
|
if (process.env.GOOSE_EXTERNAL_BACKEND) {
|
||||||
const port = process.env.GOOSE_PORT || '3000';
|
const port = process.env.GOOSE_PORT || '3000';
|
||||||
const url = `http://127.0.0.1:${port}`;
|
const url = `https://127.0.0.1:${port}`;
|
||||||
logger.info(`Using external goosed backend from env at ${url}`);
|
logger.info(`Using external goosed backend from env at ${url}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -226,6 +228,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
logger.info('Not killing external process that is managed externally');
|
logger.info('Not killing external process that is managed externally');
|
||||||
},
|
},
|
||||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||||
|
certFingerprint: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +244,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
|
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `https://127.0.0.1:${port}`;
|
||||||
|
|
||||||
const spawnEnv: Record<string, string | undefined> = {
|
const spawnEnv: Record<string, string | undefined> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -292,8 +295,34 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
|
|
||||||
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
|
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
|
||||||
|
|
||||||
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
let certFingerprint: string | null = null;
|
||||||
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
|
const fingerprintReady = new Promise<string | null>((resolve) => {
|
||||||
|
const FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT=';
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`);
|
||||||
|
|
||||||
|
if (!resolved && text.includes(FINGERPRINT_PREFIX)) {
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (line.startsWith(FINGERPRINT_PREFIX)) {
|
||||||
|
certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim();
|
||||||
|
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
|
||||||
|
resolved = true;
|
||||||
|
resolve(certFingerprint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
goosedProcess.on('exit', () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
@@ -354,6 +383,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
|
|
||||||
logger.info(`Goosed server successfully started on port ${port}`);
|
logger.info(`Goosed server successfully started on port ${port}`);
|
||||||
|
|
||||||
|
await fingerprintReady;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -361,5 +392,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
errorLog,
|
errorLog,
|
||||||
cleanup,
|
cleanup,
|
||||||
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
|
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
|
||||||
|
certFingerprint,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+86
-8
@@ -8,6 +8,7 @@ import {
|
|||||||
ipcMain,
|
ipcMain,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
net,
|
||||||
Notification,
|
Notification,
|
||||||
powerSaveBlocker,
|
powerSaveBlocker,
|
||||||
screen,
|
screen,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Tray,
|
Tray,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url';
|
import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import fsSync from 'node:fs';
|
import fsSync from 'node:fs';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
@@ -25,6 +27,7 @@ import { spawn } from 'child_process';
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { checkServerStatus } from './goosed';
|
import { checkServerStatus } from './goosed';
|
||||||
import { startGoosed } from './goosed';
|
import { startGoosed } from './goosed';
|
||||||
|
import { createClient, createConfig } from './api/client';
|
||||||
import { expandTilde } from './utils/pathUtils';
|
import { expandTilde } from './utils/pathUtils';
|
||||||
import log from './utils/logger';
|
import log from './utils/logger';
|
||||||
import { ensureWinShims } from './utils/winShims';
|
import { ensureWinShims } from './utils/winShims';
|
||||||
@@ -107,6 +110,66 @@ async function configureProxy() {
|
|||||||
|
|
||||||
if (started) app.quit();
|
if (started) app.quit();
|
||||||
|
|
||||||
|
// Accept self-signed certificates from the local goosed server.
|
||||||
|
// Both certificate-error (renderer) and setCertificateVerifyProc (main-process
|
||||||
|
// net.fetch) pin to the exact cert fingerprint emitted by goosed at startup.
|
||||||
|
// Before the fingerprint is available (during the health-check bootstrap
|
||||||
|
// window) any localhost cert is accepted so the server can come up.
|
||||||
|
let pinnedCertFingerprint: string | null = null;
|
||||||
|
|
||||||
|
function isLocalhost(hostname: string): boolean {
|
||||||
|
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFingerprint(fp: string): string {
|
||||||
|
if (fp.startsWith('sha256/')) {
|
||||||
|
const b64 = fp.slice('sha256/'.length);
|
||||||
|
const buf = Buffer.from(b64, 'base64');
|
||||||
|
return Array.from(buf)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join(':')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
return fp.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer requests: pin to the exact cert goosed generated once known.
|
||||||
|
// Before the fingerprint is available (during the health-check bootstrap
|
||||||
|
// window) any localhost cert is accepted so the server can come up.
|
||||||
|
app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!isLocalhost(parsed.hostname)) {
|
||||||
|
callback(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pinnedCertFingerprint) {
|
||||||
|
const match =
|
||||||
|
normalizeFingerprint(certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
|
||||||
|
event.preventDefault();
|
||||||
|
callback(match);
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
callback(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main-process net.fetch: pin to the exact cert goosed generated.
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
||||||
|
if (!isLocalhost(request.hostname)) {
|
||||||
|
callback(-3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pinnedCertFingerprint) {
|
||||||
|
callback(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match =
|
||||||
|
normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
|
||||||
|
callback(match ? 0 : -3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.ENABLE_PLAYWRIGHT) {
|
if (process.env.ENABLE_PLAYWRIGHT) {
|
||||||
const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222';
|
const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222';
|
||||||
console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`);
|
console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`);
|
||||||
@@ -449,7 +512,7 @@ let appConfig = {
|
|||||||
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
||||||
GOOSE_DEFAULT_MODEL: defaultModel,
|
GOOSE_DEFAULT_MODEL: defaultModel,
|
||||||
GOOSE_PREDEFINED_MODELS: predefinedModels,
|
GOOSE_PREDEFINED_MODELS: predefinedModels,
|
||||||
GOOSE_API_HOST: 'http://127.0.0.1',
|
GOOSE_API_HOST: 'https://localhost',
|
||||||
GOOSE_WORKING_DIR: '',
|
GOOSE_WORKING_DIR: '',
|
||||||
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
|
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
|
||||||
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
|
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
|
||||||
@@ -498,18 +561,18 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
|||||||
logger: log,
|
logger: log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pin the certificate fingerprint so the cert handlers above only accept
|
||||||
|
// the exact cert that *this* goosed instance generated.
|
||||||
|
if (goosedResult.certFingerprint) {
|
||||||
|
pinnedCertFingerprint = goosedResult.certFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
app.on('will-quit', async () => {
|
app.on('will-quit', async () => {
|
||||||
log.info('App quitting, terminating goosed server');
|
log.info('App quitting, terminating goosed server');
|
||||||
await goosedResult.cleanup();
|
await goosedResult.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
|
||||||
baseUrl,
|
|
||||||
workingDir,
|
|
||||||
process: goosedProcess,
|
|
||||||
errorLog,
|
|
||||||
client: goosedClient,
|
|
||||||
} = goosedResult;
|
|
||||||
|
|
||||||
const mainWindowState = windowStateKeeper({
|
const mainWindowState = windowStateKeeper({
|
||||||
defaultWidth: 940,
|
defaultWidth: 940,
|
||||||
@@ -563,6 +626,18 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
|||||||
.catch((err) => log.info('failed to install react dev tools:', err));
|
.catch((err) => log.info('failed to install react dev tools:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-create the client with Electron's net.fetch so requests to the local
|
||||||
|
// self-signed HTTPS server go through the session's certificate handling.
|
||||||
|
const goosedClient = createClient(
|
||||||
|
createConfig({
|
||||||
|
baseUrl,
|
||||||
|
fetch: net.fetch as unknown as typeof globalThis.fetch,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Secret-Key': serverSecret,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
goosedClients.set(mainWindow.id, goosedClient);
|
goosedClients.set(mainWindow.id, goosedClient);
|
||||||
|
|
||||||
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
||||||
@@ -1674,6 +1749,9 @@ async function appMain() {
|
|||||||
const sources = [
|
const sources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
'http://127.0.0.1:*',
|
'http://127.0.0.1:*',
|
||||||
|
'https://127.0.0.1:*',
|
||||||
|
'http://localhost:*',
|
||||||
|
'https://localhost:*',
|
||||||
'https://api.github.com',
|
'https://api.github.com',
|
||||||
'https://github.com',
|
'https://github.com',
|
||||||
'https://objects.githubusercontent.com',
|
'https://objects.githubusercontent.com',
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export async function setupGoosed({
|
|||||||
error: (...args) => console.error('[goosed]', ...args),
|
error: (...args) => console.error('[goosed]', ...args),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Accept self-signed TLS certs from the local goosed server.
|
||||||
|
// In Electron this is handled by setCertificateVerifyProc, but integration
|
||||||
|
// tests run in plain Node.js where fetch rejects self-signed certs.
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
const additionalEnv: Record<string, string> = {
|
const additionalEnv: Record<string, string> = {
|
||||||
GOOSE_PATH_ROOT: tempDir,
|
GOOSE_PATH_ROOT: tempDir,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user