From cced53eeae29912604ff7d5108100d3c05bc5d78 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Feb 2026 15:45:34 +0200 Subject: [PATCH 01/12] real example stacker.yml --- website/stacker.yml | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/website/stacker.yml b/website/stacker.yml index 4b1854a3..61a9d90f 100644 --- a/website/stacker.yml +++ b/website/stacker.yml @@ -2,6 +2,8 @@ name: website version: 1.0.0 project: identity: stacker-website + _id: 44 + app: type: node path: '.' @@ -12,26 +14,10 @@ services: image: ${DOCKER_USERNAME}/stacker-website:latest ports: - "3456:3456" - environment: - DB_PASSWORD: ${DB_PASSWORD} - OPENAI_API_KEY: ${OPENAI_API_KEY} volumes: - .:/app - depends_on: - - db - db: - name: db - image: postgres:14 - environment: - POSTGRES_DB: stacker_db - POSTGRES_USER: stacker_user - POSTGRES_PASSWORD: ${DB_PASSWORD} - ports: - - "5432:5432" - volumes: - - db-data:/var/lib/postgresql/data -monitors: +monitoring: status_panel: true healthcheck: endpoint: /healthz From 46ca2bdac7f60cb9da79c6fa80a4629c61e88762 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 25 Feb 2026 15:48:07 +0200 Subject: [PATCH 02/12] Update diagram in README for Stacker architecture align diagram --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 009bf2e8..a510321e 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Stacker is a platform for turning any project into a deployable Docker stack. Ad ``` ┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐ -│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ -│ │ REST │ │ queue │ (on target server) │ -│ stacker.yml │ API │ Stack Builder UI │ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ -│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ +│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ +│ │ REST │ │ queue │ (on target server) │ +│ stacker.yml │ API │ Stack Builder UI│ pull │ │ +│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ +│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ - │ └─────────────────────┘ + │ └─────────────────────┘ ▼ Terraform + Ansible ──► Cloud (Hetzner, DO, AWS, Linode) From cccbdb2222f6e4c087a40c56688e4e8c2e65aa82 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Feb 2026 16:21:06 +0200 Subject: [PATCH 03/12] ci: add release workflow to build and upload CLI binaries Triggered on GitHub Release publish. Builds stacker-cli for linux/x86_64, darwin/x86_64, darwin/aarch64 and uploads tarballs to the release assets with the naming convention the install.sh script expects: stacker-v{ver}-{arch}-{os}.tar.gz --- .github/workflows/release.yml | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..78c265b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release CLI Binaries + +permissions: + contents: write + +on: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-upload: + name: Build & upload (${{ matrix.target }}) + env: + SQLX_OFFLINE: true + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_os: linux + asset_arch: x86_64 + - os: macos-latest + target: x86_64-apple-darwin + asset_os: darwin + asset_arch: x86_64 + - os: macos-latest + target: aarch64-apple-darwin + asset_os: darwin + asset_arch: aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + override: true + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-index- + + - name: Cache target directory + uses: actions/cache@v4 + with: + path: target + key: release-${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + release-${{ runner.os }}-target-${{ matrix.target }}- + + - name: Build stacker-cli (release) + run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose + + - name: Package binary + run: | + VERSION="${GITHUB_REF_NAME#v}" + ASSET_NAME="stacker-v${VERSION}-${{ matrix.asset_arch }}-${{ matrix.asset_os }}.tar.gz" + mkdir -p staging + cp target/${{ matrix.target }}/release/stacker-cli staging/stacker + tar -czf "${ASSET_NAME}" -C staging . + echo "ASSET_NAME=${ASSET_NAME}" >> "$GITHUB_ENV" + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.ASSET_NAME }} From eb31934d98f3341fb304cdb5454bea1beb06b8dd Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Feb 2026 16:50:14 +0200 Subject: [PATCH 04/12] version in toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c268c04e..0a4f0314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stacker" -version = "0.2.3" +version = "0.2.4" edition = "2021" default-run= "server" From 24c7b465f6b60ae4b76caa1152c1f82cdc4df4c3 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Feb 2026 19:12:20 +0200 Subject: [PATCH 05/12] fix: return 400 bad_request for DcBuilder errors in compose endpoints Port parse errors (e.g. 'Could not parse container port') are user data errors, not server errors. Return 400 so the frontend can display the actual error message to the user instead of a generic fallback. --- src/routes/project/compose.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/project/compose.rs b/src/routes/project/compose.rs index 3cc7d8ae..a36f8ff1 100644 --- a/src/routes/project/compose.rs +++ b/src/routes/project/compose.rs @@ -27,7 +27,7 @@ pub async fn add( DcBuilder::new(project) .build() - .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map_err(|err| JsonResponse::::build().bad_request(err)) .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) } @@ -50,6 +50,6 @@ pub async fn admin( DcBuilder::new(project) .build() - .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map_err(|err| JsonResponse::::build().bad_request(err)) .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) } From 7de963a4f3cab0521c4f50e77931bdf8639b696d Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Feb 2026 20:10:00 +0200 Subject: [PATCH 06/12] fix: use Vault KV v1 format for SSH key storage - Remove 'data' wrapper from store_ssh_key payload (KV v1 stores flat JSON) - Fix fetch response parsing: data.private_key instead of data.data.private_key - Fix fetch response parsing: data.public_key instead of data.data.public_key --- src/helpers/vault.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/helpers/vault.rs b/src/helpers/vault.rs index d468c4a7..3a0a6c21 100644 --- a/src/helpers/vault.rs +++ b/src/helpers/vault.rs @@ -167,13 +167,13 @@ impl VaultClient { // ============ SSH Key Management Methods ============ - /// Build the Vault path for SSH keys: {base}/v1/secret/users/{user_id}/ssh_keys/{server_id} + /// Build the Vault API URL for SSH keys (KV v1). + /// Path: `{address}/{api_prefix}/secret/{prefix}/{user_id}/ssh_keys/{server_id}` fn ssh_key_path(&self, user_id: &str, server_id: i32) -> String { let base = self.address.trim_end_matches('/'); let api_prefix = self.api_prefix.trim_matches('/'); let prefix = self.ssh_key_path_prefix.trim_matches('/'); - // Path without 'data' segment (KV v1 or custom mount) if api_prefix.is_empty() { format!( "{}/secret/{}/{}/ssh_keys/{}", @@ -219,13 +219,11 @@ impl VaultClient { let path = self.ssh_key_path(user_id, server_id); let payload = json!({ - "data": { - "public_key": public_key, - "private_key": private_key, - "user_id": user_id, - "server_id": server_id, - "created_at": chrono::Utc::now().to_rfc3339() - } + "public_key": public_key, + "private_key": private_key, + "user_id": user_id, + "server_id": server_id, + "created_at": chrono::Utc::now().to_rfc3339() }); self.client @@ -244,7 +242,7 @@ impl VaultClient { format!("Vault error: {}", e) })?; - // Return the vault path for storage in database + // Return the logical vault path for storage in database let vault_key_path = format!( "secret/{}/{}/ssh_keys/{}", self.ssh_key_path_prefix.trim_matches('/'), @@ -293,7 +291,7 @@ impl VaultClient { format!("Vault parse error: {}", e) })?; - vault_response["data"]["data"]["private_key"] + vault_response["data"]["private_key"] .as_str() .map(|s| s.to_string()) .ok_or_else(|| { @@ -339,7 +337,7 @@ impl VaultClient { format!("Vault parse error: {}", e) })?; - vault_response["data"]["data"]["public_key"] + vault_response["data"]["public_key"] .as_str() .map(|s| s.to_string()) .ok_or_else(|| { From b2976c5553a4cf1fb5019d3aeb78867be87eed77 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 08:48:21 +0200 Subject: [PATCH 07/12] fix: return 400 not 500 when vault_key_path is NULL despite active key_status --- src/routes/server/ssh_key.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 30dbdf53..4ea51734 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -211,6 +211,11 @@ pub async fn get_public_key( .not_found("No active SSH key found for this server")); } + if server.vault_key_path.is_none() { + return Err(JsonResponse::::build() + .bad_request("SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.")); + } + let public_key = vault_client .get_ref() .fetch_ssh_public_key(&user.id, server_id) @@ -311,6 +316,19 @@ pub async fn validate_key( .ok("Validation failed")); } + if server.vault_key_path.is_none() { + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: "SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.".to_string(), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + // Verify we have the server IP let srv_ip = match &server.srv_ip { Some(ip) if !ip.is_empty() => ip.clone(), From 9a2cb233ef32f40999dabf620c633d60f166d608 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 19:10:46 +0200 Subject: [PATCH 08/12] fix: get_public_key returns 404/400 (not 500) on vault errors; add comprehensive SSH key tests --- src/routes/server/ssh_key.rs | 9 +- tests/common/mod.rs | 101 ++++++++ tests/server_ssh.rs | 477 +++++++++++++++++++++++++++-------- 3 files changed, 484 insertions(+), 103 deletions(-) diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 4ea51734..56a03cb0 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -222,8 +222,13 @@ pub async fn get_public_key( .await .map_err(|e| { tracing::error!("Failed to fetch public key from Vault: {}", e); - JsonResponse::::build() - .internal_server_error("Failed to retrieve public key") + if e.to_lowercase().contains("not found") { + JsonResponse::::build() + .not_found("SSH key not found in Vault. The key may have been lost or Vault was restored without its data. Please delete this key and generate a new one.") + } else { + JsonResponse::::build() + .bad_request("Failed to retrieve SSH key from Vault. Please try again or regenerate the key.") + } })?; let response = PublicKeyResponse { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 555fec29..3006212c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -4,6 +4,7 @@ use stacker::configuration::{get_configuration, DatabaseSettings, Settings}; use stacker::forms; use stacker::helpers::AgentPgPool; use std::net::TcpListener; +use wiremock::MockServer; pub async fn spawn_app_with_configuration(mut configuration: Settings) -> Option { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); @@ -85,6 +86,106 @@ pub struct TestApp { pub db_pool: PgPool, } +pub struct TestAppWithVault { + pub address: String, + pub db_pool: PgPool, + pub vault_server: MockServer, +} + +/// Spawn the full app with a mock Vault server. +/// The returned `vault_server` is a wiremock MockServer — mount expectations on it +/// before calling API endpoints that touch Vault. +pub async fn spawn_app_with_vault() -> Option { + let mut configuration = get_configuration().expect("Failed to get configuration"); + + // Mock auth server + let auth_listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind port for testing auth server"); + configuration.auth_url = format!( + "http://127.0.0.1:{}/me", + auth_listener.local_addr().unwrap().port() + ); + let _ = tokio::spawn(mock_auth_server(auth_listener)); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Mock Vault server + let vault_server = MockServer::start().await; + configuration.vault.address = vault_server.uri(); + configuration.vault.token = "test-vault-token".to_string(); + configuration.vault.api_prefix = "v1".to_string(); + configuration.vault.ssh_key_path_prefix = Some("users".to_string()); + + configuration.database.database_name = uuid::Uuid::new_v4().to_string(); + + let connection_pool = match configure_database(&configuration.database).await { + Ok(pool) => pool, + Err(err) => { + eprintln!("Skipping tests: failed to connect to postgres: {}", err); + return None; + } + }; + + let app_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind app port"); + let port = app_listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + + let agent_pool = AgentPgPool::new(connection_pool.clone()); + let server = stacker::startup::run(app_listener, connection_pool.clone(), agent_pool, configuration) + .await + .expect("Failed to bind address."); + let _ = tokio::spawn(server); + + Some(TestAppWithVault { + address, + db_pool: connection_pool, + vault_server, + }) +} + +/// Insert a minimal project into the DB and return its id. +/// Required because server.project_id has a FK constraint to project(id). +pub async fn create_test_project(pool: &PgPool, user_id: &str) -> i32 { + sqlx::query( + r#"INSERT INTO project (stack_id, user_id, name, body, created_at, updated_at) + VALUES (gen_random_uuid(), $1, 'Test Project', '{}', NOW(), NOW()) + RETURNING id"#, + ) + .bind(user_id) + .fetch_one(pool) + .await + .map(|row| { + use sqlx::Row; + row.get::("id") + }) + .expect("Failed to insert test project") +} + +/// Insert a test server with specific SSH key state and return its id. +pub async fn create_test_server( + pool: &PgPool, + user_id: &str, + project_id: i32, + key_status: &str, + vault_key_path: Option<&str>, +) -> i32 { + sqlx::query( + r#"INSERT INTO server (user_id, project_id, connection_mode, key_status, vault_key_path, created_at, updated_at) + VALUES ($1, $2, 'ssh', $3, $4, NOW(), NOW()) + RETURNING id"#, + ) + .bind(user_id) + .bind(project_id) + .bind(key_status) + .bind(vault_key_path) + .fetch_one(pool) + .await + .map(|row| { + use sqlx::Row; + row.get::("id") + }) + .expect("Failed to insert test server") +} + #[get("")] async fn mock_auth() -> actix_web::Result { println!("Mock auth endpoint called - returning test user"); diff --git a/tests/server_ssh.rs b/tests/server_ssh.rs index f012a9a8..f0986cb7 100644 --- a/tests/server_ssh.rs +++ b/tests/server_ssh.rs @@ -1,179 +1,454 @@ mod common; -use serde_json::json; +use serde_json::{json, Value}; +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, ResponseTemplate}; -// Test SSH key generation for server -// Run: cargo t --test server_ssh -- --nocapture --show-output +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── -/// Test that the server list endpoint returns success +/// Vault path pattern for SSH keys: /v1/secret/users/{user_id}/ssh_keys/{server_id} +fn vault_ssh_path_regex(user_id: &str, server_id: i32) -> String { + format!( + r"/v1/secret/users/{}/ssh_keys/{}", + user_id, server_id + ) +} + +/// Successful Vault GET response body for a KV v1 SSH key read. +fn vault_key_response(public_key: &str, private_key: &str) -> serde_json::Value { + json!({ + "data": { + "public_key": public_key, + "private_key": private_key + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: GET /server/{id}/ssh-key/public +// ───────────────────────────────────────────────────────────────────────────── + +/// Server has key_status=active but vault_key_path=NULL (Vault failed during generate). +/// Must return 400, not 500. #[tokio::test] -async fn get_server_list() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_vault_path_null_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + None, // vault_key_path is NULL + ) + .await; - let response = client - .get(&format!("{}/server", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 200 OK (empty list is fine) - assert!(response.status().is_success()); + assert_eq!(resp.status().as_u16(), 400, "Should be 400, not 500"); + let body: Value = resp.json().await.unwrap(); + let msg = body["message"].as_str().unwrap_or(""); + assert!( + msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate") || msg.to_lowercase().contains("delete"), + "Error message should mention Vault or remediation: {}", msg + ); + // Vault server must NOT have been called (no vault_key_path to use) + assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); } -/// Test that getting a non-existent server returns 404 +/// Server has key_status=active and vault_key_path set, but Vault returns 404. +/// Must return 404 (key lost from Vault), not 500. #[tokio::test] -async fn get_server_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_vault_returns_404_propagates_as_404() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 999)), + ) + .await; + + // Mount Vault mock: GET → 404 + Mock::given(method("GET")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"errors": []}))) + .mount(&app.vault_server) + .await; - let response = client - .get(&format!("{}/server/99999", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 404 for non-existent server - assert_eq!(response.status().as_u16(), 404); + assert_eq!(resp.status().as_u16(), 404, "Should be 404 when Vault returns 404"); + let body: Value = resp.json().await.unwrap(); + let msg = body["message"].as_str().unwrap_or(""); + assert!( + msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate"), + "Error message should mention Vault: {}", msg + ); } -/// Test that generating SSH key requires authentication +/// Server has key_status="none" — no key has been generated yet. +/// Must return 404. #[tokio::test] -async fn generate_ssh_key_requires_auth() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_no_active_key_returns_404() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .post(&format!("{}/server/1/ssh-key/generate", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should require authentication (401 or 403) - let status = response.status().as_u16(); - assert!(status == 401 || status == 403 || status == 404); + assert_eq!(resp.status().as_u16(), 404); } -/// Test that uploading SSH key validates input +/// Happy path: active key, vault_key_path set, Vault returns the key successfully. #[tokio::test] -async fn upload_ssh_key_validates_input() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_success() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 0)), // path value doesn't matter for routing + ) + .await; - // Send invalid key format - let invalid_data = json!({ - "public_key": "not-a-valid-key", - "private_key": "also-not-valid" - }); + let expected_pub_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey"; - let response = client - .post(&format!("{}/server/1/ssh-key/upload", &app.address)) - .header("Content-Type", "application/json") - .body(invalid_data.to_string()) + Mock::given(method("GET")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with( + ResponseTemplate::new(200).set_body_json(vault_key_response( + expected_pub_key, + "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + )), + ) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should reject invalid key format (400 or 401/403 if auth required first) - let status = response.status().as_u16(); - assert!(status == 400 || status == 401 || status == 403 || status == 404); + assert_eq!(resp.status().as_u16(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!( + body["item"]["public_key"].as_str().unwrap_or(""), + expected_pub_key, + "Response should contain the public key from Vault" + ); } -/// Test that getting public key for non-existent server returns error +// ───────────────────────────────────────────────────────────────────────────── +// Tests: POST /server/{id}/ssh-key/generate +// ───────────────────────────────────────────────────────────────────────────── + +/// When Vault is unavailable during generate, the private key MUST be returned +/// inline in the response, and the DB must have key_status=active + vault_key_path=NULL. #[tokio::test] -async fn get_public_key_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_vault_down_returns_private_key_inline() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .get(&format!("{}/server/99999/ssh-key/public", &app.address)) + // Vault is down — POST returns 500 + Mock::given(method("POST")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(500).set_body_string("vault unavailable")) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 200, "Generate should succeed even when Vault is down"); + let body: Value = resp.json().await.unwrap(); - // Should return 404 - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + // Private key must be returned inline so user can save it + assert!( + body["item"]["private_key"].is_string(), + "Private key must be returned inline when Vault is unavailable" + ); + assert!( + body["item"]["public_key"].is_string(), + "Public key must also be present" + ); + + // DB: key_status must be "active" and vault_key_path must be NULL + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "active"); + assert!( + db_vault_path.is_none(), + "vault_key_path must be NULL when Vault store failed" + ); } -/// Test that deleting SSH key for non-existent server returns error +/// Happy path: Vault is available — key is stored, no private key in response, vault_key_path saved. #[tokio::test] -async fn delete_ssh_key_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .delete(&format!("{}/server/99999/ssh-key", &app.address)) + // Vault is up — POST returns 204 + Mock::given(method("POST")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(204)) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 200); + let body: Value = resp.json().await.unwrap(); - // Should return 404 or auth error - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + // Private key must NOT be in response when Vault worked + assert!( + body["item"]["private_key"].is_null() || !body["item"]["private_key"].is_string(), + "Private key must NOT be returned when Vault stored it successfully" + ); + assert!(body["item"]["public_key"].is_string(), "Public key must be present"); + + // DB: vault_key_path must be set + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "active"); + assert!( + db_vault_path.is_some(), + "vault_key_path must be saved in DB after successful Vault store" + ); } -/// Test server update endpoint +/// Generating a key when one is already active must return 400. #[tokio::test] -async fn update_server_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_already_active_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some("secret/users/test_user_id/ssh_keys/1"), + ) + .await; + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 400); + // Vault must NOT have been called + assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); +} - let update_data = json!({ - "name": "My Server", - "connection_mode": "ssh" - }); +// ───────────────────────────────────────────────────────────────────────────── +// Tests: DELETE /server/{id}/ssh-key +// ───────────────────────────────────────────────────────────────────────────── - let response = client - .put(&format!("{}/server/99999", &app.address)) - .header("Content-Type", "application/json") - .body(update_data.to_string()) +/// Deleting an active key must call Vault DELETE, reset key_status to "none", +/// and clear vault_key_path in DB. +#[tokio::test] +async fn test_delete_key_clears_vault_and_db() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, + None => return, + }; + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 0)), + ) + .await; + + Mock::given(method("DELETE")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(204)) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 404 for non-existent server - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + assert_eq!(resp.status().as_u16(), 200); + + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "none", "key_status must be reset to 'none'"); + assert!(db_vault_path.is_none(), "vault_key_path must be cleared"); } -/// Test get servers by project endpoint +/// Deleting when no key exists must return 400. #[tokio::test] -async fn get_servers_by_project() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_delete_key_none_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .get(&format!("{}/server/project/1", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 400); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: Unauthenticated access +// ───────────────────────────────────────────────────────────────────────────── + +/// All SSH key endpoints must reject requests without a Bearer token. +#[tokio::test] +async fn test_ssh_key_endpoints_require_auth() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, + None => return, + }; + let client = reqwest::Client::new(); + + let endpoints: &[(&str, &str)] = &[ + ("GET", "/server/1/ssh-key/public"), + ("POST", "/server/1/ssh-key/generate"), + ("DELETE", "/server/1/ssh-key"), + ]; - // Should return success or auth error - let status = response.status().as_u16(); - assert!(status == 200 || status == 404 || status == 401 || status == 403); + for (verb, path) in endpoints { + let req = match *verb { + "GET" => client.get(&format!("{}{}", &app.address, path)), + "POST" => client.post(&format!("{}{}", &app.address, path)), + "DELETE" => client.delete(&format!("{}{}", &app.address, path)), + _ => unreachable!(), + }; + let resp = req.send().await.expect("request failed"); + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 401 || status == 403 || status == 404, + "{} {} without auth should return 400/401/403, got {}", + verb, path, status + ); + } } From c2430eda405cc8e4c43c3c4782856c9ed69efdd5 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 20:59:57 +0200 Subject: [PATCH 09/12] fix: add Vault address/token to local config so SSH keys are actually stored --- docker-compose.dev.yml | 2 ++ docker/local/configuration.yaml | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4fb73264..3165619e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,6 +39,8 @@ services: environment: - RUST_LOG=debug - RUST_BACKTRACE=1 + # Inherit Vault token from host environment (set VAULT_TOKEN in your shell or main .env) + - VAULT_TOKEN depends_on: stackerdb: condition: service_healthy diff --git a/docker/local/configuration.yaml b/docker/local/configuration.yaml index 141a67e1..1103ee2f 100644 --- a/docker/local/configuration.yaml +++ b/docker/local/configuration.yaml @@ -15,3 +15,11 @@ amqp: port: 5672 username: guest password: guest + +# Vault: address set here, token comes from VAULT_TOKEN env var (see docker-compose) +vault: + address: http://37.139.9.187:8200 + token: change-me + api_prefix: v1 + agent_path_prefix: agent + ssh_key_path_prefix: users From 5ada40a43dc4e221a52787a0e8ceda5b0610b9e8 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 21:04:05 +0200 Subject: [PATCH 10/12] Revert "fix: add Vault address/token to local config so SSH keys are actually stored" This reverts commit c2430eda405cc8e4c43c3c4782856c9ed69efdd5. --- docker-compose.dev.yml | 2 -- docker/local/configuration.yaml | 8 -------- 2 files changed, 10 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3165619e..4fb73264 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,8 +39,6 @@ services: environment: - RUST_LOG=debug - RUST_BACKTRACE=1 - # Inherit Vault token from host environment (set VAULT_TOKEN in your shell or main .env) - - VAULT_TOKEN depends_on: stackerdb: condition: service_healthy diff --git a/docker/local/configuration.yaml b/docker/local/configuration.yaml index 1103ee2f..141a67e1 100644 --- a/docker/local/configuration.yaml +++ b/docker/local/configuration.yaml @@ -15,11 +15,3 @@ amqp: port: 5672 username: guest password: guest - -# Vault: address set here, token comes from VAULT_TOKEN env var (see docker-compose) -vault: - address: http://37.139.9.187:8200 - token: change-me - api_prefix: v1 - agent_path_prefix: agent - ssh_key_path_prefix: users From 2a906a3f6fd4f2e14b3bb6712cbe364541168fd2 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 21:04:36 +0200 Subject: [PATCH 11/12] chore: gitignore docker/local/ to prevent committing local configs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 82bf7858..a777a745 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ configuration.yaml.backup configuration.yaml.orig .vscode/ .env +docker/local/ docs/*.sql config-to-validate.yaml From 6473b3c19f7f4e8a9b70f61acc78e0ac29663843 Mon Sep 17 00:00:00 2001 From: vsilent Date: Thu, 26 Feb 2026 21:08:26 +0200 Subject: [PATCH 12/12] fix: correct VAULT_ADDRESS and add missing VAULT_SSH_KEY_PATH_PREFIX in dev env --- docker/dev/.env | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/dev/.env b/docker/dev/.env index 892f3064..53d4f824 100644 --- a/docker/dev/.env +++ b/docker/dev/.env @@ -7,9 +7,11 @@ POSTGRES_DB=stacker POSTGRES_PORT=5432 # Vault Configuration -VAULT_ADDRESS=http://127.0.0.1:8200 +VAULT_ADDRESS=http://vault2.try.direct:8200 VAULT_TOKEN=your_vault_token_here VAULT_AGENT_PATH_PREFIX=agent +VAULT_API_PREFIX=v1 +VAULT_SSH_KEY_PATH_PREFIX=users ### 10.3 Environment Variables Required # User Service integration