feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db - Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp* - Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md - Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
This commit is contained in:
26
crates/desktop-daemon/Cargo.toml
Normal file
26
crates/desktop-daemon/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "secrets-desktop-daemon"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "secrets_desktop_daemon"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "secrets-desktop-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
dotenvy.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
rmcp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
secrets-device-auth = { path = "../device-auth" }
|
||||
23
crates/desktop-daemon/src/config.rs
Normal file
23
crates/desktop-daemon/src/config.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DaemonConfig {
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result<DaemonConfig> {
|
||||
let bind =
|
||||
std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string());
|
||||
if bind.trim().is_empty() {
|
||||
anyhow::bail!("SECRETS_DAEMON_BIND must not be empty");
|
||||
}
|
||||
Ok(DaemonConfig { bind })
|
||||
}
|
||||
|
||||
pub fn load_persisted_device_token() -> Result<Option<String>> {
|
||||
let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
Ok(token)
|
||||
}
|
||||
139
crates/desktop-daemon/src/exec.rs
Normal file
139
crates/desktop-daemon/src/exec.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::target::{ExecutionTarget, ResolvedTarget};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TargetExecInput {
|
||||
pub target_ref: Option<String>,
|
||||
pub command: String,
|
||||
pub timeout_secs: Option<u64>,
|
||||
pub working_dir: Option<String>,
|
||||
pub env_overrides: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ExecResult {
|
||||
pub resolved_target: ResolvedTarget,
|
||||
pub resolved_env_keys: Vec<String>,
|
||||
pub command: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub timed_out: bool,
|
||||
pub duration_ms: u128,
|
||||
pub stdout_truncated: bool,
|
||||
pub stderr_truncated: bool,
|
||||
}
|
||||
|
||||
fn truncate_output(text: String) -> (String, bool) {
|
||||
if text.chars().count() <= MAX_OUTPUT_CHARS {
|
||||
return (text, false);
|
||||
}
|
||||
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
|
||||
(truncated, true)
|
||||
}
|
||||
|
||||
fn stringify_env_override(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_env_overrides(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
overrides: Option<&Map<String, Value>>,
|
||||
) -> Result<()> {
|
||||
let Some(overrides) = overrides else {
|
||||
return Ok(());
|
||||
};
|
||||
for (key, value) in overrides {
|
||||
if key.is_empty() || key.contains('=') {
|
||||
return Err(anyhow!("invalid env override key: {key}"));
|
||||
}
|
||||
if key.starts_with("TARGET_") {
|
||||
return Err(anyhow!(
|
||||
"env override `{key}` cannot override reserved TARGET_* variables"
|
||||
));
|
||||
}
|
||||
if let Some(value) = stringify_env_override(value) {
|
||||
env.insert(key.clone(), value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_command(
|
||||
input: &TargetExecInput,
|
||||
target: &ExecutionTarget,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ExecResult> {
|
||||
let mut env = target.env.clone();
|
||||
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
.arg("-lc")
|
||||
.arg(&input.command)
|
||||
.kill_on_drop(true)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
for (key, value) in &env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn command: {}", input.command))?;
|
||||
|
||||
let timed = tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs.clamp(1, 86400)),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (exit_code, stdout, stderr, timed_out) = match timed {
|
||||
Ok(output) => {
|
||||
let output = output.context("failed waiting for command output")?;
|
||||
(
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(_) => (None, String::new(), "command timed out".to_string(), true),
|
||||
};
|
||||
|
||||
let (stdout, stdout_truncated) = truncate_output(stdout);
|
||||
let (stderr, stderr_truncated) = truncate_output(stderr);
|
||||
|
||||
Ok(ExecResult {
|
||||
resolved_target: target.resolved.clone(),
|
||||
resolved_env_keys: target.resolved_env_keys(),
|
||||
command: input.command.clone(),
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
timed_out,
|
||||
duration_ms: started.elapsed().as_millis(),
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
})
|
||||
}
|
||||
684
crates/desktop-daemon/src/lib.rs
Normal file
684
crates/desktop-daemon/src/lib.rs
Normal file
@@ -0,0 +1,684 @@
|
||||
pub mod config;
|
||||
pub mod exec;
|
||||
pub mod target;
|
||||
pub mod vault_client;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{StatusCode, header},
|
||||
response::Response,
|
||||
routing::{any, get},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
exec::{TargetExecInput, execute_command},
|
||||
target::{TargetSnapshot, build_execution_target},
|
||||
vault_client::{
|
||||
EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get,
|
||||
authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail,
|
||||
fetch_revealed_entry_secrets,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
session_base: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
id: Value,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
fn json_response(status: StatusCode, value: Value) -> Response {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(Body::from(value.to_string()))
|
||||
.expect("build response")
|
||||
}
|
||||
|
||||
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_success_response(id: Value, value: Value) -> Response {
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": pretty
|
||||
}
|
||||
],
|
||||
"isError": false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": message.into()
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn initialize_response(id: Value) -> Response {
|
||||
let session_id = format!(
|
||||
"desktop-daemon-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "secrets-desktop-daemon",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"title": "Secrets Desktop Daemon"
|
||||
},
|
||||
"instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session. Legacy aliases secrets_find, secrets_add, and secrets_update remain supported."
|
||||
}
|
||||
});
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.header("mcp-session-id", session_id)
|
||||
.body(Body::from(payload.to_string()))
|
||||
.expect("build response")
|
||||
}
|
||||
|
||||
fn tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "secrets_entry_find",
|
||||
"description": "Find entries from the user's secrets vault.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_entry_get",
|
||||
"description": "Get one entry from the unlocked local vault by entry id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_entry_add",
|
||||
"description": "Create a new entry and optionally include initial secrets.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"metadata": { "type": ["object", "null"] },
|
||||
"secrets": {
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"secret_type": { "type": ["string", "null"] },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["folder", "name"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_entry_update",
|
||||
"description": "Update an existing entry by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"metadata": { "type": ["object", "null"] }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_entry_delete",
|
||||
"description": "Move an entry into recycle bin by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_entry_restore",
|
||||
"description": "Restore a deleted entry from recycle bin by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_secret_add",
|
||||
"description": "Create one secret under an existing entry.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry_id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"secret_type": { "type": ["string", "null"] },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["entry_id", "name", "value"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_secret_update",
|
||||
"description": "Update one secret by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"secret_type": { "type": ["string", "null"] },
|
||||
"value": { "type": ["string", "null"] }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_secret_delete",
|
||||
"description": "Delete one secret by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_secret_history",
|
||||
"description": "List history snapshots for one secret by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_secret_rollback",
|
||||
"description": "Rollback one secret by id to a previous version or history id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"version": { "type": ["integer", "null"] },
|
||||
"history_id": { "type": ["integer", "null"] }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "target_exec",
|
||||
"description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_ref": { "type": ["string", "null"] },
|
||||
"command": { "type": "string" },
|
||||
"timeout_secs": { "type": ["integer", "null"] },
|
||||
"working_dir": { "type": ["string", "null"] },
|
||||
"env_overrides": { "type": ["object", "null"] }
|
||||
},
|
||||
"required": ["target_ref", "command"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_find",
|
||||
"description": "Legacy alias for secrets_entry_find.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_add",
|
||||
"description": "Legacy alias for secrets_entry_add.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"metadata": { "type": ["object", "null"] },
|
||||
"secrets": { "type": ["array", "null"] }
|
||||
},
|
||||
"required": ["folder", "name"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_update",
|
||||
"description": "Legacy alias for secrets_entry_update.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"metadata": { "type": ["object", "null"] }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot {
|
||||
let metadata = detail
|
||||
.metadata
|
||||
.iter()
|
||||
.map(|field| (field.label.clone(), Value::String(field.value.clone())))
|
||||
.collect();
|
||||
let secret_fields = detail
|
||||
.secrets
|
||||
.iter()
|
||||
.map(|secret| crate::target::SecretFieldRef {
|
||||
name: secret.name.clone(),
|
||||
secret_type: Some(secret.secret_type.clone()),
|
||||
})
|
||||
.collect();
|
||||
TargetSnapshot {
|
||||
id: detail.id.clone(),
|
||||
folder: detail.folder.clone(),
|
||||
name: detail.name.clone(),
|
||||
entry_type: Some(detail.cipher_type.clone()),
|
||||
metadata,
|
||||
secret_fields,
|
||||
}
|
||||
}
|
||||
|
||||
fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap<String, Value> {
|
||||
secrets
|
||||
.iter()
|
||||
.map(|secret| (secret.name.clone(), Value::String(secret.value.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result<Value> {
|
||||
match name {
|
||||
"secrets_find" | "secrets_entry_find" => {
|
||||
let folder = arguments
|
||||
.get("folder")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let query = arguments
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let entry_type = arguments
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let mut params = Vec::new();
|
||||
if let Some(folder) = folder {
|
||||
params.push(("folder", folder));
|
||||
}
|
||||
if let Some(query) = query {
|
||||
params.push(("query", query));
|
||||
}
|
||||
if let Some(entry_type) = entry_type {
|
||||
params.push(("entry_type", entry_type));
|
||||
}
|
||||
params.push(("deleted_only", "false".to_string()));
|
||||
let entries = authorized_get(state, "/vault/entries", ¶ms)
|
||||
.await?
|
||||
.json::<Vec<EntrySummary>>()
|
||||
.await
|
||||
.context("failed to decode entries list")?;
|
||||
Ok(json!({
|
||||
"entries": entries.into_iter().map(|entry| {
|
||||
json!({
|
||||
"id": entry.id,
|
||||
"folder": entry.folder,
|
||||
"name": entry.name,
|
||||
"type": entry.cipher_type
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
"secrets_entry_get" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let detail = fetch_entry_detail(state, id).await?;
|
||||
let secrets = fetch_revealed_entry_secrets(state, id).await?;
|
||||
Ok(entry_detail_payload(&detail, Some(&secrets)))
|
||||
}
|
||||
"secrets_add" | "secrets_entry_add" => {
|
||||
let folder = arguments
|
||||
.get("folder")
|
||||
.and_then(Value::as_str)
|
||||
.context("folder is required")?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.context("name is required")?;
|
||||
let entry_type = arguments
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("entry");
|
||||
let metadata = arguments
|
||||
.get("metadata")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let res = authorized_post(
|
||||
state,
|
||||
"/vault/entries",
|
||||
&json!({
|
||||
"folder": folder,
|
||||
"name": name,
|
||||
"entry_type": entry_type,
|
||||
"metadata": metadata,
|
||||
"secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode create result")?)
|
||||
}
|
||||
"secrets_update" | "secrets_entry_update" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let body = json!({
|
||||
"folder": arguments.get("folder").cloned().unwrap_or(Value::Null),
|
||||
"entry_type": arguments.get("type").cloned().unwrap_or(Value::Null),
|
||||
"title": arguments.get("name").cloned().unwrap_or(Value::Null),
|
||||
"metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null)
|
||||
});
|
||||
let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode update result")?)
|
||||
}
|
||||
"secrets_entry_delete" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let res =
|
||||
authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode delete result")?)
|
||||
}
|
||||
"secrets_entry_restore" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let res =
|
||||
authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode restore result")?)
|
||||
}
|
||||
"secrets_secret_add" => {
|
||||
let entry_id = arguments
|
||||
.get("entry_id")
|
||||
.and_then(Value::as_str)
|
||||
.context("entry_id is required")?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.context("name is required")?;
|
||||
let value = arguments
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.context("value is required")?;
|
||||
let res = authorized_post(
|
||||
state,
|
||||
&format!("/vault/entries/{entry_id}/secrets"),
|
||||
&json!({
|
||||
"name": name,
|
||||
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
|
||||
"value": value
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode secret create result")?)
|
||||
}
|
||||
"secrets_secret_update" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let res = authorized_patch(
|
||||
state,
|
||||
&format!("/vault/secrets/{id}"),
|
||||
&json!({
|
||||
"name": arguments.get("name").cloned().unwrap_or(Value::Null),
|
||||
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
|
||||
"value": arguments.get("value").cloned().unwrap_or(Value::Null)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode secret update result")?)
|
||||
}
|
||||
"secrets_secret_delete" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let res =
|
||||
authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode secret delete result")?)
|
||||
}
|
||||
"secrets_secret_history" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[])
|
||||
.await?
|
||||
.json::<Vec<SecretHistoryItem>>()
|
||||
.await
|
||||
.context("failed to decode secret history")?;
|
||||
Ok(json!({
|
||||
"history": history.into_iter().map(|item| {
|
||||
json!({
|
||||
"history_id": item.history_id,
|
||||
"secret_id": item.secret_id,
|
||||
"name": item.name,
|
||||
"type": item.secret_type,
|
||||
"masked_value": item.masked_value,
|
||||
"value": item.value,
|
||||
"version": item.version,
|
||||
"action": item.action,
|
||||
"created_at": item.created_at
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
"secrets_secret_rollback" => {
|
||||
let id = arguments
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("id is required")?;
|
||||
let res = authorized_post(
|
||||
state,
|
||||
&format!("/vault/secrets/{id}/rollback"),
|
||||
&json!({
|
||||
"version": arguments.get("version").cloned().unwrap_or(Value::Null),
|
||||
"history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(res
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("failed to decode secret rollback result")?)
|
||||
}
|
||||
"target_exec" => {
|
||||
let input: TargetExecInput =
|
||||
serde_json::from_value(arguments).context("invalid target_exec arguments")?;
|
||||
let target_ref = input
|
||||
.target_ref
|
||||
.as_ref()
|
||||
.context("target_ref is required")?;
|
||||
let detail = fetch_entry_detail(state, target_ref).await?;
|
||||
let secrets = fetch_revealed_entry_secrets(state, target_ref).await?;
|
||||
let execution_target = build_execution_target(
|
||||
&entry_detail_to_snapshot(&detail),
|
||||
&revealed_secrets_to_env(&secrets),
|
||||
)?;
|
||||
let result =
|
||||
execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30))
|
||||
.await?;
|
||||
Ok(serde_json::to_value(result).context("failed to encode exec result")?)
|
||||
}
|
||||
other => Err(anyhow!("unsupported tool: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_mcp(State(state): State<AppState>, body: String) -> Response {
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&body) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
return json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": format!("invalid request: {err}")
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match request.method.as_str() {
|
||||
"initialize" => initialize_response(request.id),
|
||||
"tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })),
|
||||
"tools/call" => {
|
||||
let name = request
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let arguments = request
|
||||
.params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
match call_tool(&state, name, arguments).await {
|
||||
Ok(value) => tool_success_response(request.id, value),
|
||||
Err(err) => tool_error_response(request.id, err.to_string()),
|
||||
}
|
||||
}
|
||||
other => json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("method `{other}` not supported by secrets-desktop-daemon")
|
||||
}
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_router() -> Result<Router> {
|
||||
let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:9520".to_string());
|
||||
let state = AppState {
|
||||
session_base,
|
||||
client: reqwest::Client::new(),
|
||||
};
|
||||
Ok(Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/mcp", any(handle_mcp))
|
||||
.with_state(state))
|
||||
}
|
||||
26
crates/desktop-daemon/src/main.rs
Normal file
26
crates/desktop-daemon/src/main.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "secrets_desktop_daemon=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = secrets_desktop_daemon::config::load_config()?;
|
||||
let app = secrets_desktop_daemon::build_router().await?;
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind {}", config.bind))?;
|
||||
|
||||
tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.context("daemon server error")?;
|
||||
Ok(())
|
||||
}
|
||||
332
crates/desktop-daemon/src/target.rs
Normal file
332
crates/desktop-daemon/src/target.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SecretFieldRef {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub secret_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TargetSnapshot {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
pub secret_fields: Vec<SecretFieldRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ResolvedTarget {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutionTarget {
|
||||
pub resolved: ResolvedTarget,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExecutionTarget {
|
||||
pub fn resolved_env_keys(&self) -> Vec<String> {
|
||||
self.env.keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn stringify_value(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_env_key(key: &str) -> String {
|
||||
let mut out = String::with_capacity(key.len());
|
||||
for ch in key.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_uppercase());
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
while out.contains("__") {
|
||||
out = out.replace("__", "_");
|
||||
}
|
||||
out.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = value.filter(|v| !v.is_empty()) {
|
||||
env.entry(key.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| metadata.get(*key))
|
||||
.and_then(stringify_value)
|
||||
}
|
||||
|
||||
fn secret_alias(
|
||||
secrets: &HashMap<String, Value>,
|
||||
secret_types: &HashMap<&str, Option<&str>>,
|
||||
name_match: impl Fn(&str) -> bool,
|
||||
type_match: impl Fn(Option<&str>) -> bool,
|
||||
) -> Option<String> {
|
||||
secrets.iter().find_map(|(name, value)| {
|
||||
let normalized = sanitize_env_key(name);
|
||||
let ty = secret_types.get(name.as_str()).copied().flatten();
|
||||
if name_match(&normalized) || type_match(ty) {
|
||||
stringify_value(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_execution_target(
|
||||
snapshot: &TargetSnapshot,
|
||||
secrets: &HashMap<String, Value>,
|
||||
) -> Result<ExecutionTarget> {
|
||||
if snapshot.id.trim().is_empty() {
|
||||
return Err(anyhow!("target snapshot missing id"));
|
||||
}
|
||||
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
|
||||
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
|
||||
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
|
||||
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
|
||||
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
|
||||
}
|
||||
|
||||
for (key, value) in &snapshot.metadata {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_META_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
|
||||
.secret_fields
|
||||
.iter()
|
||||
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
|
||||
.collect();
|
||||
|
||||
for (key, value) in secrets {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_SECRET_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_HOST",
|
||||
metadata_alias(
|
||||
&snapshot.metadata,
|
||||
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_PORT",
|
||||
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_USER",
|
||||
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_BASE_URL",
|
||||
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_API_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_TOKEN",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("TOKEN"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_SSH_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("SSH") || name.ends_with("PEM"),
|
||||
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
|
||||
),
|
||||
);
|
||||
|
||||
Ok(ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: snapshot.id.clone(),
|
||||
folder: snapshot.folder.clone(),
|
||||
name: snapshot.name.clone(),
|
||||
entry_type: snapshot.entry_type.clone(),
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_snapshot() -> TargetSnapshot {
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert(
|
||||
"host".to_string(),
|
||||
Value::String("git.example.com".to_string()),
|
||||
);
|
||||
metadata.insert("port".to_string(), Value::String("22".to_string()));
|
||||
metadata.insert("username".to_string(), Value::String("deploy".to_string()));
|
||||
metadata.insert(
|
||||
"base_url".to_string(),
|
||||
Value::String("https://api.example.com".to_string()),
|
||||
);
|
||||
TargetSnapshot {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "infra".to_string(),
|
||||
name: "production".to_string(),
|
||||
entry_type: Some("ssh_key".to_string()),
|
||||
metadata,
|
||||
secret_fields: vec![
|
||||
SecretFieldRef {
|
||||
name: "api_key".to_string(),
|
||||
secret_type: Some("text".to_string()),
|
||||
},
|
||||
SecretFieldRef {
|
||||
name: "token".to_string(),
|
||||
secret_type: Some("text".to_string()),
|
||||
},
|
||||
SecretFieldRef {
|
||||
name: "ssh_key".to_string(),
|
||||
secret_type: Some("ssh-key".to_string()),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_standard_target_env_keys() {
|
||||
let snapshot = build_snapshot();
|
||||
let secrets = HashMap::from([
|
||||
("api_key".to_string(), Value::String("ak-123".to_string())),
|
||||
("token".to_string(), Value::String("tok-456".to_string())),
|
||||
(
|
||||
"ssh_key".to_string(),
|
||||
Value::String("-----BEGIN KEY-----".to_string()),
|
||||
),
|
||||
]);
|
||||
|
||||
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
|
||||
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_ENTRY_ID").map(String::as_str),
|
||||
Some("entry-1")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_NAME").map(String::as_str),
|
||||
Some("production")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_FOLDER").map(String::as_str),
|
||||
Some("infra")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_TYPE").map(String::as_str),
|
||||
Some("ssh_key")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_HOST").map(String::as_str),
|
||||
Some("git.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_PORT").map(String::as_str),
|
||||
Some("22")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_USER").map(String::as_str),
|
||||
Some("deploy")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_BASE_URL").map(String::as_str),
|
||||
Some("https://api.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_API_KEY").map(String::as_str),
|
||||
Some("ak-123")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_TOKEN").map(String::as_str),
|
||||
Some("tok-456")
|
||||
);
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_SSH_KEY").map(String::as_str),
|
||||
Some("-----BEGIN KEY-----")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_sanitized_meta_and_secret_keys() {
|
||||
let mut snapshot = build_snapshot();
|
||||
snapshot.metadata.insert(
|
||||
"private-ip".to_string(),
|
||||
Value::String("10.0.0.8".to_string()),
|
||||
);
|
||||
let secrets = HashMap::from([(
|
||||
"access key id".to_string(),
|
||||
Value::String("access-1".to_string()),
|
||||
)]);
|
||||
|
||||
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
|
||||
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str),
|
||||
Some("10.0.0.8")
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.env
|
||||
.get("TARGET_SECRET_ACCESS_KEY_ID")
|
||||
.map(String::as_str),
|
||||
Some("access-1")
|
||||
);
|
||||
}
|
||||
}
|
||||
168
crates/desktop-daemon/src/vault_client.rs
Normal file
168
crates/desktop-daemon/src/vault_client.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EntrySummary {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "title")]
|
||||
pub name: String,
|
||||
#[serde(rename = "subtitle")]
|
||||
pub cipher_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EntryDetail {
|
||||
pub id: String,
|
||||
#[serde(rename = "title")]
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "entry_type")]
|
||||
pub cipher_type: String,
|
||||
pub metadata: Vec<DetailField>,
|
||||
pub secrets: Vec<SecretField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DetailField {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretField {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub secret_type: String,
|
||||
pub masked_value: String,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretValueField {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretHistoryItem {
|
||||
pub history_id: i64,
|
||||
pub secret_id: String,
|
||||
pub name: String,
|
||||
pub secret_type: String,
|
||||
pub masked_value: String,
|
||||
pub value: String,
|
||||
pub version: i64,
|
||||
pub action: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn authorized_get(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
query: &[(&str, String)],
|
||||
) -> Result<reqwest::Response> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}{}", state.session_base, path))
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||
}
|
||||
|
||||
pub async fn authorized_patch(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
) -> Result<reqwest::Response> {
|
||||
state
|
||||
.client
|
||||
.patch(format!("{}{}", state.session_base, path))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||
}
|
||||
|
||||
pub async fn authorized_post(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
) -> Result<reqwest::Response> {
|
||||
state
|
||||
.client
|
||||
.post(format!("{}{}", state.session_base, path))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||
}
|
||||
|
||||
pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result<EntryDetail> {
|
||||
authorized_get(state, &format!("/vault/entries/{entry_id}"), &[])
|
||||
.await?
|
||||
.json::<EntryDetail>()
|
||||
.await
|
||||
.context("failed to decode entry detail")
|
||||
}
|
||||
|
||||
pub async fn fetch_revealed_entry_secrets(
|
||||
state: &AppState,
|
||||
entry_id: &str,
|
||||
) -> Result<Vec<SecretValueField>> {
|
||||
let detail = fetch_entry_detail(state, entry_id).await?;
|
||||
let mut secrets = Vec::new();
|
||||
for secret in detail.secrets {
|
||||
let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[])
|
||||
.await?
|
||||
.json::<SecretValueField>()
|
||||
.await
|
||||
.context("failed to decode revealed secret value")?;
|
||||
secrets.push(item);
|
||||
}
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value {
|
||||
let revealed_by_id: HashMap<&str, &SecretValueField> = revealed
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|secret| (secret.id.as_str(), secret))
|
||||
.collect();
|
||||
json!({
|
||||
"id": detail.id,
|
||||
"folder": detail.folder,
|
||||
"name": detail.name,
|
||||
"type": detail.cipher_type,
|
||||
"metadata": detail.metadata.iter().map(|field| {
|
||||
json!({
|
||||
"label": field.label,
|
||||
"value": field.value
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"secrets": detail.secrets.iter().map(|secret| {
|
||||
let revealed = revealed_by_id.get(secret.id.as_str());
|
||||
json!({
|
||||
"id": secret.id,
|
||||
"name": secret.name,
|
||||
"type": secret.secret_type,
|
||||
"masked_value": secret.masked_value,
|
||||
"value": revealed.map(|item| item.value.clone()),
|
||||
"version": secret.version
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user