Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/hypercolor-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ impl DaemonClient {
parse_api_response(response).await
}

/// Send a POST request with a JSON body, extra headers, and parse the response.
///
/// # Errors
///
/// Returns an error if the daemon is unreachable, the body cannot be
/// serialized, or the daemon returns a non-success status code.
pub async fn post_with_headers(
&self,
path: &str,
body: &impl Serialize,
headers: &[(&str, &str)],
) -> Result<serde_json::Value> {
let url = format!("{}/api/v1{path}", self.base_url);
let request = headers.iter().fold(
self.with_auth(self.http.post(&url)),
|request, (name, value)| request.header(*name, *value),
);
let response = request.json(body).send().await.with_context(|| {
format!("Failed to connect to daemon at {url}. Is the daemon running?")
})?;
parse_api_response(response).await
}

/// Send a PUT request with a JSON body and parse the response.
///
/// # Errors
Expand Down
9 changes: 8 additions & 1 deletion crates/hypercolor-cli/src/commands/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use clap::{Args, Subcommand};
use crate::client::DaemonClient;
use crate::output::{OutputContext, OutputFormat, extract_str};

const CONNECT_INTENT_HEADER: &str = "x-hypercolor-connect-intent";
const CONNECT_INTENT_VALUE: &str = "manual";

#[derive(Debug, Args)]
pub struct CloudArgs {
#[command(subcommand)]
Expand Down Expand Up @@ -131,7 +134,11 @@ async fn execute_connection(

let response = if args.connect {
client
.post("/cloud/connection/connect", &serde_json::json!({}))
.post_with_headers(
"/cloud/connection/connect",
&serde_json::json!({}),
&[(CONNECT_INTENT_HEADER, CONNECT_INTENT_VALUE)],
)
.await?
} else if args.disconnect {
client
Expand Down
16 changes: 14 additions & 2 deletions crates/hypercolor-cli/tests/request_shape_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::time::Duration;

use anyhow::{Context, Result, bail};
use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::http::Uri;
use axum::routing::{delete, get, patch, post};
use axum::{Json, Router};
Expand Down Expand Up @@ -306,12 +307,22 @@ async fn cloud_connection_prepare_posts_daemon_prepare_endpoint() -> Result<()>
#[tokio::test]
async fn cloud_connection_connect_posts_daemon_connect_endpoint() -> Result<()> {
let captured_uri: SharedUri = Arc::new(Mutex::new(None));
let captured_header: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let router = Router::new()
.route(
"/api/v1/cloud/connection/connect",
post(
|State(captured_uri): State<SharedUri>, uri: Uri| async move {
|State((captured_uri, captured_header)): State<(
SharedUri,
Arc<Mutex<Option<String>>>,
)>,
uri: Uri,
headers: HeaderMap| async move {
*captured_uri.lock().await = Some(uri.to_string());
*captured_header.lock().await = headers
.get("x-hypercolor-connect-intent")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
Json(serde_json::json!({
"data": {
"state": "ready",
Expand All @@ -333,7 +344,7 @@ async fn cloud_connection_connect_posts_daemon_connect_endpoint() -> Result<()>
},
),
)
.with_state(Arc::clone(&captured_uri));
.with_state((Arc::clone(&captured_uri), Arc::clone(&captured_header)));
let (port, shutdown_tx, task) = spawn_server(router).await?;

let output = run_hyper_output(port, &["cloud", "connection", "--connect"]).await?;
Expand All @@ -358,6 +369,7 @@ async fn cloud_connection_connect_posts_daemon_connect_endpoint() -> Result<()>
captured_uri.lock().await.as_deref(),
Some("/api/v1/cloud/connection/connect")
);
assert_eq!(captured_header.lock().await.as_deref(), Some("manual"));

Ok(())
}
Expand Down
19 changes: 18 additions & 1 deletion crates/hypercolor-daemon/src/api/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::sync::Arc;
use std::time::{Duration, SystemTime};

use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::response::Response;
use hypercolor_cloud_client::api as cloud_api;
use hypercolor_cloud_client::daemon_link::{IdentityNonce, UpgradeNonce};
Expand All @@ -30,6 +31,9 @@ use crate::cloud_entitlements::{
};
use crate::cloud_socket::{CloudSocketHelloInput, CloudSocketStartError};

const CONNECT_INTENT_HEADER: &str = "x-hypercolor-connect-intent";
const CONNECT_INTENT_VALUE: &str = "manual";

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CloudStatus {
pub compiled: bool,
Expand Down Expand Up @@ -250,7 +254,13 @@ pub async fn prepare_connection(State(state): State<Arc<AppState>>) -> Response
}
}

pub async fn connect_connection(State(state): State<Arc<AppState>>) -> Response {
pub async fn connect_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
if !connect_intent_header_valid(&headers) {
return ApiError::bad_request("missing required connect intent header");
}
let cloud_config = cloud_config(&state);
if !cloud_config.enabled {
return ApiError::conflict("cloud connection is disabled");
Expand Down Expand Up @@ -280,6 +290,13 @@ pub async fn connect_connection(State(state): State<Arc<AppState>>) -> Response
}
}

fn connect_intent_header_valid(headers: &HeaderMap) -> bool {
headers
.get(CONNECT_INTENT_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == CONNECT_INTENT_VALUE)
}

pub async fn disconnect_connection(State(state): State<Arc<AppState>>) -> Response {
let store = match KeyringSecretStore::new_native() {
Ok(store) => store,
Expand Down
25 changes: 24 additions & 1 deletion crates/hypercolor-daemon/tests/cloud_api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,13 +438,16 @@ async fn cloud_identity_bootstrap_rejects_disabled_cloud_without_keyring() {

#[tokio::test]
async fn cloud_connection_connect_rejects_disabled_cloud_without_keyring() {
let app = api::build_router(Arc::new(AppState::new()), None);
let tempdir = TempDir::new().expect("temp data dir should be created");
let state = Arc::new(AppState::new_with_data_dir(tempdir.path().join("data")));
let app = api::build_router(state, None);

let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/cloud/connection/connect")
.header("x-hypercolor-connect-intent", "manual")
.body(Body::empty())
.expect("request should build"),
)
Expand All @@ -454,6 +457,26 @@ async fn cloud_connection_connect_rejects_disabled_cloud_without_keyring() {
assert_eq!(response.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn cloud_connection_connect_rejects_missing_intent_header() {
let tempdir = TempDir::new().expect("temp data dir should be created");
let state = Arc::new(AppState::new_with_data_dir(tempdir.path().join("data")));
let app = api::build_router(state, None);

let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/cloud/connection/connect")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn cloud_connection_prepare_stages_signed_request_without_returning_secret() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
Expand Down
Loading