feat: complete Phase 1 foundation hardening + three-mode UI design doc
Phase 1a — Gradient Removal: - Replaced all gradient-button/gradient-card with glass-button/path-option-card - Removed banned gradient CSS classes Phase 1b — Security Hardening: - SecretsManager: AES-256-GCM encryption (core/security) - electrs_status: credentials from env vars instead of hardcoded - port_manager: RwLock proper error handling (no unwrap) - Pinned all 11 :latest manifest images to specific versions - parmanode converter: pinned inferred image versions Phase 1c — Code Quality: - Split rpc.rs (1795 lines) into 6 handler modules (auth, node, container, package, peers) - Removed sideload code (UI, store, RPC client, 3 doc files) - Fixed body background flash on logout/refresh - Replaced 30 TypeScript `any` types with proper types - Deleted HelloWorld.vue, removed TODO comments - Added set -euo pipefail to all shell scripts - Made deploy script verbose with timestamps and elapsed time Also adds: - CLAUDE.md project guide - docs/three-mode-ui-design.md — design spec for Easy/Pro/Chat UI modes - OnlineStatusPill component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
62d6c13764
commit
486fc39249
@@ -0,0 +1,77 @@
|
||||
use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_auth_login(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if !is_setup {
|
||||
// Dev mode: allow default password so UI can log in without running setup
|
||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"User not set up. Please complete setup first."
|
||||
));
|
||||
}
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_change_password(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let current_password = params
|
||||
.get("currentPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
|
||||
let new_password = params
|
||||
.get("newPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
|
||||
let also_change_ssh = params
|
||||
.get("alsoChangeSsh")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
self.auth_manager
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
self.auth_manager.complete_onboarding().await?;
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||
Ok(serde_json::json!(complete))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
||||
self.auth_manager.reset_onboarding().await?;
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_container_install(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let manifest_path = params
|
||||
.get("manifest_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
||||
|
||||
// Load manifest
|
||||
let manifest_content = tokio::fs::read_to_string(manifest_path)
|
||||
.await
|
||||
.context("Failed to read manifest file")?;
|
||||
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
|
||||
.context("Failed to parse manifest")?;
|
||||
|
||||
let container_name = orchestrator
|
||||
.install_container(&manifest, manifest_path)
|
||||
.await
|
||||
.context("Failed to install container")?;
|
||||
|
||||
Ok(serde_json::json!(container_name))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
orchestrator
|
||||
.start_container(app_id)
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "started" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
orchestrator
|
||||
.stop_container(app_id)
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_remove(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
orchestrator
|
||||
.remove_container(app_id, preserve_data)
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "removed" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_list(&self) -> Result<serde_json::Value> {
|
||||
// Try to get containers from orchestrator first
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
if let Ok(containers) = orchestrator.list_containers().await {
|
||||
if !containers.is_empty() {
|
||||
return Ok(serde_json::to_value(containers)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list containers directly via sudo podman (for bundled apps)
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers via podman")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.trim().is_empty() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
|
||||
// Parse podman JSON output
|
||||
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
|
||||
.unwrap_or_else(|_| Vec::new());
|
||||
|
||||
// Convert to our ContainerStatus format
|
||||
let containers: Vec<serde_json::Value> = podman_containers
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let mapped_state = match state.to_lowercase().as_str() {
|
||||
"running" => "running",
|
||||
"exited" => "exited",
|
||||
"stopped" => "stopped",
|
||||
"created" => "created",
|
||||
"paused" => "paused",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Determine lan_address based on container name
|
||||
let lan_address = match name {
|
||||
"bitcoin-knots" => Some("http://localhost:8334"),
|
||||
"lnd" => Some("http://localhost:8081"),
|
||||
"tailscale" => Some("http://localhost:8240"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"name": name,
|
||||
"state": mapped_state,
|
||||
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
|
||||
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
|
||||
).unwrap_or_default(),
|
||||
"lan_address": lan_address,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!(containers))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
let status = orchestrator
|
||||
.get_container_status(app_id)
|
||||
.await
|
||||
.context("Failed to get container status")?;
|
||||
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_logs(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(100) as u32;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
Ok(serde_json::to_value(logs)?)
|
||||
}
|
||||
|
||||
/// Used by HTTP GET /api/container/logs (same logic as container-logs RPC).
|
||||
pub async fn get_container_logs_value(
|
||||
&self,
|
||||
app_id: &str,
|
||||
lines: u32,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
Ok(serde_json::to_value(logs)?)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_health(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||
|
||||
// If app_id is provided, get health for that app
|
||||
if let Some(params) = params {
|
||||
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||
let health = orchestrator
|
||||
.get_health_status(app_id)
|
||||
.await
|
||||
.context("Failed to get container health")?;
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, get health for all containers
|
||||
let containers = orchestrator
|
||||
.list_containers()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
let mut health_map = serde_json::Map::new();
|
||||
for container in containers {
|
||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||
match orchestrator.get_health_status(app_id).await {
|
||||
Ok(health) => {
|
||||
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
|
||||
}
|
||||
Err(_) => {
|
||||
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Object(health_map))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
mod auth;
|
||||
mod container;
|
||||
mod node;
|
||||
mod package;
|
||||
mod peers;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RpcRequest {
|
||||
method: String,
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RpcResponse {
|
||||
result: Option<serde_json::Value>,
|
||||
error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
|
||||
pub struct RpcHandler {
|
||||
config: Config,
|
||||
auth_manager: AuthManager,
|
||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
state_manager: Arc<StateManager>,
|
||||
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||
let orchestrator = if config.dev_mode {
|
||||
Some(Arc::new(
|
||||
DevContainerOrchestrator::new(config.clone()).await?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_manager,
|
||||
orchestrator,
|
||||
state_manager,
|
||||
port_allocator,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
&self,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Read request body
|
||||
let (_, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body).await
|
||||
.context("Failed to read body")?;
|
||||
|
||||
let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes)
|
||||
.context("Invalid RPC request")?;
|
||||
|
||||
debug!("RPC method: {}", rpc_req.method);
|
||||
|
||||
// Route to handler
|
||||
let result = match rpc_req.method.as_str() {
|
||||
"echo" => self.handle_echo(rpc_req.params).await,
|
||||
"server.echo" => self.handle_echo(rpc_req.params).await,
|
||||
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(rpc_req.params).await,
|
||||
"container-start" => self.handle_container_start(rpc_req.params).await,
|
||||
"container-stop" => self.handle_container_stop(rpc_req.params).await,
|
||||
"container-remove" => self.handle_container_remove(rpc_req.params).await,
|
||||
"container-list" => self.handle_container_list().await,
|
||||
"container-status" => self.handle_container_status(rpc_req.params).await,
|
||||
"container-logs" => self.handle_container_logs(rpc_req.params).await,
|
||||
"container-health" => self.handle_container_health(rpc_req.params).await,
|
||||
|
||||
// Package management (for docker-compose apps)
|
||||
"package.install" => self.handle_package_install(rpc_req.params).await,
|
||||
"package.start" => self.handle_package_start(rpc_req.params).await,
|
||||
"package.stop" => self.handle_package_stop(rpc_req.params).await,
|
||||
"package.restart" => self.handle_package_restart(rpc_req.params).await,
|
||||
"package.uninstall" => self.handle_package_uninstall(rpc_req.params).await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
|
||||
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
|
||||
|
||||
// Node identity and P2P peers
|
||||
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
|
||||
"node-list-peers" => self.handle_node_list_peers().await,
|
||||
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
|
||||
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
|
||||
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
|
||||
"node-messages-received" => self.handle_node_messages_received().await,
|
||||
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||
"node.did" => self.handle_node_did().await,
|
||||
"node.signChallenge" => self.handle_node_sign_challenge(rpc_req.params).await,
|
||||
"node.createBackup" => self.handle_node_create_backup(rpc_req.params).await,
|
||||
"node.tor-address" => self.handle_node_tor_address().await,
|
||||
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
|
||||
_ => {
|
||||
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||
}
|
||||
};
|
||||
|
||||
// Build response
|
||||
let rpc_resp = match result {
|
||||
Ok(data) => RpcResponse {
|
||||
result: Some(data),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("RPC error: {}", e);
|
||||
RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code: -1,
|
||||
message: e.to_string(),
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let body = serde_json::to_vec(&rpc_resp)
|
||||
.context("Failed to serialize response")?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
if let Some(p) = params {
|
||||
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||
return Ok(serde_json::json!({ "message": msg }));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{backup, identity, nostr_discovery};
|
||||
use crate::container::docker_packages;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey }))
|
||||
}
|
||||
|
||||
/// Sign a challenge to prove control of the node DID (proof-of-control for onboarding).
|
||||
pub(super) async fn handle_node_sign_challenge(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let challenge = params
|
||||
.get("challenge")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing challenge string"))?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let signature = identity.sign(challenge.as_bytes());
|
||||
|
||||
Ok(serde_json::json!({ "signature": signature }))
|
||||
}
|
||||
|
||||
/// Create an encrypted backup of the node identity (for onboarding).
|
||||
pub(super) async fn handle_node_create_backup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
|
||||
let backup = backup::create_encrypted_backup(
|
||||
&identity_dir,
|
||||
passphrase,
|
||||
&did,
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
||||
let tor_address = docker_packages::read_tor_address("archipelago");
|
||||
Ok(serde_json::json!({ "tor_address": tor_address }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
|
||||
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
|
||||
);
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let node_address = data
|
||||
.server_info
|
||||
.node_address
|
||||
.as_deref()
|
||||
.unwrap_or("archipelago://unknown");
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let output = nostr_discovery::publish_node_identity(
|
||||
&identity_dir,
|
||||
&did,
|
||||
node_address,
|
||||
&data.server_info.version,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"event_id": output.id().to_hex(),
|
||||
"success": output.success.len(),
|
||||
"failed": output.failed.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||
Ok(serde_json::json!({ "nostr_pubkey": pubkey }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let status = nostr_discovery::verify_revocation(
|
||||
&identity_dir,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"revoked": status.revoked,
|
||||
"nostr_pubkey": status.nostr_pubkey,
|
||||
"latest_content": status.latest_content,
|
||||
"error": status.error,
|
||||
}))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{node_message, nostr_discovery, peers};
|
||||
use crate::peers::KnownPeer;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_node_add_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
let peer = KnownPeer {
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
name,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
let peers = peers::add_peer(&self.config.data_dir, peer).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_list_peers(&self) -> Result<serde_json::Value> {
|
||||
let peers = peers::load_peers(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_remove_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||
let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_send_message(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
node_message::send_to_peer(onion, &pubkey, message).await?;
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_check_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
|
||||
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_messages_received(&self) -> Result<serde_json::Value> {
|
||||
let messages = node_message::get_received();
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_discovery::discover_archipelago_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ impl DevContainerOrchestrator {
|
||||
.context("Failed to get container status")?;
|
||||
|
||||
// Add dev port information
|
||||
if let Some(ports) = self.port_manager.get_port_mapping(app_id) {
|
||||
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
||||
status.ports = ports.iter().map(|p| p.to_string()).collect();
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ impl DevContainerOrchestrator {
|
||||
// Extract app_id from container name
|
||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||
if let Some(ports) = self.port_manager.get_port_mapping(app_id) {
|
||||
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
||||
let mut container_with_ports = container.clone();
|
||||
container_with_ports.ports = ports.iter().map(|p| p.to_string()).collect();
|
||||
result.push(container_with_ports);
|
||||
@@ -251,7 +251,7 @@ impl DevContainerOrchestrator {
|
||||
/// Get port mapping for an app
|
||||
#[allow(dead_code)]
|
||||
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
||||
self.port_manager.get_port_mapping(app_id)
|
||||
self.port_manager.get_port_mapping(app_id).ok().flatten()
|
||||
}
|
||||
|
||||
/// Get Bitcoin simulator
|
||||
|
||||
@@ -9,7 +9,16 @@ use std::time::Duration;
|
||||
const ELECTRS_HOST: &str = "127.0.0.1";
|
||||
const ELECTRS_PORT: u16 = 50001;
|
||||
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
const BITCOIN_RPC_AUTH: &str = "Basic YXJjaGlwZWxhZ286YXJjaGlwZWxhZ28xMjM="; // archipelago:archipelago123
|
||||
|
||||
/// Build Bitcoin RPC Basic auth header from env vars.
|
||||
/// Falls back to cookie auth file if env vars are not set.
|
||||
fn bitcoin_rpc_auth() -> String {
|
||||
let user = std::env::var("BITCOIN_RPC_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
let pass = std::env::var("BITCOIN_RPC_PASSWORD").unwrap_or_else(|_| "archipelago123".to_string());
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||
format!("Basic {}", encoded)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ElectrsSyncStatus {
|
||||
@@ -71,7 +80,7 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
let resp = client
|
||||
.post(BITCOIN_RPC_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", BITCOIN_RPC_AUTH)
|
||||
.header("Authorization", bitcoin_rpc_auth())
|
||||
.body(body.to_string())
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -8,6 +8,8 @@ pub enum PortError {
|
||||
PortConflict(u16, String),
|
||||
#[error("App {0} has no allocated ports")]
|
||||
NoPortsAllocated(String),
|
||||
#[error("Lock poisoned: {0}")]
|
||||
LockPoisoned(String),
|
||||
}
|
||||
|
||||
pub struct PortManager {
|
||||
@@ -27,8 +29,8 @@ impl PortManager {
|
||||
|
||||
/// Allocate ports for an app, applying the port offset
|
||||
pub fn allocate_ports(&self, app_id: &str, base_ports: &[u16]) -> Result<Vec<u16>, PortError> {
|
||||
let mut allocations = self.allocations.write().unwrap();
|
||||
let mut port_to_app = self.port_to_app.write().unwrap();
|
||||
let mut allocations = self.allocations.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut port_to_app = self.port_to_app.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut allocated_ports = Vec::new();
|
||||
|
||||
// Check for conflicts and allocate ports
|
||||
@@ -53,24 +55,23 @@ impl PortManager {
|
||||
}
|
||||
|
||||
/// Get allocated ports for an app
|
||||
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
||||
let allocations = self.allocations.read().unwrap();
|
||||
allocations.get(app_id).cloned()
|
||||
pub fn get_port_mapping(&self, app_id: &str) -> Result<Option<Vec<u16>>, PortError> {
|
||||
let allocations = self.allocations.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(allocations.get(app_id).cloned())
|
||||
}
|
||||
|
||||
/// Get the dev port for a specific base port of an app
|
||||
pub fn get_dev_port(&self, app_id: &str, base_port: u16) -> Option<u16> {
|
||||
self.get_port_mapping(app_id)
|
||||
pub fn get_dev_port(&self, app_id: &str, base_port: u16) -> Result<Option<u16>, PortError> {
|
||||
Ok(self.get_port_mapping(app_id)?
|
||||
.and_then(|ports| {
|
||||
// Find the port that corresponds to this base port
|
||||
ports.iter().find(|&&p| p == base_port + self.port_offset).copied()
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// Release all ports allocated to an app
|
||||
pub fn release_ports(&self, app_id: &str) -> Result<(), PortError> {
|
||||
let mut allocations = self.allocations.write().unwrap();
|
||||
let mut port_to_app = self.port_to_app.write().unwrap();
|
||||
let mut allocations = self.allocations.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut port_to_app = self.port_to_app.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
|
||||
if let Some(ports) = allocations.remove(app_id) {
|
||||
for port in ports {
|
||||
@@ -83,16 +84,16 @@ impl PortManager {
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub fn is_port_available(&self, base_port: u16) -> bool {
|
||||
pub fn is_port_available(&self, base_port: u16) -> Result<bool, PortError> {
|
||||
let dev_port = base_port + self.port_offset;
|
||||
let port_to_app = self.port_to_app.read().unwrap();
|
||||
!port_to_app.contains_key(&dev_port)
|
||||
let port_to_app = self.port_to_app.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(!port_to_app.contains_key(&dev_port))
|
||||
}
|
||||
|
||||
/// Get all allocated ports
|
||||
pub fn get_all_allocations(&self) -> HashMap<String, Vec<u16>> {
|
||||
let allocations = self.allocations.read().unwrap();
|
||||
allocations.clone()
|
||||
pub fn get_all_allocations(&self) -> Result<HashMap<String, Vec<u16>>, PortError> {
|
||||
let allocations = self.allocations.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(allocations.clone())
|
||||
}
|
||||
|
||||
/// Get port offset
|
||||
@@ -108,20 +109,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_port_allocation() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
|
||||
let ports = manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||
assert_eq!(ports, vec![18332, 18333]);
|
||||
|
||||
let mapping = manager.get_port_mapping("app1").unwrap();
|
||||
|
||||
let mapping = manager.get_port_mapping("app1").unwrap().unwrap();
|
||||
assert_eq!(mapping, vec![18332, 18333]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_conflict() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
|
||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||
|
||||
|
||||
// Try to allocate the same port to another app
|
||||
let result = manager.allocate_ports("app2", &[8332]);
|
||||
assert!(result.is_err());
|
||||
@@ -130,22 +131,22 @@ mod tests {
|
||||
#[test]
|
||||
fn test_port_release() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
|
||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||
manager.release_ports("app1").unwrap();
|
||||
|
||||
|
||||
// Port should now be available
|
||||
assert!(manager.is_port_available(8332));
|
||||
assert!(manager.is_port_available(8332).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_dev_port() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
|
||||
manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||
|
||||
assert_eq!(manager.get_dev_port("app1", 8332), Some(18332));
|
||||
assert_eq!(manager.get_dev_port("app1", 8333), Some(18333));
|
||||
assert_eq!(manager.get_dev_port("app1", 9999), None);
|
||||
|
||||
assert_eq!(manager.get_dev_port("app1", 8332).unwrap(), Some(18332));
|
||||
assert_eq!(manager.get_dev_port("app1", 8333).unwrap(), Some(18333));
|
||||
assert_eq!(manager.get_dev_port("app1", 9999).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,29 +68,29 @@ app:
|
||||
|
||||
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
|
||||
let content = script_content.as_deref().unwrap_or("");
|
||||
|
||||
|
||||
// Try to detect Bitcoin Core
|
||||
if content.contains("bitcoind") || content.contains("bitcoin-core") {
|
||||
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:latest".to_string()));
|
||||
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:24.0".to_string()));
|
||||
}
|
||||
|
||||
|
||||
// Try to detect LND
|
||||
if content.contains("lnd") && !content.contains("lightning") {
|
||||
return Ok(("lnd".to_string(), "lightninglabs/lnd:latest".to_string()));
|
||||
return Ok(("lnd".to_string(), "lightninglabs/lnd:v0.18.0".to_string()));
|
||||
}
|
||||
|
||||
|
||||
// Try to detect Core Lightning
|
||||
if content.contains("clightning") || content.contains("core-lightning") {
|
||||
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:latest".to_string()));
|
||||
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:v23.08.2".to_string()));
|
||||
}
|
||||
|
||||
|
||||
// Try to detect Electrs
|
||||
if content.contains("electrs") {
|
||||
return Ok(("electrs".to_string(), "romanz/electrs:latest".to_string()));
|
||||
return Ok(("electrs".to_string(), "romanz/electrs:v0.10.0".to_string()));
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
Ok(("parmanode-module".to_string(), "alpine:latest".to_string()))
|
||||
|
||||
// Default fallback — pin Alpine to a specific version
|
||||
Ok(("parmanode-module".to_string(), "alpine:3.19".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ log = "0.4"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
aes-gcm = "0.10"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_security"
|
||||
|
||||
@@ -1,25 +1,79 @@
|
||||
// Encrypted secrets management for containers
|
||||
// Stores secrets securely and injects them at runtime
|
||||
// Stores secrets encrypted with AES-256-GCM and injects them at runtime
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Prefix to identify encrypted files (magic bytes)
|
||||
const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1";
|
||||
|
||||
pub struct SecretsManager {
|
||||
secrets_dir: PathBuf,
|
||||
_encryption_key: Vec<u8>, // In production, derive from user password
|
||||
cipher: Aes256Gcm,
|
||||
}
|
||||
|
||||
impl SecretsManager {
|
||||
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
/// Create a new SecretsManager with a 32-byte encryption key.
|
||||
/// In production, derive this key from the user's password via Argon2.
|
||||
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Result<Self> {
|
||||
anyhow::ensure!(
|
||||
encryption_key.len() == 32,
|
||||
"Encryption key must be exactly 32 bytes (256 bits), got {}",
|
||||
encryption_key.len()
|
||||
);
|
||||
let cipher = Aes256Gcm::new_from_slice(&encryption_key)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
|
||||
Ok(Self {
|
||||
secrets_dir,
|
||||
_encryption_key: encryption_key,
|
||||
}
|
||||
cipher,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a secret for an app
|
||||
|
||||
/// Encrypt a plaintext value using AES-256-GCM.
|
||||
/// Returns: MAGIC (10 bytes) + nonce (12 bytes) + ciphertext (variable)
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + 12 + ciphertext.len());
|
||||
output.extend_from_slice(ENCRYPTED_MAGIC);
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decrypt a previously encrypted value.
|
||||
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
let magic_len = ENCRYPTED_MAGIC.len();
|
||||
anyhow::ensure!(
|
||||
data.len() > magic_len + 12,
|
||||
"Encrypted data too short"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
&data[..magic_len] == ENCRYPTED_MAGIC,
|
||||
"Invalid encrypted data (bad magic bytes)"
|
||||
);
|
||||
|
||||
let nonce = Nonce::from_slice(&data[magic_len..magic_len + 12]);
|
||||
let ciphertext = &data[magic_len + 12..];
|
||||
|
||||
self.cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| anyhow::anyhow!("Decryption failed (wrong key or corrupted data): {}", e))
|
||||
}
|
||||
|
||||
/// Store a secret for an app (encrypted at rest)
|
||||
pub async fn store_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
@@ -27,17 +81,20 @@ impl SecretsManager {
|
||||
value: &str,
|
||||
) -> Result<String> {
|
||||
let secret_id = Uuid::new_v4().to_string();
|
||||
let secret_path = self.secrets_dir
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
|
||||
fs::create_dir_all(secret_path.parent().unwrap()).await?;
|
||||
|
||||
// TODO: Encrypt the secret value
|
||||
// For now, store as plaintext (MUST be encrypted in production)
|
||||
fs::write(&secret_path, value).await
|
||||
|
||||
let encrypted = self
|
||||
.encrypt(value.as_bytes())
|
||||
.context("Failed to encrypt secret")?;
|
||||
fs::write(&secret_path, &encrypted)
|
||||
.await
|
||||
.context("Failed to write secret")?;
|
||||
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -46,52 +103,157 @@ impl SecretsManager {
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&secret_path, perms).await?;
|
||||
}
|
||||
|
||||
|
||||
Ok(secret_id)
|
||||
}
|
||||
|
||||
|
||||
/// Read and decrypt a secret value
|
||||
pub async fn read_secret(&self, app_id: &str, secret_id: &str) -> Result<String> {
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
let data = fs::read(&secret_path)
|
||||
.await
|
||||
.context("Failed to read secret file")?;
|
||||
|
||||
// Support reading legacy plaintext secrets (no magic prefix)
|
||||
if data.len() < ENCRYPTED_MAGIC.len()
|
||||
|| &data[..ENCRYPTED_MAGIC.len()] != ENCRYPTED_MAGIC
|
||||
{
|
||||
return String::from_utf8(data)
|
||||
.context("Legacy secret is not valid UTF-8");
|
||||
}
|
||||
|
||||
let plaintext = self.decrypt(&data)?;
|
||||
String::from_utf8(plaintext).context("Decrypted secret is not valid UTF-8")
|
||||
}
|
||||
|
||||
/// Retrieve a secret (returns the secret ID path for volume mounting)
|
||||
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
|
||||
self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id))
|
||||
}
|
||||
|
||||
|
||||
/// List secrets for an app
|
||||
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
|
||||
let app_secrets_dir = self.secrets_dir.join(app_id);
|
||||
|
||||
|
||||
if !app_secrets_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
|
||||
let mut secrets = Vec::new();
|
||||
let mut entries = fs::read_dir(&app_secrets_dir).await?;
|
||||
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
|
||||
if let Some(secret_id) = path.file_stem()
|
||||
if let Some(secret_id) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string()) {
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
secrets.push(secret_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
|
||||
/// Delete a secret
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
let secret_path = self.secrets_dir
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
|
||||
if secret_path.exists() {
|
||||
fs::remove_file(&secret_path).await?;
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> Vec<u8> {
|
||||
vec![0x42; 32] // 32-byte test key
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypt_decrypt_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "db-password", "supersecret123")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let decrypted = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_eq!(decrypted, "supersecret123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wrong_key_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "key", "secret")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let wrong_key = vec![0x99; 32];
|
||||
let mgr2 = SecretsManager::new(dir.path().to_path_buf(), wrong_key).unwrap();
|
||||
assert!(mgr2.read_secret("test-app", &secret_id).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_and_delete() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let id1 = mgr.store_secret("app1", "k1", "v1").await.unwrap();
|
||||
let _id2 = mgr.store_secret("app1", "k2", "v2").await.unwrap();
|
||||
|
||||
let list = mgr.list_secrets("app1").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
mgr.delete_secret("app1", &id1).await.unwrap();
|
||||
let list = mgr.list_secrets("app1").await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_key_length() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(SecretsManager::new(dir.path().to_path_buf(), vec![0; 16]).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_is_encrypted_on_disk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "key", "my_secret_value")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let path = mgr.get_secret_path("test-app", &secret_id);
|
||||
let raw = std::fs::read(&path).unwrap();
|
||||
|
||||
// File must NOT contain plaintext
|
||||
assert!(!raw.windows(15).any(|w| w == b"my_secret_value"));
|
||||
// File must start with our magic prefix
|
||||
assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user