[APP-3801] implement remote environments auth (#9331)
This commit is contained in:
+6
-4
@@ -565,12 +565,12 @@ pub fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy) => {
|
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy(args)) => {
|
||||||
return crate::remote_server::run_proxy();
|
return crate::remote_server::run_proxy(args.identity_key.clone());
|
||||||
}
|
}
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon) => {
|
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon(args)) => {
|
||||||
return crate::remote_server::run_daemon();
|
return crate::remote_server::run_daemon(args.identity_key.clone());
|
||||||
}
|
}
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
warp_cli::Command::Worker(warp_cli::WorkerCommand::RipgrepSearch {
|
warp_cli::Command::Worker(warp_cli::WorkerCommand::RipgrepSearch {
|
||||||
@@ -1253,6 +1253,8 @@ fn initialize_app(
|
|||||||
ctx.add_singleton_model(|_ctx| SyncedInputState::new());
|
ctx.add_singleton_model(|_ctx| SyncedInputState::new());
|
||||||
|
|
||||||
ctx.add_singleton_model(remote_server::manager::RemoteServerManager::new);
|
ctx.add_singleton_model(remote_server::manager::RemoteServerManager::new);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
remote_server::wire_auth_token_rotation(ctx);
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Starting warp with channel state {} and version {:?}",
|
"Starting warp with channel state {} and version {:?}",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use remote_server::auth::RemoteServerAuthContext;
|
||||||
|
use warpui::r#async::BoxFuture;
|
||||||
|
|
||||||
|
use crate::auth::auth_state::AuthState;
|
||||||
|
use crate::server::server_api::auth::AuthClient;
|
||||||
|
|
||||||
|
/// Builds the app-wide auth context used by remote-server connections.
|
||||||
|
pub fn server_api_auth_context(
|
||||||
|
auth_state: Arc<AuthState>,
|
||||||
|
auth_client: Arc<dyn AuthClient>,
|
||||||
|
) -> RemoteServerAuthContext {
|
||||||
|
let token_auth_state = auth_state.clone();
|
||||||
|
let token_auth_client = auth_client;
|
||||||
|
let identity_auth_state = auth_state;
|
||||||
|
|
||||||
|
RemoteServerAuthContext::new(
|
||||||
|
move || -> BoxFuture<'static, Option<String>> {
|
||||||
|
if !use_authenticated_user_identity(&token_auth_state) {
|
||||||
|
return Box::pin(async { None });
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_client = token_auth_client.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
match auth_client.get_or_refresh_access_token().await {
|
||||||
|
Ok(token) => token.bearer_token(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
move || remote_server_identity_key(&identity_auth_state),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_authenticated_user_identity(auth_state: &AuthState) -> bool {
|
||||||
|
auth_state.is_logged_in() && !auth_state.is_user_anonymous().unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_server_identity_key(auth_state: &AuthState) -> String {
|
||||||
|
if use_authenticated_user_identity(auth_state) {
|
||||||
|
auth_state
|
||||||
|
.user_id()
|
||||||
|
.map(|uid| uid.as_string())
|
||||||
|
.unwrap_or_else(|| auth_state.anonymous_id())
|
||||||
|
} else {
|
||||||
|
auth_state.anonymous_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use crate::server::server_api::{ServerApiEvent, ServerApiProvider};
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use remote_server::manager::RemoteServerManager;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use warpui::SingletonEntity;
|
||||||
// Re-export everything from the `remote_server` crate so existing
|
// Re-export everything from the `remote_server` crate so existing
|
||||||
// `crate::remote_server::*` imports in `app` continue to work.
|
// `crate::remote_server::*` imports in `app` continue to work.
|
||||||
pub use remote_server::*;
|
pub use remote_server::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub mod auth_context;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod server_model;
|
pub mod server_model;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
@@ -11,23 +19,23 @@ pub mod unix;
|
|||||||
|
|
||||||
/// Run the `remote-server-proxy` subcommand.
|
/// Run the `remote-server-proxy` subcommand.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn run_proxy() -> anyhow::Result<()> {
|
pub fn run_proxy(identity_key: String) -> anyhow::Result<()> {
|
||||||
unix::run_proxy()
|
unix::run_proxy(identity_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
pub fn run_proxy() -> anyhow::Result<()> {
|
pub fn run_proxy(_identity_key: String) -> anyhow::Result<()> {
|
||||||
anyhow::bail!("remote-server-proxy is not supported on this platform")
|
anyhow::bail!("remote-server-proxy is not supported on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the `remote-server-daemon` subcommand.
|
/// Run the `remote-server-daemon` subcommand.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn run_daemon() -> anyhow::Result<()> {
|
pub fn run_daemon(identity_key: String) -> anyhow::Result<()> {
|
||||||
unix::run_daemon()
|
unix::run_daemon(identity_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
pub fn run_daemon() -> anyhow::Result<()> {
|
pub fn run_daemon(_identity_key: String) -> anyhow::Result<()> {
|
||||||
anyhow::bail!("remote-server-daemon is not supported on this platform")
|
anyhow::bail!("remote-server-daemon is not supported on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,3 +87,17 @@ pub(super) fn run_daemon_app(
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forwards app auth-token rotation events to the remote-server manager.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub fn wire_auth_token_rotation(ctx: &mut warpui::AppContext) {
|
||||||
|
let server_api = ServerApiProvider::handle(ctx);
|
||||||
|
let manager = RemoteServerManager::handle(ctx);
|
||||||
|
ctx.subscribe_to_model(&server_api, move |_, event, ctx| {
|
||||||
|
if let ServerApiEvent::AccessTokenRefreshed { token } = event {
|
||||||
|
manager.update(ctx, |manager, _| {
|
||||||
|
manager.rotate_auth_token(token.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ use warp_util::file::FileId;
|
|||||||
|
|
||||||
use super::proto::{
|
use super::proto::{
|
||||||
client_message, delete_file_response, run_command_response, server_message,
|
client_message, delete_file_response, run_command_response, server_message,
|
||||||
write_file_response, Abort, ClientMessage, DeleteFile, DeleteFileResponse, DeleteFileSuccess,
|
write_file_response, Abort, Authenticate, ClientMessage, DeleteFile, DeleteFileResponse,
|
||||||
ErrorCode, ErrorResponse, FailedFileRead, FileContextProto, FileOperationError,
|
DeleteFileSuccess, ErrorCode, ErrorResponse, FailedFileRead, FileContextProto,
|
||||||
InitializeResponse, NavigatedToDirectory, NavigatedToDirectoryResponse,
|
FileOperationError, Initialize, InitializeResponse, NavigatedToDirectory,
|
||||||
ReadFileContextResponse, RunCommandError, RunCommandErrorCode, RunCommandRequest,
|
NavigatedToDirectoryResponse, ReadFileContextResponse, RunCommandError, RunCommandErrorCode,
|
||||||
RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped, WriteFile,
|
RunCommandRequest, RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped,
|
||||||
WriteFileResponse, WriteFileSuccess,
|
WriteFile, WriteFileResponse, WriteFileSuccess,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How long the daemon waits with no connections before exiting.
|
/// How long the daemon waits with no connections before exiting.
|
||||||
@@ -164,6 +164,13 @@ pub struct ServerModel {
|
|||||||
executors: HashMap<SessionId, Arc<LocalCommandExecutor>>,
|
executors: HashMap<SessionId, Arc<LocalCommandExecutor>>,
|
||||||
/// Tracks in-flight file write/delete operations and handles cleanup.
|
/// Tracks in-flight file write/delete operations and handles cleanup.
|
||||||
pending_file_ops: PendingFileOps,
|
pending_file_ops: PendingFileOps,
|
||||||
|
/// Daemon-wide bearer credential for the identity-scoped daemon.
|
||||||
|
///
|
||||||
|
/// The token is written by Initialize when the client supplies a
|
||||||
|
/// non-empty credential, or by Authenticate during token rotation. It is
|
||||||
|
/// intentionally retained across proxy connection teardown and cleared
|
||||||
|
/// only by daemon process exit.
|
||||||
|
auth_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity for ServerModel {
|
impl Entity for ServerModel {
|
||||||
@@ -188,6 +195,7 @@ impl ServerModel {
|
|||||||
host_id,
|
host_id,
|
||||||
executors: HashMap::new(),
|
executors: HashMap::new(),
|
||||||
pending_file_ops: PendingFileOps::new(),
|
pending_file_ops: PendingFileOps::new(),
|
||||||
|
auth_token: None,
|
||||||
};
|
};
|
||||||
// Subscribe to FileModel and RepoMetadataModel events
|
// Subscribe to FileModel and RepoMetadataModel events
|
||||||
// file operation results and repo metadata pushes are forwarded to all
|
// file operation results and repo metadata pushes are forwarded to all
|
||||||
@@ -377,7 +385,13 @@ impl ServerModel {
|
|||||||
let request_id = RequestId::from(msg.request_id);
|
let request_id = RequestId::from(msg.request_id);
|
||||||
|
|
||||||
let outcome = match msg.message {
|
let outcome = match msg.message {
|
||||||
Some(client_message::Message::Initialize(_)) => self.handle_initialize(&request_id),
|
Some(client_message::Message::Initialize(msg)) => {
|
||||||
|
self.handle_initialize(msg, &request_id)
|
||||||
|
}
|
||||||
|
Some(client_message::Message::Authenticate(msg)) => {
|
||||||
|
self.handle_authenticate(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Some(client_message::Message::SessionBootstrapped(msg)) => {
|
Some(client_message::Message::SessionBootstrapped(msg)) => {
|
||||||
self.handle_session_bootstrapped(msg);
|
self.handle_session_bootstrapped(msg);
|
||||||
return;
|
return;
|
||||||
@@ -497,8 +511,11 @@ impl ServerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handles `Initialize` by returning the server version and host id.
|
/// Handles `Initialize` by returning the server version and host id.
|
||||||
fn handle_initialize(&self, request_id: &RequestId) -> HandlerOutcome {
|
fn handle_initialize(&mut self, msg: Initialize, request_id: &RequestId) -> HandlerOutcome {
|
||||||
log::info!("Handling Initialize (request_id={request_id})");
|
log::info!("Handling Initialize (request_id={request_id})");
|
||||||
|
if !msg.auth_token.is_empty() {
|
||||||
|
self.auth_token = Some(msg.auth_token);
|
||||||
|
}
|
||||||
let server_version = ChannelState::app_version()
|
let server_version = ChannelState::app_version()
|
||||||
.unwrap_or(env!("CARGO_PKG_VERSION"))
|
.unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -510,6 +527,20 @@ impl ServerModel {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles `Authenticate` by replacing the daemon-wide credential.
|
||||||
|
/// This is a notification — no response is sent.
|
||||||
|
fn handle_authenticate(&mut self, msg: Authenticate) {
|
||||||
|
if msg.auth_token.is_empty() {
|
||||||
|
log::warn!("Received Authenticate notification with empty auth token; ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.auth_token = Some(msg.auth_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_token(&self) -> Option<&str> {
|
||||||
|
self.auth_token.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles `Abort` by cancelling the in-progress request it targets.
|
/// Handles `Abort` by cancelling the in-progress request it targets.
|
||||||
/// This is a notification — no response is sent.
|
/// This is a notification — no response is sent.
|
||||||
fn handle_abort(&mut self, abort: Abort, request_id: &RequestId) {
|
fn handle_abort(&mut self, abort: Abort, request_id: &RequestId) {
|
||||||
@@ -1074,3 +1105,7 @@ fn file_context_result_to_proto(result: ReadFileContextResult) -> ReadFileContex
|
|||||||
failed_files,
|
failed_files,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "server_model_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::super::proto::{Authenticate, Initialize};
|
||||||
|
use super::super::protocol::RequestId;
|
||||||
|
use super::{PendingFileOps, ServerModel};
|
||||||
|
|
||||||
|
fn test_model() -> ServerModel {
|
||||||
|
ServerModel {
|
||||||
|
connection_senders: HashMap::new(),
|
||||||
|
snapshot_sent_roots_by_connection: HashMap::new(),
|
||||||
|
grace_timer_cancel: None,
|
||||||
|
in_progress: HashMap::new(),
|
||||||
|
host_id: "test-host-id".to_string(),
|
||||||
|
executors: HashMap::new(),
|
||||||
|
pending_file_ops: PendingFileOps::new(),
|
||||||
|
auth_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_id() -> RequestId {
|
||||||
|
RequestId::from("test-request".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_model_starts_without_auth_token() {
|
||||||
|
let model = test_model();
|
||||||
|
|
||||||
|
assert_eq!(model.auth_token(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialize_with_auth_token_stores_token() {
|
||||||
|
let mut model = test_model();
|
||||||
|
|
||||||
|
model.handle_initialize(
|
||||||
|
Initialize {
|
||||||
|
auth_token: "initial-token".to_string(),
|
||||||
|
},
|
||||||
|
&request_id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(model.auth_token(), Some("initial-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_initialize_preserves_existing_auth_token() {
|
||||||
|
let mut model = test_model();
|
||||||
|
model.handle_initialize(
|
||||||
|
Initialize {
|
||||||
|
auth_token: "initial-token".to_string(),
|
||||||
|
},
|
||||||
|
&request_id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
model.handle_initialize(
|
||||||
|
Initialize {
|
||||||
|
auth_token: String::new(),
|
||||||
|
},
|
||||||
|
&request_id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(model.auth_token(), Some("initial-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticate_with_auth_token_replaces_auth_token() {
|
||||||
|
let mut model = test_model();
|
||||||
|
model.handle_initialize(
|
||||||
|
Initialize {
|
||||||
|
auth_token: "initial-token".to_string(),
|
||||||
|
},
|
||||||
|
&request_id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
model.handle_authenticate(Authenticate {
|
||||||
|
auth_token: "rotated-token".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(model.auth_token(), Some("rotated-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_authenticate_preserves_existing_auth_token() {
|
||||||
|
let mut model = test_model();
|
||||||
|
model.handle_initialize(
|
||||||
|
Initialize {
|
||||||
|
auth_token: "initial-token".to_string(),
|
||||||
|
},
|
||||||
|
&request_id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
model.handle_authenticate(Authenticate {
|
||||||
|
auth_token: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(model.auth_token(), Some("initial-token"));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
//! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install
|
//! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install
|
||||||
//! the remote server binary and to launch the `remote-server-proxy` process
|
//! the remote server binary and to launch the `remote-server-proxy` process
|
||||||
//! whose stdin/stdout become the protocol channel.
|
//! whose stdin/stdout become the protocol channel.
|
||||||
|
use std::fmt;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -12,8 +13,11 @@ use std::sync::Arc;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use warpui::r#async::executor;
|
use warpui::r#async::executor;
|
||||||
|
|
||||||
|
use remote_server::auth::RemoteServerAuthContext;
|
||||||
use remote_server::client::RemoteServerClient;
|
use remote_server::client::RemoteServerClient;
|
||||||
use remote_server::setup::{self, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT};
|
use remote_server::setup::{
|
||||||
|
self, remote_server_daemon_dir, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT,
|
||||||
|
};
|
||||||
use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args};
|
use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args};
|
||||||
use remote_server::transport::{Connection, RemoteTransport};
|
use remote_server::transport::{Connection, RemoteTransport};
|
||||||
|
|
||||||
@@ -23,16 +27,54 @@ use remote_server::transport::{Connection, RemoteTransport};
|
|||||||
/// process (`ssh -N -o ControlMaster=yes -o ControlPath=<path>`). All SSH
|
/// process (`ssh -N -o ControlMaster=yes -o ControlPath=<path>`). All SSH
|
||||||
/// commands (binary check, install, proxy launch) are multiplexed through
|
/// commands (binary check, install, proxy launch) are multiplexed through
|
||||||
/// this socket without re-authenticating.
|
/// this socket without re-authenticating.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct SshTransport {
|
pub struct SshTransport {
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
|
auth_context: Arc<RemoteServerAuthContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SshTransport {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("SshTransport")
|
||||||
|
.field("socket_path", &self.socket_path)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshTransport {
|
impl SshTransport {
|
||||||
pub fn new(socket_path: PathBuf) -> Self {
|
pub fn new(socket_path: PathBuf, auth_context: Arc<RemoteServerAuthContext>) -> Self {
|
||||||
Self { socket_path }
|
Self {
|
||||||
|
socket_path,
|
||||||
|
auth_context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_path(&self) -> &PathBuf {
|
||||||
|
&self.socket_path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_daemon_socket_path(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/server.sock",
|
||||||
|
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_daemon_pid_path(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/server.pid",
|
||||||
|
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_proxy_command(&self) -> String {
|
||||||
|
let binary = remote_server::setup::remote_server_binary();
|
||||||
|
let identity_key = self.auth_context.remote_server_identity_key();
|
||||||
|
let quoted_identity_key = shell_words::quote(&identity_key);
|
||||||
|
format!("{binary} remote-server-proxy --identity-key {quoted_identity_key}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteTransport for SshTransport {
|
impl RemoteTransport for SshTransport {
|
||||||
fn detect_platform(
|
fn detect_platform(
|
||||||
&self,
|
&self,
|
||||||
@@ -100,10 +142,10 @@ impl RemoteTransport for SshTransport {
|
|||||||
executor: Arc<executor::Background>,
|
executor: Arc<executor::Background>,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Connection>> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Result<Connection>> + Send>> {
|
||||||
let socket_path = self.socket_path.clone();
|
let socket_path = self.socket_path.clone();
|
||||||
|
let remote_proxy_command = self.remote_proxy_command();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let binary = setup::remote_server_binary();
|
|
||||||
let mut args = ssh_args(&socket_path);
|
let mut args = ssh_args(&socket_path);
|
||||||
args.push(format!("{binary} remote-server-proxy"));
|
args.push(remote_proxy_command);
|
||||||
|
|
||||||
// `kill_on_drop(true)` pairs with ownership of the `Child` being
|
// `kill_on_drop(true)` pairs with ownership of the `Child` being
|
||||||
// returned in the [`Connection`] below: the
|
// returned in the [`Connection`] below: the
|
||||||
@@ -142,3 +184,28 @@ impl RemoteTransport for SshTransport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use warpui::r#async::BoxFuture;
|
||||||
|
fn static_auth_context() -> Arc<RemoteServerAuthContext> {
|
||||||
|
Arc::new(RemoteServerAuthContext::new(
|
||||||
|
|| -> BoxFuture<'static, Option<String>> { Box::pin(async { None }) },
|
||||||
|
|| "user id/with spaces".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_proxy_command_quotes_identity_key() {
|
||||||
|
let transport = SshTransport::new(
|
||||||
|
PathBuf::from("/tmp/control-master.sock"),
|
||||||
|
static_auth_context(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = transport.remote_proxy_command();
|
||||||
|
|
||||||
|
assert!(command.contains("remote-server-proxy --identity-key"));
|
||||||
|
assert!(command.contains("'user id/with spaces'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
mod proxy;
|
mod proxy;
|
||||||
|
|
||||||
use super::server_model::{ConnectionId, ServerModel};
|
use super::server_model::{ConnectionId, ServerModel};
|
||||||
|
use std::fs::Permissions;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use warpui::r#async::executor;
|
use warpui::r#async::executor;
|
||||||
|
|
||||||
/// Run the `remote-server-proxy` subcommand.
|
/// Run the `remote-server-proxy` subcommand.
|
||||||
@@ -21,11 +23,11 @@ use warpui::r#async::executor;
|
|||||||
/// Ensures the daemon is running (starting it if necessary), then bridges
|
/// Ensures the daemon is running (starting it if necessary), then bridges
|
||||||
/// this process's stdin/stdout to the daemon's Unix socket for the lifetime
|
/// this process's stdin/stdout to the daemon's Unix socket for the lifetime
|
||||||
/// of the SSH session.
|
/// of the SSH session.
|
||||||
pub fn run_proxy() -> anyhow::Result<()> {
|
pub fn run_proxy(identity_key: String) -> anyhow::Result<()> {
|
||||||
env_logger::Builder::from_default_env()
|
env_logger::Builder::from_default_env()
|
||||||
.target(env_logger::Target::Stderr)
|
.target(env_logger::Target::Stderr)
|
||||||
.init();
|
.init();
|
||||||
proxy::run()
|
proxy::run(&identity_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the `remote-server-daemon` subcommand.
|
/// Run the `remote-server-daemon` subcommand.
|
||||||
@@ -33,7 +35,7 @@ pub fn run_proxy() -> anyhow::Result<()> {
|
|||||||
/// Binds a Unix domain socket and writes a PID file, then delegates the
|
/// Binds a Unix domain socket and writes a PID file, then delegates the
|
||||||
/// WarpUI app startup to [`super::run_daemon_app`] with the Unix-specific
|
/// WarpUI app startup to [`super::run_daemon_app`] with the Unix-specific
|
||||||
/// `ServerModel` constructor.
|
/// `ServerModel` constructor.
|
||||||
pub fn run_daemon() -> anyhow::Result<()> {
|
pub fn run_daemon(identity_key: String) -> anyhow::Result<()> {
|
||||||
// Log to a rotating file so daemon output is preserved across invocations.
|
// Log to a rotating file so daemon output is preserved across invocations.
|
||||||
// The file is written to the same directory as client logs (~/Library/Logs
|
// The file is written to the same directory as client logs (~/Library/Logs
|
||||||
// on macOS, ~/.local/share/warp-terminal on Linux). Since the daemon runs
|
// on macOS, ~/.local/share/warp-terminal on Linux). Since the daemon runs
|
||||||
@@ -43,19 +45,19 @@ pub fn run_daemon() -> anyhow::Result<()> {
|
|||||||
log_destination: Some(warp_logging::LogDestination::File),
|
log_destination: Some(warp_logging::LogDestination::File),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// socket_path: ~/.warp[-channel]/remote-server/server.sock
|
// socket_path: ~/.warp[-channel]/remote-server/{identity_key}/server.sock
|
||||||
// The Unix domain socket the daemon binds on. Proxy processes connect
|
// The Unix domain socket the daemon binds on. Proxy processes connect
|
||||||
// to it and bridge their SSH stdio channel through it.
|
// to it and bridge their SSH stdio channel through it.
|
||||||
//
|
//
|
||||||
// pid_path: ~/.warp[-channel]/remote-server/server.pid
|
// pid_path: ~/.warp[-channel]/remote-server/{identity_key}/server.pid
|
||||||
// Contains the daemon's PID. Proxy processes read it and use
|
// Contains the daemon's PID. Proxy processes read it and use
|
||||||
// kill(pid, 0) to detect whether the daemon is still alive before
|
// kill(pid, 0) to detect whether the daemon is still alive before
|
||||||
// deciding whether to start a new one.
|
// deciding whether to start a new one.
|
||||||
let socket_path = proxy::socket_path();
|
let socket_path = proxy::socket_path(&identity_key);
|
||||||
let pid_path = proxy::pid_path();
|
let pid_path = proxy::pid_path(&identity_key);
|
||||||
|
|
||||||
if let Some(parent) = socket_path.parent() {
|
if let Some(parent) = socket_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
proxy::ensure_private_daemon_dir(parent)?;
|
||||||
}
|
}
|
||||||
if socket_path.exists() {
|
if socket_path.exists() {
|
||||||
std::fs::remove_file(&socket_path)?;
|
std::fs::remove_file(&socket_path)?;
|
||||||
@@ -64,6 +66,7 @@ pub fn run_daemon() -> anyhow::Result<()> {
|
|||||||
// Bind with std (no async runtime needed yet); converted to
|
// Bind with std (no async runtime needed yet); converted to
|
||||||
// async_io::Async inside the closure where the executor is active.
|
// async_io::Async inside the closure where the executor is active.
|
||||||
let listener = std::os::unix::net::UnixListener::bind(&socket_path)?;
|
let listener = std::os::unix::net::UnixListener::bind(&socket_path)?;
|
||||||
|
std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600))?;
|
||||||
// async_io::Async::new() requires non-blocking mode.
|
// async_io::Async::new() requires non-blocking mode.
|
||||||
listener.set_nonblocking(true)?;
|
listener.set_nonblocking(true)?;
|
||||||
log::info!("Daemon bound to {}", socket_path.display());
|
log::info!("Daemon bound to {}", socket_path.display());
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
//! 4. Connect to `server.sock` and bridge stdin/stdout to the socket using
|
//! 4. Connect to `server.sock` and bridge stdin/stdout to the socket using
|
||||||
//! the existing 4-byte length-prefixed frame format.
|
//! the existing 4-byte length-prefixed frame format.
|
||||||
|
|
||||||
|
use std::fs::Permissions;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::os::unix::io::AsRawFd;
|
use std::os::unix::io::AsRawFd;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
@@ -18,30 +20,37 @@ use std::time::Duration;
|
|||||||
use super::super::setup;
|
use super::super::setup;
|
||||||
|
|
||||||
/// Path to the daemon's Unix domain socket.
|
/// Path to the daemon's Unix domain socket.
|
||||||
pub(super) fn socket_path() -> PathBuf {
|
pub(super) fn socket_path(identity_key: &str) -> PathBuf {
|
||||||
let dir = setup::remote_server_dir();
|
let dir = setup::remote_server_daemon_dir(identity_key);
|
||||||
let expanded = shellexpand::tilde(&dir).into_owned();
|
let expanded = shellexpand::tilde(&dir).into_owned();
|
||||||
PathBuf::from(expanded).join("server.sock")
|
PathBuf::from(expanded).join("server.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the daemon's PID file (also used as the flock target).
|
/// Path to the daemon's PID file (also used as the flock target).
|
||||||
pub(super) fn pid_path() -> PathBuf {
|
pub(super) fn pid_path(identity_key: &str) -> PathBuf {
|
||||||
let dir = setup::remote_server_dir();
|
let dir = setup::remote_server_daemon_dir(identity_key);
|
||||||
let expanded = shellexpand::tilde(&dir).into_owned();
|
let expanded = shellexpand::tilde(&dir).into_owned();
|
||||||
PathBuf::from(expanded).join("server.pid")
|
PathBuf::from(expanded).join("server.pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensures the daemon directory exists with owner-only permissions.
|
||||||
|
pub(super) fn ensure_private_daemon_dir(path: &std::path::Path) -> anyhow::Result<()> {
|
||||||
|
std::fs::create_dir_all(path)?;
|
||||||
|
std::fs::set_permissions(path, Permissions::from_mode(0o700))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Entry point for `remote-server-proxy`.
|
/// Entry point for `remote-server-proxy`.
|
||||||
///
|
///
|
||||||
/// Ensures the daemon is running, then bridges stdin/stdout to the daemon's
|
/// Ensures the daemon is running, then bridges stdin/stdout to the daemon's
|
||||||
/// Unix socket for the lifetime of this SSH session.
|
/// Unix socket for the lifetime of this SSH session.
|
||||||
pub fn run() -> anyhow::Result<()> {
|
pub fn run(identity_key: &str) -> anyhow::Result<()> {
|
||||||
let socket_path = socket_path();
|
let socket_path = socket_path(identity_key);
|
||||||
let pid_path = pid_path();
|
let pid_path = pid_path(identity_key);
|
||||||
|
|
||||||
// Ensure the parent directory exists.
|
// Ensure the parent directory exists.
|
||||||
if let Some(parent) = socket_path.parent() {
|
if let Some(parent) = socket_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
ensure_private_daemon_dir(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Acquire exclusive flock on the PID file --------------------------------
|
// ---- Acquire exclusive flock on the PID file --------------------------------
|
||||||
@@ -83,6 +92,8 @@ pub fn run() -> anyhow::Result<()> {
|
|||||||
let exe = std::env::current_exe()?;
|
let exe = std::env::current_exe()?;
|
||||||
let mut cmd = command::blocking::Command::new(&exe);
|
let mut cmd = command::blocking::Command::new(&exe);
|
||||||
cmd.arg("remote-server-daemon")
|
cmd.arg("remote-server-daemon")
|
||||||
|
.arg("--identity-key")
|
||||||
|
.arg(identity_key)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::null());
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ use parking_lot::{Mutex, RwLock};
|
|||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -355,7 +356,7 @@ cfg_if::cfg_if! {
|
|||||||
/// An event related to the server API itself (and not a particular API call).
|
/// An event related to the server API itself (and not a particular API call).
|
||||||
/// Most errors should be handled in callbacks to individual APIs, rather than sent over the
|
/// Most errors should be handled in callbacks to individual APIs, rather than sent over the
|
||||||
/// server API channel.
|
/// server API channel.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ServerApiEvent {
|
pub enum ServerApiEvent {
|
||||||
/// We made a staging API call that was blocked, which may indicate a firewall misconfiguration.
|
/// We made a staging API call that was blocked, which may indicate a firewall misconfiguration.
|
||||||
StagingAccessBlocked,
|
StagingAccessBlocked,
|
||||||
@@ -364,6 +365,25 @@ pub enum ServerApiEvent {
|
|||||||
NeedsReauth,
|
NeedsReauth,
|
||||||
/// The user's account has been disabled.
|
/// The user's account has been disabled.
|
||||||
UserAccountDisabled,
|
UserAccountDisabled,
|
||||||
|
/// The current bearer token was refreshed.
|
||||||
|
AccessTokenRefreshed {
|
||||||
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ServerApiEvent {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::StagingAccessBlocked => f.write_str("StagingAccessBlocked"),
|
||||||
|
Self::NeedsReauth => f.write_str("NeedsReauth"),
|
||||||
|
Self::UserAccountDisabled => f.write_str("UserAccountDisabled"),
|
||||||
|
Self::AccessTokenRefreshed { .. } => f
|
||||||
|
.debug_struct("AccessTokenRefreshed")
|
||||||
|
.field("token", &"<redacted>")
|
||||||
|
.finish(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An API wrapper struct with methods to requests to warp-server.
|
/// An API wrapper struct with methods to requests to warp-server.
|
||||||
|
|||||||
@@ -268,6 +268,12 @@ impl AuthClient for ServerApi {
|
|||||||
let new_firebase_token_info = result?;
|
let new_firebase_token_info = result?;
|
||||||
self.auth_state
|
self.auth_state
|
||||||
.update_firebase_tokens(new_firebase_token_info.clone());
|
.update_firebase_tokens(new_firebase_token_info.clone());
|
||||||
|
let _ = self
|
||||||
|
.event_sender
|
||||||
|
.send(ServerApiEvent::AccessTokenRefreshed {
|
||||||
|
token: new_firebase_token_info.id_token.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
return Ok(AuthToken::Firebase(new_firebase_token_info.id_token));
|
return Ok(AuthToken::Firebase(new_firebase_token_info.id_token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
use crate::auth::auth_state::AuthStateProvider;
|
||||||
|
use crate::remote_server::auth_context::server_api_auth_context;
|
||||||
use instant::Instant;
|
use instant::Instant;
|
||||||
|
use remote_server::auth::RemoteServerAuthContext;
|
||||||
use settings::Setting;
|
use settings::Setting;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use warp_core::SessionId;
|
use warp_core::SessionId;
|
||||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
|
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
|
||||||
|
|
||||||
@@ -7,6 +12,7 @@ use crate::terminal::warpify::settings::SshExtensionInstallMode;
|
|||||||
|
|
||||||
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
|
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
|
||||||
use crate::remote_server::ssh_transport::SshTransport;
|
use crate::remote_server::ssh_transport::SshTransport;
|
||||||
|
use crate::server::server_api::ServerApiProvider;
|
||||||
use crate::terminal::model::session::{IsLegacySSHSession, SessionInfo};
|
use crate::terminal::model::session::{IsLegacySSHSession, SessionInfo};
|
||||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||||
use crate::terminal::warpify::settings::WarpifySettings;
|
use crate::terminal::warpify::settings::WarpifySettings;
|
||||||
@@ -59,6 +65,7 @@ enum SshInitState {
|
|||||||
pub struct RemoteServerController<T: EventLoopSender> {
|
pub struct RemoteServerController<T: EventLoopSender> {
|
||||||
pty_controller: WeakModelHandle<PtyController<T>>,
|
pty_controller: WeakModelHandle<PtyController<T>>,
|
||||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||||
|
auth_context: Arc<RemoteServerAuthContext>,
|
||||||
state: SshInitState,
|
state: SshInitState,
|
||||||
/// Whether the binary was installed during this setup flow.
|
/// Whether the binary was installed during this setup flow.
|
||||||
did_install: bool,
|
did_install: bool,
|
||||||
@@ -76,6 +83,10 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let auth_context = Arc::new(server_api_auth_context(
|
||||||
|
AuthStateProvider::as_ref(ctx).get().clone(),
|
||||||
|
ServerApiProvider::as_ref(ctx).get_auth_client(),
|
||||||
|
));
|
||||||
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| {
|
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| {
|
||||||
if let ModelEvent::SshInitShell {
|
if let ModelEvent::SshInitShell {
|
||||||
pending_session_info,
|
pending_session_info,
|
||||||
@@ -104,12 +115,25 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
RemoteServerManagerEvent::SessionConnectionFailed { session_id, .. } => {
|
RemoteServerManagerEvent::SessionConnectionFailed { session_id, .. } => {
|
||||||
me.on_session_connection_failed(*session_id, ctx);
|
me.on_session_connection_failed(*session_id, ctx);
|
||||||
}
|
}
|
||||||
_ => {}
|
RemoteServerManagerEvent::SessionConnecting { .. }
|
||||||
|
| RemoteServerManagerEvent::SessionDisconnected { .. }
|
||||||
|
| RemoteServerManagerEvent::SessionReconnected { .. }
|
||||||
|
| RemoteServerManagerEvent::SessionDeregistered { .. }
|
||||||
|
| RemoteServerManagerEvent::HostConnected { .. }
|
||||||
|
| RemoteServerManagerEvent::HostDisconnected { .. }
|
||||||
|
| RemoteServerManagerEvent::NavigatedToDirectory { .. }
|
||||||
|
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
|
||||||
|
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
|
||||||
|
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
|
||||||
|
| RemoteServerManagerEvent::SetupStateChanged { .. }
|
||||||
|
| RemoteServerManagerEvent::ClientRequestFailed { .. }
|
||||||
|
| RemoteServerManagerEvent::ServerMessageDecodingError { .. } => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
pty_controller,
|
pty_controller,
|
||||||
model_event_dispatcher,
|
model_event_dispatcher,
|
||||||
|
auth_context,
|
||||||
state: SshInitState::Idle,
|
state: SshInitState::Idle,
|
||||||
did_install: false,
|
did_install: false,
|
||||||
remote_platform: None,
|
remote_platform: None,
|
||||||
@@ -135,7 +159,6 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
};
|
};
|
||||||
let session_id = info.session_id;
|
let session_id = info.session_id;
|
||||||
let socket_path = socket_path.clone();
|
let socket_path = socket_path.clone();
|
||||||
|
|
||||||
debug_assert!(matches!(self.state, SshInitState::Idle));
|
debug_assert!(matches!(self.state, SshInitState::Idle));
|
||||||
match std::mem::replace(&mut self.state, SshInitState::Idle) {
|
match std::mem::replace(&mut self.state, SshInitState::Idle) {
|
||||||
SshInitState::Idle => {}
|
SshInitState::Idle => {}
|
||||||
@@ -158,8 +181,7 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
self.flush_stashed_bootstrap(old_info, ctx);
|
self.flush_stashed_bootstrap(old_info, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let transport = SshTransport::new(socket_path, self.auth_context.clone());
|
||||||
let transport = SshTransport::new(socket_path);
|
|
||||||
self.did_install = false;
|
self.did_install = false;
|
||||||
self.remote_platform = None;
|
self.remote_platform = None;
|
||||||
self.state = SshInitState::AwaitingCheck {
|
self.state = SshInitState::AwaitingCheck {
|
||||||
@@ -199,14 +221,13 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
|
let socket_path = transport.socket_path().clone();
|
||||||
self.state = SshInitState::AwaitingConnect {
|
self.state = SshInitState::AwaitingConnect {
|
||||||
session_id,
|
session_id,
|
||||||
session_info,
|
session_info,
|
||||||
setup_start,
|
setup_start,
|
||||||
};
|
};
|
||||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
self.connect_session_for_current_identity(session_id, socket_path, ctx);
|
||||||
mgr.connect_session(session_id, transport, ctx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let install_mode = *WarpifySettings::as_ref(ctx)
|
let install_mode = *WarpifySettings::as_ref(ctx)
|
||||||
@@ -404,14 +425,13 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
let socket_path = transport.socket_path().clone();
|
||||||
self.state = SshInitState::AwaitingConnect {
|
self.state = SshInitState::AwaitingConnect {
|
||||||
session_id,
|
session_id,
|
||||||
session_info,
|
session_info,
|
||||||
setup_start,
|
setup_start,
|
||||||
};
|
};
|
||||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
self.connect_session_for_current_identity(session_id, socket_path, ctx);
|
||||||
mgr.connect_session(session_id, transport, ctx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Binary install failed for {session_id:?}: {err}");
|
log::error!("Binary install failed for {session_id:?}: {err}");
|
||||||
@@ -419,4 +439,17 @@ impl<T: EventLoopSender> RemoteServerController<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connect_session_for_current_identity(
|
||||||
|
&mut self,
|
||||||
|
session_id: SessionId,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let transport = SshTransport::new(socket_path, self.auth_context.clone());
|
||||||
|
let auth_context = self.auth_context.clone();
|
||||||
|
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||||
|
mgr.connect_session(session_id, transport, auth_context, ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ message ClientMessage {
|
|||||||
DeleteFile delete_file = 8;
|
DeleteFile delete_file = 8;
|
||||||
RunCommandRequest run_command = 9;
|
RunCommandRequest run_command = 9;
|
||||||
ReadFileContextRequest read_file_context = 10;
|
ReadFileContextRequest read_file_context = 10;
|
||||||
|
Authenticate authenticate = 11;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Top-level envelope for all server → client messages.
|
// Top-level envelope for all server → client messages.
|
||||||
@@ -46,7 +47,21 @@ message ServerMessage {
|
|||||||
// ── Initialize handshake
|
// ── Initialize handshake
|
||||||
|
|
||||||
// Sent by the client immediately after connecting to negotiate the protocol.
|
// Sent by the client immediately after connecting to negotiate the protocol.
|
||||||
message Initialize {}
|
message Initialize {
|
||||||
|
// Optional bearer token used by the daemon for Warp-server requests.
|
||||||
|
// Empty means no credential was available and does not clear an existing
|
||||||
|
// daemon credential.
|
||||||
|
string auth_token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client when its bearer credential rotates after initialization.
|
||||||
|
// This is a notification (fire-and-forget) — the server does not send a response.
|
||||||
|
message Authenticate {
|
||||||
|
// Optional bearer token used by the daemon for Warp-server requests.
|
||||||
|
// Empty means no credential was available and does not clear an existing
|
||||||
|
// daemon credential.
|
||||||
|
string auth_token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Sent by the client to cancel an in-progress request.
|
// Sent by the client to cancel an in-progress request.
|
||||||
// This is a notification (fire-and-forget) — the server does not send a response.
|
// This is a notification (fire-and-forget) — the server does not send a response.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use warpui::r#async::BoxFuture;
|
||||||
|
type GetAuthTokenFn = dyn Fn() -> BoxFuture<'static, Option<String>> + Send + Sync;
|
||||||
|
type RemoteServerIdentityKeyFn = dyn Fn() -> String + Send + Sync;
|
||||||
|
|
||||||
|
/// App-supplied authentication context for transport-agnostic remote-server code.
|
||||||
|
///
|
||||||
|
/// Bearer tokens are delivered only through protocol messages. Identity keys
|
||||||
|
/// are non-secret stable partition keys used to select the remote daemon's
|
||||||
|
/// socket/PID directory.
|
||||||
|
///
|
||||||
|
/// This keeps the `remote_server` crate decoupled from app-side auth/server API
|
||||||
|
/// types while still allowing initial connect and reconnect handshakes to fetch
|
||||||
|
/// the current app credential.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RemoteServerAuthContext {
|
||||||
|
get_auth_token: Arc<GetAuthTokenFn>,
|
||||||
|
remote_server_identity_key: Arc<RemoteServerIdentityKeyFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteServerAuthContext {
|
||||||
|
pub fn new(
|
||||||
|
get_auth_token: impl Fn() -> BoxFuture<'static, Option<String>> + Send + Sync + 'static,
|
||||||
|
remote_server_identity_key: impl Fn() -> String + Send + Sync + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
get_auth_token: Arc::new(get_auth_token),
|
||||||
|
remote_server_identity_key: Arc::new(remote_server_identity_key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_auth_token(&self) -> BoxFuture<'static, Option<String>> {
|
||||||
|
(self.get_auth_token)()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_server_identity_key(&self) -> String {
|
||||||
|
(self.remote_server_identity_key)()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@ use futures::io::{AsyncRead, AsyncWrite};
|
|||||||
use warpui::r#async::{executor, FutureExt as _};
|
use warpui::r#async::{executor, FutureExt as _};
|
||||||
|
|
||||||
use crate::proto::{
|
use crate::proto::{
|
||||||
client_message, server_message, Abort, ClientMessage, DeleteFile, ErrorCode, Initialize,
|
client_message, server_message, Abort, Authenticate, ClientMessage, DeleteFile, ErrorCode,
|
||||||
InitializeResponse, LoadRepoMetadataDirectoryResponse, NavigatedToDirectoryResponse,
|
Initialize, InitializeResponse, LoadRepoMetadataDirectoryResponse,
|
||||||
ReadFileContextRequest, ReadFileContextResponse, RunCommandRequest, RunCommandResponse,
|
NavigatedToDirectoryResponse, ReadFileContextRequest, ReadFileContextResponse,
|
||||||
ServerMessage, SessionBootstrapped, WriteFile,
|
RunCommandRequest, RunCommandResponse, ServerMessage, SessionBootstrapped, WriteFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::protocol::{self, ProtocolError, RequestId};
|
use crate::protocol::{self, ProtocolError, RequestId};
|
||||||
@@ -182,11 +182,16 @@ impl RemoteServerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends an `Initialize` request and awaits the `InitializeResponse`.
|
/// Sends an `Initialize` request and awaits the `InitializeResponse`.
|
||||||
pub async fn initialize(&self) -> Result<InitializeResponse, ClientError> {
|
pub async fn initialize(
|
||||||
|
&self,
|
||||||
|
auth_token: Option<&str>,
|
||||||
|
) -> Result<InitializeResponse, ClientError> {
|
||||||
let request_id = RequestId::new();
|
let request_id = RequestId::new();
|
||||||
let msg = ClientMessage {
|
let msg = ClientMessage {
|
||||||
request_id: request_id.to_string(),
|
request_id: request_id.to_string(),
|
||||||
message: Some(client_message::Message::Initialize(Initialize {})),
|
message: Some(client_message::Message::Initialize(Initialize {
|
||||||
|
auth_token: auth_token.unwrap_or_default().to_owned(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = self.send_request(request_id, msg).await?;
|
let response = self.send_request(request_id, msg).await?;
|
||||||
@@ -200,6 +205,18 @@ impl RemoteServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends an `Authenticate` notification to rotate the daemon-wide
|
||||||
|
/// credential after initialization.
|
||||||
|
pub fn authenticate(&self, auth_token: &str) {
|
||||||
|
let msg = ClientMessage {
|
||||||
|
request_id: String::new(),
|
||||||
|
message: Some(client_message::Message::Authenticate(Authenticate {
|
||||||
|
auth_token: auth_token.to_owned(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
self.send_notification(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends a `SessionBootstrapped` notification (fire-and-forget) so the
|
/// Sends a `SessionBootstrapped` notification (fire-and-forget) so the
|
||||||
/// server can create a `LocalCommandExecutor` for the session.
|
/// server can create a `LocalCommandExecutor` for the session.
|
||||||
pub fn notify_session_bootstrapped(
|
pub fn notify_session_bootstrapped(
|
||||||
|
|||||||
@@ -75,11 +75,69 @@ async fn initialize_round_trip() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = client.initialize().await.unwrap();
|
let resp = client.initialize(None).await.unwrap();
|
||||||
assert_eq!(resp.server_version, "test-0.1.0");
|
assert_eq!(resp.server_version, "test-0.1.0");
|
||||||
assert_eq!(resp.host_id, "test-host-id");
|
assert_eq!(resp.host_id, "test-host-id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn initialize_sends_empty_auth_token_when_none() {
|
||||||
|
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||||
|
match &msg.message {
|
||||||
|
Some(client_message::Message::Initialize(init)) => {
|
||||||
|
assert!(init.auth_token.is_empty());
|
||||||
|
}
|
||||||
|
other => panic!("Expected Initialize, got {other:?}"),
|
||||||
|
}
|
||||||
|
server_message::Message::InitializeResponse(InitializeResponse {
|
||||||
|
server_version: "test-0.1.0".to_string(),
|
||||||
|
host_id: "test-host-id".to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
client.initialize(None).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn initialize_sends_auth_token_when_provided() {
|
||||||
|
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||||
|
match &msg.message {
|
||||||
|
Some(client_message::Message::Initialize(init)) => {
|
||||||
|
assert_eq!(init.auth_token, "secret-token");
|
||||||
|
}
|
||||||
|
other => panic!("Expected Initialize, got {other:?}"),
|
||||||
|
}
|
||||||
|
server_message::Message::InitializeResponse(InitializeResponse {
|
||||||
|
server_version: "test-0.1.0".to_string(),
|
||||||
|
host_id: "test-host-id".to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
client.initialize(Some("secret-token")).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authenticate_sends_fire_and_forget_message() {
|
||||||
|
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||||
|
let (server_read, _server_write) = tokio::io::split(server_stream);
|
||||||
|
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||||
|
let executor = executor::Background::default();
|
||||||
|
let (client, _event_rx) =
|
||||||
|
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||||
|
|
||||||
|
client.authenticate("rotated-secret");
|
||||||
|
|
||||||
|
let msg = protocol::read_client_message(&mut server_read.compat())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
match msg.message {
|
||||||
|
Some(client_message::Message::Authenticate(auth)) => {
|
||||||
|
assert_eq!(auth.auth_token, "rotated-secret");
|
||||||
|
}
|
||||||
|
other => panic!("Expected Authenticate, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn disconnected_on_closed_stream() {
|
async fn disconnected_on_closed_stream() {
|
||||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||||
@@ -92,7 +150,7 @@ async fn disconnected_on_closed_stream() {
|
|||||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||||
|
|
||||||
// An initialize call on a dead stream must complete with an error rather than hang.
|
// An initialize call on a dead stream must complete with an error rather than hang.
|
||||||
let result = client.initialize().await;
|
let result = client.initialize(None).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
||||||
// The reader task should detect EOF and emit a Disconnected event.
|
// The reader task should detect EOF and emit a Disconnected event.
|
||||||
@@ -148,7 +206,9 @@ async fn concurrent_in_flight_requests() {
|
|||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
let c = std::sync::Arc::clone(&client);
|
let c = std::sync::Arc::clone(&client);
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
c.initialize().await.expect("concurrent initialize failed")
|
c.initialize(None)
|
||||||
|
.await
|
||||||
|
.expect("concurrent initialize failed")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod host_id;
|
pub mod host_id;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::auth::RemoteServerAuthContext;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::client::ClientEvent;
|
use crate::client::ClientEvent;
|
||||||
use crate::client::RemoteServerClient;
|
use crate::client::RemoteServerClient;
|
||||||
@@ -33,7 +34,9 @@ struct ReconnectParams {
|
|||||||
host_id: HostId,
|
host_id: HostId,
|
||||||
exit_status: Option<RemoteServerExitStatus>,
|
exit_status: Option<RemoteServerExitStatus>,
|
||||||
transport: Arc<dyn RemoteTransport>,
|
transport: Arc<dyn RemoteTransport>,
|
||||||
|
auth_context: Arc<RemoteServerAuthContext>,
|
||||||
control_path: Option<PathBuf>,
|
control_path: Option<PathBuf>,
|
||||||
|
identity_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error from [`RemoteServerManager::run_connect_and_handshake`] that
|
/// Error from [`RemoteServerManager::run_connect_and_handshake`] that
|
||||||
@@ -151,6 +154,12 @@ pub enum RemoteSessionState {
|
|||||||
Connected {
|
Connected {
|
||||||
client: Arc<RemoteServerClient>,
|
client: Arc<RemoteServerClient>,
|
||||||
host_id: HostId,
|
host_id: HostId,
|
||||||
|
/// Identity key that was active when this session was established.
|
||||||
|
/// Used by `rotate_auth_token` to ensure token rotation notifications
|
||||||
|
/// are only delivered to sessions that belong to the current user
|
||||||
|
/// identity, preventing a stale session for a previous identity from
|
||||||
|
/// receiving a different user's bearer token.
|
||||||
|
identity_key: String,
|
||||||
/// The transport's owning `Child`. See `Initializing::_child`.
|
/// The transport's owning `Child`. See `Initializing::_child`.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
_child: async_process::Child,
|
_child: async_process::Child,
|
||||||
@@ -335,6 +344,10 @@ pub struct RemoteServerManager {
|
|||||||
/// remote server daemon on every (re)connect. Persists until
|
/// remote server daemon on every (re)connect. Persists until
|
||||||
/// `deregister_session`.
|
/// `deregister_session`.
|
||||||
session_bootstrap_info: HashMap<SessionId, SessionBootstrapInfo>,
|
session_bootstrap_info: HashMap<SessionId, SessionBootstrapInfo>,
|
||||||
|
/// App auth context used for connection-time `Initialize` and future
|
||||||
|
/// reconnect handshakes.
|
||||||
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||||
|
auth_context: Option<Arc<RemoteServerAuthContext>>,
|
||||||
/// Detected remote platform per session, populated during the binary check
|
/// Detected remote platform per session, populated during the binary check
|
||||||
/// phase via `detect_platform()`. Used for telemetry.
|
/// phase via `detect_platform()`. Used for telemetry.
|
||||||
session_platforms: HashMap<SessionId, RemotePlatform>,
|
session_platforms: HashMap<SessionId, RemotePlatform>,
|
||||||
@@ -354,6 +367,7 @@ impl RemoteServerManager {
|
|||||||
spawner: ctx.spawner(),
|
spawner: ctx.spawner(),
|
||||||
last_navigated_path: HashMap::new(),
|
last_navigated_path: HashMap::new(),
|
||||||
session_bootstrap_info: HashMap::new(),
|
session_bootstrap_info: HashMap::new(),
|
||||||
|
auth_context: None,
|
||||||
session_platforms: HashMap::new(),
|
session_platforms: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,6 +497,7 @@ impl RemoteServerManager {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
transport: T,
|
transport: T,
|
||||||
|
auth_context: Arc<RemoteServerAuthContext>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) where
|
) where
|
||||||
T: RemoteTransport + 'static,
|
T: RemoteTransport + 'static,
|
||||||
@@ -504,6 +519,7 @@ impl RemoteServerManager {
|
|||||||
|
|
||||||
self.sessions
|
self.sessions
|
||||||
.insert(session_id, RemoteSessionState::Connecting);
|
.insert(session_id, RemoteSessionState::Connecting);
|
||||||
|
self.auth_context = Some(Arc::clone(&auth_context));
|
||||||
ctx.emit(RemoteServerManagerEvent::SessionConnecting { session_id });
|
ctx.emit(RemoteServerManagerEvent::SessionConnecting { session_id });
|
||||||
|
|
||||||
let spawner = self.spawner.clone();
|
let spawner = self.spawner.clone();
|
||||||
@@ -511,12 +527,17 @@ impl RemoteServerManager {
|
|||||||
// Wrap the transport in an Arc so it can be stored on `Connected`
|
// Wrap the transport in an Arc so it can be stored on `Connected`
|
||||||
// for reconnection after a spontaneous disconnect.
|
// for reconnection after a spontaneous disconnect.
|
||||||
let transport: Arc<dyn RemoteTransport> = Arc::new(transport);
|
let transport: Arc<dyn RemoteTransport> = Arc::new(transport);
|
||||||
|
let auth_context_for_task = Arc::clone(&auth_context);
|
||||||
|
// Capture the identity key synchronously so it travels with the
|
||||||
|
// session and can be used to filter token-rotation notifications.
|
||||||
|
let identity_key = auth_context.remote_server_identity_key();
|
||||||
|
|
||||||
ctx.background_executor()
|
ctx.background_executor()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
match Self::run_connect_and_handshake(
|
match Self::run_connect_and_handshake(
|
||||||
session_id,
|
session_id,
|
||||||
&*transport,
|
&*transport,
|
||||||
|
&auth_context_for_task,
|
||||||
&spawner,
|
&spawner,
|
||||||
&executor,
|
&executor,
|
||||||
)
|
)
|
||||||
@@ -525,7 +546,13 @@ impl RemoteServerManager {
|
|||||||
Ok(host_id) => {
|
Ok(host_id) => {
|
||||||
let _ = spawner
|
let _ = spawner
|
||||||
.spawn(move |me, ctx| {
|
.spawn(move |me, ctx| {
|
||||||
me.mark_session_connected(session_id, host_id, transport, ctx);
|
me.mark_session_connected(
|
||||||
|
session_id,
|
||||||
|
host_id,
|
||||||
|
identity_key,
|
||||||
|
transport,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -562,13 +589,14 @@ impl RemoteServerManager {
|
|||||||
/// 1. Calls `transport.connect()` to establish streams.
|
/// 1. Calls `transport.connect()` to establish streams.
|
||||||
/// 2. Transitions the session to `Initializing` and starts draining the
|
/// 2. Transitions the session to `Initializing` and starts draining the
|
||||||
/// event channel.
|
/// event channel.
|
||||||
/// 3. Runs the initialize handshake.
|
/// 3. Runs the initialize handshake with the current auth token, if any.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(host_id)` on success, or a phase-tagged error.
|
/// Returns `Ok(host_id)` on success, or a phase-tagged error.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
async fn run_connect_and_handshake(
|
async fn run_connect_and_handshake(
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
transport: &dyn RemoteTransport,
|
transport: &dyn RemoteTransport,
|
||||||
|
auth_context: &RemoteServerAuthContext,
|
||||||
spawner: &ModelSpawner<Self>,
|
spawner: &ModelSpawner<Self>,
|
||||||
executor: &Arc<warpui::r#async::executor::Background>,
|
executor: &Arc<warpui::r#async::executor::Background>,
|
||||||
) -> Result<HostId, ConnectAndHandshakeError> {
|
) -> Result<HostId, ConnectAndHandshakeError> {
|
||||||
@@ -624,8 +652,9 @@ impl RemoteServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Initialize handshake.
|
// Phase 2: Initialize handshake.
|
||||||
|
let auth_token = auth_context.get_auth_token().await;
|
||||||
let resp = client
|
let resp = client
|
||||||
.initialize()
|
.initialize(auth_token.as_deref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ConnectAndHandshakeError::Initialize(anyhow::anyhow!("{e:#}")))?;
|
.map_err(|e| ConnectAndHandshakeError::Initialize(anyhow::anyhow!("{e:#}")))?;
|
||||||
Ok(HostId::new(resp.host_id))
|
Ok(HostId::new(resp.host_id))
|
||||||
@@ -732,6 +761,42 @@ impl RemoteServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rotates the daemon-wide auth credential on each connected remote host.
|
||||||
|
///
|
||||||
|
/// Only sessions whose stored `identity_key` matches the current identity
|
||||||
|
/// (from `auth_context`) receive the notification. This prevents a stale
|
||||||
|
/// session established under a previous user identity from receiving a
|
||||||
|
/// newly-rotated bearer token that belongs to a different user.
|
||||||
|
///
|
||||||
|
/// Within the matching identity, a daemon may have multiple client
|
||||||
|
/// connections. The credential is stored daemon-wide, so sending one
|
||||||
|
/// notification per connected host is sufficient.
|
||||||
|
pub fn rotate_auth_token(&self, token: String) {
|
||||||
|
let Some(ref auth_context) = self.auth_context else {
|
||||||
|
log::warn!("rotate_auth_token: no auth_context available, skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current_identity_key = auth_context.remote_server_identity_key();
|
||||||
|
let mut authenticated_hosts = HashSet::new();
|
||||||
|
for state in self.sessions.values() {
|
||||||
|
let RemoteSessionState::Connected {
|
||||||
|
client,
|
||||||
|
host_id,
|
||||||
|
identity_key,
|
||||||
|
..
|
||||||
|
} = state
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if identity_key != ¤t_identity_key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if authenticated_hosts.insert(host_id.clone()) {
|
||||||
|
client.authenticate(&token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the connection state for this session.
|
/// Returns the connection state for this session.
|
||||||
pub fn session(&self, session_id: SessionId) -> Option<&RemoteSessionState> {
|
pub fn session(&self, session_id: SessionId) -> Option<&RemoteSessionState> {
|
||||||
self.sessions.get(&session_id)
|
self.sessions.get(&session_id)
|
||||||
@@ -955,6 +1020,7 @@ impl RemoteServerManager {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
host_id: HostId,
|
host_id: HostId,
|
||||||
|
identity_key: String,
|
||||||
transport: Arc<dyn RemoteTransport>,
|
transport: Arc<dyn RemoteTransport>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
@@ -976,6 +1042,7 @@ impl RemoteServerManager {
|
|||||||
RemoteSessionState::Connected {
|
RemoteSessionState::Connected {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
host_id: host_id.clone(),
|
host_id: host_id.clone(),
|
||||||
|
identity_key,
|
||||||
_child,
|
_child,
|
||||||
control_path,
|
control_path,
|
||||||
transport,
|
transport,
|
||||||
@@ -1067,6 +1134,7 @@ impl RemoteServerManager {
|
|||||||
// with a transport available, and not being explicitly deregistered.
|
// with a transport available, and not being explicitly deregistered.
|
||||||
if let RemoteSessionState::Connected {
|
if let RemoteSessionState::Connected {
|
||||||
host_id,
|
host_id,
|
||||||
|
identity_key,
|
||||||
mut _child,
|
mut _child,
|
||||||
control_path,
|
control_path,
|
||||||
transport,
|
transport,
|
||||||
@@ -1076,7 +1144,24 @@ impl RemoteServerManager {
|
|||||||
let exit_status = Self::capture_exit_status(&mut _child, session_id);
|
let exit_status = Self::capture_exit_status(&mut _child, session_id);
|
||||||
// Drop the old child process explicitly before reconnecting.
|
// Drop the old child process explicitly before reconnecting.
|
||||||
drop(_child);
|
drop(_child);
|
||||||
|
let Some(auth_context) = self.auth_context.clone() else {
|
||||||
|
log::warn!(
|
||||||
|
"Spontaneous disconnect for session {session_id:?}, \
|
||||||
|
but no auth context is available for reconnect"
|
||||||
|
);
|
||||||
|
self.sessions
|
||||||
|
.insert(session_id, RemoteSessionState::Disconnected);
|
||||||
|
self.remove_from_host_index(&host_id, session_id);
|
||||||
|
ctx.emit(RemoteServerManagerEvent::SessionDisconnected {
|
||||||
|
session_id,
|
||||||
|
host_id: host_id.clone(),
|
||||||
|
exit_status,
|
||||||
|
});
|
||||||
|
if !self.host_to_sessions.contains_key(&host_id) {
|
||||||
|
ctx.emit(RemoteServerManagerEvent::HostDisconnected { host_id });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
log::info!(
|
log::info!(
|
||||||
"Spontaneous disconnect for session {session_id:?}, \
|
"Spontaneous disconnect for session {session_id:?}, \
|
||||||
will attempt reconnect (transport={transport:?})"
|
will attempt reconnect (transport={transport:?})"
|
||||||
@@ -1104,7 +1189,9 @@ impl RemoteServerManager {
|
|||||||
host_id,
|
host_id,
|
||||||
exit_status,
|
exit_status,
|
||||||
transport,
|
transport,
|
||||||
|
auth_context,
|
||||||
control_path,
|
control_path,
|
||||||
|
identity_key,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
@@ -1129,7 +1216,9 @@ impl RemoteServerManager {
|
|||||||
host_id,
|
host_id,
|
||||||
exit_status,
|
exit_status,
|
||||||
transport,
|
transport,
|
||||||
|
auth_context,
|
||||||
control_path,
|
control_path,
|
||||||
|
identity_key,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1149,6 +1238,7 @@ impl RemoteServerManager {
|
|||||||
let spawner = self.spawner.clone();
|
let spawner = self.spawner.clone();
|
||||||
let executor = ctx.background_executor().clone();
|
let executor = ctx.background_executor().clone();
|
||||||
let transport_clone = Arc::clone(&transport);
|
let transport_clone = Arc::clone(&transport);
|
||||||
|
let auth_context_for_task = Arc::clone(&auth_context);
|
||||||
|
|
||||||
ctx.background_executor()
|
ctx.background_executor()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
@@ -1168,6 +1258,7 @@ impl RemoteServerManager {
|
|||||||
match Self::run_connect_and_handshake(
|
match Self::run_connect_and_handshake(
|
||||||
session_id,
|
session_id,
|
||||||
&*transport_clone,
|
&*transport_clone,
|
||||||
|
&auth_context_for_task,
|
||||||
&spawner,
|
&spawner,
|
||||||
&executor,
|
&executor,
|
||||||
)
|
)
|
||||||
@@ -1188,6 +1279,7 @@ impl RemoteServerManager {
|
|||||||
me.mark_session_connected(
|
me.mark_session_connected(
|
||||||
session_id,
|
session_id,
|
||||||
new_host_id.clone(),
|
new_host_id.clone(),
|
||||||
|
identity_key,
|
||||||
transport,
|
transport,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
@@ -1225,7 +1317,9 @@ impl RemoteServerManager {
|
|||||||
host_id,
|
host_id,
|
||||||
exit_status,
|
exit_status,
|
||||||
transport,
|
transport,
|
||||||
|
auth_context,
|
||||||
control_path,
|
control_path,
|
||||||
|
identity_key,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use super::*;
|
|||||||
async fn round_trip_client_message() {
|
async fn round_trip_client_message() {
|
||||||
let msg = ClientMessage {
|
let msg = ClientMessage {
|
||||||
request_id: "test-123".to_string(),
|
request_id: "test-123".to_string(),
|
||||||
message: Some(client_message::Message::Initialize(Initialize {})),
|
message: Some(client_message::Message::Initialize(Initialize {
|
||||||
|
auth_token: String::new(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
@@ -119,7 +121,9 @@ async fn write_message_too_large() {
|
|||||||
fn try_extract_request_id_from_valid_message() {
|
fn try_extract_request_id_from_valid_message() {
|
||||||
let msg = ClientMessage {
|
let msg = ClientMessage {
|
||||||
request_id: "abc-123".to_string(),
|
request_id: "abc-123".to_string(),
|
||||||
message: Some(client_message::Message::Initialize(Initialize {})),
|
message: Some(client_message::Message::Initialize(Initialize {
|
||||||
|
auth_token: String::new(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
let buf = msg.encode_to_vec();
|
let buf = msg.encode_to_vec();
|
||||||
assert_eq!(try_extract_request_id(&buf), Some("abc-123".to_string()));
|
assert_eq!(try_extract_request_id(&buf), Some("abc-123".to_string()));
|
||||||
|
|||||||
@@ -137,6 +137,38 @@ pub fn remote_server_dir() -> String {
|
|||||||
format!("~/{warp_dir}/remote-server")
|
format!("~/{warp_dir}/remote-server")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a filesystem-safe directory name for a remote-server identity key.
|
||||||
|
///
|
||||||
|
/// The identity key is not secret, but it can contain bytes that are unsafe or
|
||||||
|
/// ambiguous in paths. Keep ASCII alphanumeric characters plus `-` and `_`;
|
||||||
|
/// percent-encode all other UTF-8 bytes.
|
||||||
|
pub fn remote_server_identity_dir_name(identity_key: &str) -> String {
|
||||||
|
if identity_key.is_empty() {
|
||||||
|
return "empty".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut encoded = String::with_capacity(identity_key.len());
|
||||||
|
for byte in identity_key.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identity-scoped remote directory used for the daemon socket
|
||||||
|
/// and PID file.
|
||||||
|
pub fn remote_server_daemon_dir(identity_key: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
remote_server_dir(),
|
||||||
|
remote_server_identity_dir_name(identity_key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the binary name, keyed by channel.
|
/// Returns the binary name, keyed by channel.
|
||||||
///
|
///
|
||||||
/// Matches the CLI command names: `oz` (stable), `oz-preview`, `oz-dev`.
|
/// Matches the CLI command names: `oz` (stable), `oz-preview`, `oz-dev`.
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ pub struct ParentOpts {
|
|||||||
pub handle: Option<process_handle::ProcessHandle>,
|
pub handle: Option<process_handle::ProcessHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hidden worker args used to scope remote-server proxy/daemon sockets by
|
||||||
|
/// Warp identity without exposing credentials.
|
||||||
|
#[derive(Debug, Clone, Default, clap::Args)]
|
||||||
|
pub struct RemoteServerIdentityArgs {
|
||||||
|
/// Non-secret identity partition key for the remote-server daemon.
|
||||||
|
#[arg(long = "identity-key", hide = true)]
|
||||||
|
pub identity_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Global options that apply to all CLI commands.
|
/// Global options that apply to all CLI commands.
|
||||||
#[derive(Debug, Default, Clone, clap::Args)]
|
#[derive(Debug, Default, Clone, clap::Args)]
|
||||||
pub struct GlobalOptions {
|
pub struct GlobalOptions {
|
||||||
@@ -437,14 +446,14 @@ pub enum WorkerCommand {
|
|||||||
/// to the daemon via a Unix domain socket.
|
/// to the daemon via a Unix domain socket.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
#[clap(hide = true)]
|
#[clap(hide = true)]
|
||||||
RemoteServerProxy,
|
RemoteServerProxy(RemoteServerIdentityArgs),
|
||||||
|
|
||||||
/// Run the long-lived remote development server daemon.
|
/// Run the long-lived remote development server daemon.
|
||||||
/// Listens on a Unix domain socket and accepts multiple concurrent
|
/// Listens on a Unix domain socket and accepts multiple concurrent
|
||||||
/// connections from proxy processes.
|
/// connections from proxy processes.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
#[clap(hide = true)]
|
#[clap(hide = true)]
|
||||||
RemoteServerDaemon,
|
RemoteServerDaemon(RemoteServerIdentityArgs),
|
||||||
|
|
||||||
/// Run a headless ripgrep search worker.
|
/// Run a headless ripgrep search worker.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
|||||||
Reference in New Issue
Block a user