feat: add CLI commands and server default behavior

- Introduced CLI commands for server, login, upload, sign, verify, and more using `clap`.
- Updated Dockerfile and docker-compose to default to `server` command on startup.
- Enhanced `test_local.sh` for testing the server and client operations.
- Added multipart support to `reqwest` and new CLI documentation in `README.md`.
- Updated `Cargo.toml` with new dependencies to support CLI and multipart uploads.
This commit is contained in:
Harald Hoyer 2025-03-20 16:23:29 +01:00
parent c662dfbfd8
commit 26e81cef17
7 changed files with 461 additions and 29 deletions

View file

@ -2,6 +2,7 @@
## Build & Test Commands
- Build & run: `cargo build && cargo run`
- Run server: `cargo run server`
- Run tests: `cargo test` (or `cargo test -- --nocapture` for verbose output)
- Run single test: `cargo test test_name -- --nocapture`
- Docker test: `./test_docker.sh` (includes vault initialization)
@ -9,6 +10,15 @@
- Lint: `cargo clippy -- -D warnings`
- Format: `cargo fmt --all`
## CLI Commands
- Start server: `cargo run server [--vault-addr URL] [--api-port PORT]`
- Login: `cargo run login --username USER --password PASS [--api-url URL]`
- Upload document: `cargo run upload --name NAME --file PATH [--api-url URL]`
- Sign document: `cargo run sign --document-id ID --username USER --token TOKEN [--api-url URL]`
- Verify document: `cargo run verify --document-id ID [--api-url URL]`
- List documents: `cargo run list [--api-url URL]`
- Get document details: `cargo run get --document-id ID [--api-url URL]`
## Code Style Guidelines
- **Formatting**: Follow rustfmt conventions (run `cargo fmt` before committing)
- **Imports**: Group by crate (stdlib → external → internal)

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.11.18", features = ["json"] }
reqwest = { version = "0.11.18", features = ["json", "multipart"] }
tokio = { version = "1.28.0", features = ["full"] }
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"
@ -15,3 +15,4 @@ sha2 = "0.10.6"
base64 = "0.21.0"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
clap = { version = "4.4.6", features = ["derive", "env"] }

View file

@ -31,5 +31,7 @@ RUN apt-get update && apt-get install -y vault
WORKDIR /usr/local/bin
COPY --from=builder /usr/src/vault-hier/target/release/vault-hier .
# Set the entrypoint to directly run the Rust binary
# Set the entrypoint to directly run the Rust binary with the server command by default
ENTRYPOINT ["/usr/local/bin/vault-hier"]
CMD ["server"]

View file

@ -7,6 +7,7 @@ This project implements a hierarchical document signing system using HashiCorp V
- **Hierarchical Signing**: Requires 3 of 5 signatures to validate a document, with at least 1 signature from each department
- **Department Structure**: Two departments (Legal and Finance) with 5 users each
- **Document API**: Upload, sign, and verify documents through a RESTful API
- **CLI Client**: Interact with the system through command-line interface
- **Vault Integration**: Leverages HashiCorp Vault's Transit engine for cryptographic operations
## System Architecture
@ -50,7 +51,46 @@ The system consists of:
- Legal department: legal1/legal1pass through legal5/legal5pass
- Finance department: finance1/finance1pass through finance5/finance5pass
### API Usage Examples
### CLI Commands
The project includes a command-line interface to interact with the API:
1. **Start the Server**:
```bash
cargo run server [--vault-addr URL] [--api-port PORT]
```
2. **Login**:
```bash
cargo run login --username USER --password PASS [--api-url URL]
```
3. **Upload Document**:
```bash
cargo run upload --name NAME --file PATH [--api-url URL]
```
4. **Sign Document**:
```bash
cargo run sign --document-id ID --username USER --token TOKEN [--api-url URL]
```
5. **Verify Document**:
```bash
cargo run verify --document-id ID [--api-url URL]
```
6. **List Documents**:
```bash
cargo run list [--api-url URL]
```
7. **Get Document Details**:
```bash
cargo run get --document-id ID [--api-url URL]
```
### API Usage Examples (curl)
1. **Login**:
```bash

View file

@ -39,6 +39,8 @@ services:
deploy:
restart_policy:
condition: none
# Run with 'server' command
command: server --vault-addr http://vault:8200 --api-port 3000
volumes:
vault-data:

View file

@ -1,11 +1,111 @@
use anyhow::Result;
use std::env;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::{info};
use tracing_subscriber::{fmt, EnvFilter};
// Import our library
use vault_hier::{start_api, initialize_vault};
#[derive(Parser)]
#[command(author, version, about = "Hierarchical Document Signing with HashiCorp Vault")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the vault-hier server
Server {
/// Address of the Vault server
#[arg(long, env = "VAULT_ADDR", default_value = "http://127.0.0.1:8200")]
vault_addr: String,
/// Port for the API server
#[arg(long, env = "API_PORT", default_value_t = 3000)]
api_port: u16,
},
/// Login to get an authentication token
Login {
/// Username for authentication
#[arg(short, long)]
username: String,
/// Password for authentication
#[arg(short, long)]
password: String,
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
/// Upload a document
Upload {
/// Name of the document
#[arg(short, long)]
name: String,
/// Path to the document file
#[arg(short, long)]
file: PathBuf,
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
/// Sign a document
Sign {
/// Document ID to sign
#[arg(short, long)]
document_id: String,
/// Username for signing
#[arg(short, long)]
username: String,
/// Authentication token
#[arg(short, long)]
token: String,
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
/// Verify a document's signatures
Verify {
/// Document ID to verify
#[arg(short, long)]
document_id: String,
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
/// List all documents
List {
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
/// Get document details
Get {
/// Document ID to retrieve
#[arg(short, long)]
document_id: String,
/// API server address
#[arg(long, default_value = "http://localhost:3000")]
api_url: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize tracing
@ -14,27 +114,230 @@ async fn main() -> Result<()> {
.with_target(false)
.init();
// Get Vault address from env var or use default
let vault_addr = env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
let cli = Cli::parse();
// Get API port from env var or use default
let api_port = env::var("API_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse::<u16>()
.unwrap_or(3000);
match cli.command {
Commands::Server { vault_addr, api_port } => {
run_server(&vault_addr, api_port).await?;
},
Commands::Login { username, password, api_url } => {
login(&username, &password, &api_url).await?;
},
Commands::Upload { name, file, api_url } => {
upload_document(&name, file, &api_url).await?;
},
Commands::Sign { document_id, username, token, api_url } => {
sign_document(&document_id, &username, &token, &api_url).await?;
},
Commands::Verify { document_id, api_url } => {
verify_document(&document_id, &api_url).await?;
},
Commands::List { api_url } => {
list_documents(&api_url).await?;
},
Commands::Get { document_id, api_url } => {
get_document(&document_id, &api_url).await?;
},
}
Ok(())
}
async fn run_server(vault_addr: &str, api_port: u16) -> Result<()> {
info!("Vault address: {}", vault_addr);
info!("Connecting to Vault at: {}", vault_addr);
// Initialize and unseal Vault, get the root token
let root_token = initialize_vault(&vault_addr).await?;
let root_token = initialize_vault(vault_addr).await?;
info!("Starting hierarchical document signing API...");
// Start the hierarchical signing API
start_api(&vault_addr, &root_token, api_port).await?;
start_api(vault_addr, &root_token, api_port).await?;
info!("API server shutdown. Exiting.");
Ok(())
}
async fn login(username: &str, password: &str, api_url: &str) -> Result<()> {
info!("Logging in as user: {}", username);
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/api/login", api_url))
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.send()
.await?;
if response.status().is_success() {
let data: serde_json::Value = response.json().await?;
println!("Login successful!");
println!("Token: {}", data["token"]);
println!("Department: {}", data["department"]);
} else {
let error_text = response.text().await?;
println!("Login failed: {}", error_text);
}
Ok(())
}
async fn upload_document(name: &str, file_path: PathBuf, api_url: &str) -> Result<()> {
info!("Uploading document: {}", name);
let file_content = tokio::fs::read(&file_path).await?;
let file_name = file_path.file_name().unwrap().to_string_lossy();
let form = reqwest::multipart::Form::new()
.text("name", name.to_string())
.part("file", reqwest::multipart::Part::bytes(file_content)
.file_name(file_name.to_string()));
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/api/documents", api_url))
.multipart(form)
.send()
.await?;
if response.status().is_success() {
let data: serde_json::Value = response.json().await?;
println!("Document uploaded successfully!");
println!("Document ID: {}", data["id"]);
} else {
let error_text = response.text().await?;
println!("Upload failed: {}", error_text);
}
Ok(())
}
async fn sign_document(document_id: &str, username: &str, token: &str, api_url: &str) -> Result<()> {
info!("Signing document: {}", document_id);
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/api/documents/{}/sign", api_url, document_id))
.json(&serde_json::json!({
"username": username,
"token": token,
}))
.send()
.await?;
if response.status().is_success() {
let data: serde_json::Value = response.json().await?;
println!("Document signed successfully!");
println!("Signature ID: {}", data["signature_id"]);
} else {
let error_text = response.text().await?;
println!("Signing failed: {}", error_text);
}
Ok(())
}
async fn verify_document(document_id: &str, api_url: &str) -> Result<()> {
info!("Verifying document: {}", document_id);
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/documents/{}/verify", api_url, document_id))
.send()
.await?;
if response.status().is_success() {
let data: serde_json::Value = response.json().await?;
println!("Verification result:");
println!(" Valid: {}", data["valid"]);
println!(" Total signatures: {}", data["signature_count"]);
println!(" Departments represented: {}", data["departments_represented"]);
if let Some(signatures) = data["signatures"].as_array() {
println!("\nSignatures:");
for (i, sig) in signatures.iter().enumerate() {
println!(" {}. User: {}, Department: {}, Time: {}",
i+1,
sig["username"],
sig["department"],
sig["timestamp"]);
}
}
} else {
let error_text = response.text().await?;
println!("Verification failed: {}", error_text);
}
Ok(())
}
async fn list_documents(api_url: &str) -> Result<()> {
info!("Listing all documents");
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/documents", api_url))
.send()
.await?;
if response.status().is_success() {
let data: Vec<serde_json::Value> = response.json().await?;
println!("Documents:");
for (i, doc) in data.iter().enumerate() {
println!(" {}. ID: {}, Name: {}", i+1, doc["id"], doc["name"]);
}
if data.is_empty() {
println!(" No documents found.");
}
} else {
let error_text = response.text().await?;
println!("Failed to list documents: {}", error_text);
}
Ok(())
}
async fn get_document(document_id: &str, api_url: &str) -> Result<()> {
info!("Getting document: {}", document_id);
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/documents/{}", api_url, document_id))
.send()
.await?;
if response.status().is_success() {
let doc: serde_json::Value = response.json().await?;
println!("Document details:");
println!(" ID: {}", doc["id"]);
println!(" Name: {}", doc["name"]);
println!(" Hash: {}", doc["hash"]);
println!(" Created: {}", doc["created_at"]);
if let Some(signatures) = doc["signatures"].as_array() {
println!("\nSignatures:");
for (i, sig) in signatures.iter().enumerate() {
println!(" {}. User: {}, Department: {}, Time: {}",
i+1,
sig["username"],
sig["department"],
sig["timestamp"]);
}
if signatures.is_empty() {
println!(" No signatures yet.");
}
}
} else {
let error_text = response.text().await?;
println!("Failed to get document: {}", error_text);
}
Ok(())
}

View file

@ -85,29 +85,102 @@ for i in {1..10}; do
sleep 2
done
# Build and run the Rust application
echo "Building and running the Rust application..."
cargo build && cargo run
# Build and run the Rust application with the server command
echo "Building and running the vault-hier server..."
cargo build && cargo run server --vault-addr "$VAULT_ADDR" &
SERVER_PID=$!
# Check if the credentials file was created
if [ -f "vault-credentials.txt" ]; then
echo "Test successful! Credentials were saved to vault-credentials.txt"
# Extract the unseal keys for demonstration
UNSEAL_KEYS=$(grep "Key" vault-credentials.txt | head -n 3 | awk '{print $3}')
ROOT_TOKEN=$(grep "Root Token" vault-credentials.txt | awk '{print $3}')
# Wait for the server to start
echo "Waiting for the server to start..."
sleep 5
echo "Root Token: $ROOT_TOKEN"
echo "First 3 Unseal Keys (needed for threshold):"
echo "$UNSEAL_KEYS"
# Test the server with some client operations
echo "Testing the client operations..."
# Clean up temporary files
rm -f vault-credentials.txt
else
echo "Test failed! Credentials file was not created."
# Create a sample file for testing
echo "Creating a sample file for testing..."
echo "This is a test document" > test_document.txt
# Test login
echo "Testing login with legal1 user..."
LOGIN_OUTPUT=$(cargo run login --username legal1 --password legal1pass)
TOKEN=$(echo "$LOGIN_OUTPUT" | grep "Token:" | awk '{print $2}')
if [ -z "$TOKEN" ]; then
echo "Login failed. Could not get token."
cat test_document.txt
kill -9 $SERVER_PID
kill -9 $VAULT_PID
rm "$VAULT_PID_FILE"
exit 1
fi
echo "Login successful, got token: ${TOKEN:0:8}..."
# Test upload
echo "Testing document upload..."
UPLOAD_OUTPUT=$(cargo run upload --name "Test Document" --file test_document.txt)
DOC_ID=$(echo "$UPLOAD_OUTPUT" | grep "Document ID:" | awk '{print $3}')
if [ -z "$DOC_ID" ]; then
echo "Upload failed. Could not get document ID."
kill -9 $SERVER_PID
kill -9 $VAULT_PID
rm "$VAULT_PID_FILE"
exit 1
fi
echo "Upload successful, got document ID: $DOC_ID"
# Test signing
echo "Testing document signing..."
SIGN_OUTPUT=$(cargo run sign --document-id "$DOC_ID" --username legal1 --token "$TOKEN")
if echo "$SIGN_OUTPUT" | grep -q "Document signed successfully"; then
echo "Document signed successfully"
else
echo "Signing failed"
kill -9 $SERVER_PID
kill -9 $VAULT_PID
rm "$VAULT_PID_FILE"
exit 1
fi
# Test verification
echo "Testing document verification..."
VERIFY_OUTPUT=$(cargo run verify --document-id "$DOC_ID")
if echo "$VERIFY_OUTPUT" | grep -q "Verification result"; then
echo "Verification successful"
else
echo "Verification failed"
kill -9 $SERVER_PID
kill -9 $VAULT_PID
rm "$VAULT_PID_FILE"
exit 1
fi
# Check if the credentials file was created
if [ -f "vault-credentials.txt" ] || [ -f "vault-credentials.json" ]; then
echo "Test successful! Credentials were saved"
if [ -f "vault-credentials.txt" ]; then
# Extract the unseal keys for demonstration
UNSEAL_KEYS=$(grep "Key" vault-credentials.txt | head -n 3 | awk '{print $3}')
ROOT_TOKEN=$(grep "Root Token" vault-credentials.txt | awk '{print $3}')
echo "Root Token: $ROOT_TOKEN"
echo "First 3 Unseal Keys (needed for threshold):"
echo "$UNSEAL_KEYS"
fi
else
echo "Warning: Credentials file was not created."
fi
echo -e "\nTest complete! Cleaning up..."
# Stop vault-hier server
kill -9 $SERVER_PID
# Stop Vault server
kill -9 $VAULT_PID
rm "$VAULT_PID_FILE"
@ -115,5 +188,6 @@ rm "$VAULT_PID_FILE"
# Clean up test environment
rm -rf /tmp/vault-test
rm -f ./vault_server.log
rm -f test_document.txt
echo "All cleaned up. Test successful!"