Add backend API for projects & snapshots
Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
1 parent
3d72c53262
commit
473e8e01fa
57 files changed
+3888
-281
No files matched your search
Generated
+122
-2
@@ -67,6 +67,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -107,12 +119,36 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -184,6 +220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -198,6 +235,18 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
@@ -279,6 +328,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@@ -302,6 +360,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
@@ -325,6 +393,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
@@ -419,6 +498,8 @@ name = "envsync-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"comfy-table",
|
||||
@@ -534,6 +615,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1137,6 +1228,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1282,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1292,9 +1394,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -2016,6 +2124,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -2082,6 +2196,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -5,7 +5,9 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
clap = "4.5.60"
|
||||
clap = { version = "4.5.60", features = ["derive", "env"] }
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
comfy-table = "7.2.2"
|
||||
console = "0.16.2"
|
||||
ctrlc = "3.5.2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
@@ -7,7 +7,6 @@ use tokio::time::sleep;
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceAuthRequest {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -20,59 +19,88 @@ struct DeviceAuthResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: u64,
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> {
|
||||
// 1. Request device code
|
||||
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url);
|
||||
|
||||
/// Exchange a refresh token for a new access token.
|
||||
pub async fn refresh_token(
|
||||
client: &Client,
|
||||
keycloak_url: &str,
|
||||
client_id: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
|
||||
let params = [
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", client_id),
|
||||
("refresh_token", refresh_token),
|
||||
];
|
||||
let resp = client.post(&token_url).form(¶ms).send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&text)
|
||||
.map_err(|e| anyhow!("Failed to parse refresh response: {e}"))?)
|
||||
} else {
|
||||
Err(anyhow!("Token refresh failed ({status}): {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
client: &Client,
|
||||
keycloak_url: &str,
|
||||
client_id: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let device_auth_url = format!("{keycloak_url}/protocol/openid-connect/auth/device");
|
||||
|
||||
let params = [("client_id", client_id)];
|
||||
let response = client.post(&device_auth_url).form(¶ms).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Device auth failed ({}): {}", status, body);
|
||||
anyhow::bail!("Device auth failed ({status}): {body}");
|
||||
}
|
||||
|
||||
let resp: DeviceAuthResponse = response.json().await?;
|
||||
|
||||
println!("Open this URL in your browser: {}", resp.verification_uri);
|
||||
println!("Open this URL in your browser:");
|
||||
println!(" {}", resp.verification_uri);
|
||||
println!("Enter the code: {}", resp.user_code);
|
||||
|
||||
// 2. Poll for token
|
||||
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url);
|
||||
let interval = Duration::from_secs(resp.interval);
|
||||
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
|
||||
let interval = Duration::from_secs(resp.interval.max(5));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed().as_secs() < resp.expires_in {
|
||||
loop {
|
||||
if start.elapsed().as_secs() >= resp.expires_in {
|
||||
anyhow::bail!("Authentication timed out. Please try again.");
|
||||
}
|
||||
|
||||
sleep(interval).await;
|
||||
|
||||
let params = [
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", &resp.device_code),
|
||||
("client_id", client_id),
|
||||
];
|
||||
let token_resp = client.post(&token_url).form(¶ms).send().await?;
|
||||
|
||||
let status = token_resp.status();
|
||||
let text = token_resp.text().await.unwrap_or_default();
|
||||
println!("Token response status: {}, body: {}", status, text);
|
||||
|
||||
if status.is_success() {
|
||||
let token: TokenResponse = serde_json::from_str(&text)?;
|
||||
return Ok(token.access_token);
|
||||
} else if status.as_u16() == 400 && text.contains("authorization_pending") {
|
||||
sleep(interval).await;
|
||||
return Ok(token);
|
||||
} else if status.as_u16() == 400
|
||||
&& (text.contains("authorization_pending") || text.contains("slow_down"))
|
||||
{
|
||||
continue;
|
||||
} else {
|
||||
anyhow::bail!("Unexpected token response: {} - {}", status, text);
|
||||
anyhow::bail!("Token request failed ({status}): {text}");
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Authentication timed out");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod vault;
|
||||
@@ -0,0 +1,190 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::EnvSyncError;
|
||||
|
||||
// ── Request types ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateProjectRequest {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PushSnapshotRequest {
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterPubkeyRequest {
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
// ── Response types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ProjectResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SnapshotMetaResponse {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub message: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SnapshotFullResponse {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub message: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ── Client ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct VaultClient {
|
||||
client: Client,
|
||||
api_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl VaultClient {
|
||||
pub fn new(api_url: impl Into<String>, token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_url: api_url.into(),
|
||||
token: token.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, resp: reqwest::Response) -> Result<reqwest::Response, EnvSyncError> {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let message = resp.text().await.unwrap_or_default();
|
||||
return Err(EnvSyncError::ApiError { status, message });
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn create_project(
|
||||
&self,
|
||||
name: &str,
|
||||
environment: &str,
|
||||
) -> Result<ProjectResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/projects", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&CreateProjectRequest {
|
||||
name: name.to_string(),
|
||||
environment: environment.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<ProjectResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/projects", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn push_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
req: PushSnapshotRequest,
|
||||
) -> Result<SnapshotMetaResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/projects/{project_id}/snapshots", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Option<SnapshotFullResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots/latest",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.check(resp).await?.json().await?))
|
||||
}
|
||||
|
||||
pub async fn list_snapshots(
|
||||
&self,
|
||||
project_id: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<SnapshotMetaResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots?limit={limit}",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
snap_id: &str,
|
||||
) -> Result<SnapshotFullResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots/{snap_id}",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn register_pubkey(&self, public_key: &str) -> Result<(), EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.put(format!("{}/api/users/me/pubkey", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&RegisterPubkeyRequest {
|
||||
public_key: public_key.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
self.check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "envsync",
|
||||
about = "Zero-knowledge encrypted environment variable manager",
|
||||
version
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// API server URL (overrides config, env: ENVSYNC_API_URL)
|
||||
#[arg(long, env = "ENVSYNC_API_URL", global = true)]
|
||||
pub api_url: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Authenticate with EnvSync via device code flow
|
||||
Login,
|
||||
|
||||
/// Log out and clear stored credentials
|
||||
Logout,
|
||||
|
||||
/// Initialize a new project in the current directory
|
||||
Init {
|
||||
/// Project name (defaults to directory name)
|
||||
#[arg(long, short)]
|
||||
name: Option<String>,
|
||||
/// Environment (e.g. development, staging, production)
|
||||
#[arg(long, short, default_value = "development")]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// Encrypt and push the local .env to the server
|
||||
Push {
|
||||
/// Commit message describing this snapshot
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
/// Force push even when behind remote version
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Decrypt and pull the latest .env snapshot from the server
|
||||
Pull {
|
||||
/// Overwrite local .env without prompting
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Show diff between local .env and latest remote snapshot
|
||||
Diff {
|
||||
/// Print actual values instead of masking with ***
|
||||
#[arg(long)]
|
||||
show_values: bool,
|
||||
},
|
||||
|
||||
/// Show snapshot history for this project
|
||||
Log {
|
||||
/// Maximum number of entries to display
|
||||
#[arg(long, short, default_value = "20")]
|
||||
limit: u32,
|
||||
},
|
||||
|
||||
/// Set a key in the vault and push a new snapshot
|
||||
Set {
|
||||
key: String,
|
||||
value: String,
|
||||
/// Commit message
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Remove a key from the vault and push a new snapshot
|
||||
Unset {
|
||||
key: String,
|
||||
/// Commit message
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Roll back the vault to a specific snapshot version
|
||||
Rollback {
|
||||
/// Version number to restore
|
||||
version: u32,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long, short)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Show current auth status and project configuration
|
||||
Status,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use console::style;
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(show_values: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let project = LocalProject::load()?;
|
||||
|
||||
// Load local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
let local_map: BTreeMap<String, String> = if env_path.exists() {
|
||||
let mut m = BTreeMap::new();
|
||||
for item in dotenvy::from_path_iter(&env_path)? {
|
||||
let (k, v) = item?;
|
||||
m.insert(k, v);
|
||||
}
|
||||
m
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
// Fetch remote
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let remote_map = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
None => {
|
||||
println!("No remote snapshot found. All local keys are new:");
|
||||
for k in local_map.keys() {
|
||||
let display = if show_values {
|
||||
local_map[k].as_str().to_string()
|
||||
} else {
|
||||
"***".to_string()
|
||||
};
|
||||
println!(" {}", style(format!("+{k}={display}")).green());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Some(snap) => {
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let ciphertext = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let m: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
m
|
||||
}
|
||||
};
|
||||
|
||||
let all_keys: std::collections::BTreeSet<&String> =
|
||||
local_map.keys().chain(remote_map.keys()).collect();
|
||||
|
||||
let mut has_diff = false;
|
||||
for key in all_keys {
|
||||
match (local_map.get(key), remote_map.get(key)) {
|
||||
(Some(lv), Some(rv)) if lv == rv => {} // unchanged
|
||||
(Some(lv), Some(rv)) => {
|
||||
has_diff = true;
|
||||
if show_values {
|
||||
println!(" {}", style(format!("~{key}: {rv} → {lv}")).yellow());
|
||||
} else {
|
||||
println!(" {}", style(format!("~{key}")).yellow());
|
||||
}
|
||||
}
|
||||
(Some(lv), None) => {
|
||||
has_diff = true;
|
||||
let display = if show_values { lv.as_str() } else { "***" };
|
||||
println!(" {}", style(format!("+{key}={display}")).green());
|
||||
}
|
||||
(None, Some(rv)) => {
|
||||
has_diff = true;
|
||||
let display = if show_values { rv.as_str() } else { "***" };
|
||||
println!(" {}", style(format!("-{key}={display}")).red());
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_diff {
|
||||
println!("No differences — local matches remote.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,118 +1,89 @@
|
||||
use anyhow::{Result, Context, anyhow};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sodiumoxide::crypto::box__;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
pub user_id: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KeypairConfig {
|
||||
pub public_key: String, // base64 encoded
|
||||
pub private_key: String, // base64 encrypted with a passphrase?
|
||||
}
|
||||
pub async fn run(
|
||||
name: Option<String>,
|
||||
env: Option<String>,
|
||||
api_url: &str,
|
||||
) -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VaultContext {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub vault_key: Option<String>, // base64 encrypted with user's public key
|
||||
}
|
||||
// Determine defaults from current directory
|
||||
let cwd = std::env::current_dir()?;
|
||||
let default_name = cwd
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("my-project")
|
||||
.to_string();
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct Config {
|
||||
pub api_url: String,
|
||||
pub auth: Option<AuthConfig>,
|
||||
pub keypair: Option<KeypairConfig>,
|
||||
pub current_context: Option<VaultContext>,
|
||||
}
|
||||
let project_name: String = if let Some(n) = name {
|
||||
n
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Project name")
|
||||
.default(default_name)
|
||||
.interact_text()?
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
ProjectDirs::from("com", "envsync", "cli")
|
||||
.expect("Failed to get config directory")
|
||||
.config_dir()
|
||||
.join("config.toml")
|
||||
}
|
||||
let environment: String = if let Some(e) = env {
|
||||
e
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Environment")
|
||||
.default("development".to_string())
|
||||
.interact_text()?
|
||||
};
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {:?}", path))?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
println!("Creating project '{project_name}' ({environment})...");
|
||||
|
||||
// Check .envsync doesn't already exist
|
||||
if LocalProject::path().exists() {
|
||||
let overwrite = Confirm::new()
|
||||
.with_prompt("A .envsync file already exists. Re-initialize?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !overwrite {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let project = vc.create_project(&project_name, &environment).await?;
|
||||
|
||||
let local = LocalProject {
|
||||
project_id: project.id.clone(),
|
||||
project_name: project.name.clone(),
|
||||
environment: project.environment.clone(),
|
||||
latest_version: 0,
|
||||
};
|
||||
local.save()?;
|
||||
|
||||
// Ensure .envsync is in .gitignore
|
||||
add_to_gitignore(&cwd)?;
|
||||
|
||||
println!("Initialized project '{}' (id: {})", project.name, project.id);
|
||||
println!(" Environment : {}", project.environment);
|
||||
println!(" .envsync : written (gitignored)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_to_gitignore(dir: &std::path::Path) -> Result<()> {
|
||||
let path = dir.join(".gitignore");
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if !content.lines().any(|l| l.trim() == ".envsync") {
|
||||
let sep = if content.ends_with('\n') || content.is_empty() { "" } else { "\n" };
|
||||
fs::write(&path, format!("{content}{sep}.envsync\n"))?;
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_auth_token(&self) -> Result<String> {
|
||||
self.auth
|
||||
.as_ref()
|
||||
.map(|a| a.access_token.clone())
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync init' first."))
|
||||
}
|
||||
|
||||
pub fn get_current_context(&self) -> Result<&VaultContext> {
|
||||
self.current_context
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No project initialized. Run 'envsync init' in your project."))
|
||||
}
|
||||
|
||||
pub fn set_current_context(&mut self, context: VaultContext) {
|
||||
self.current_context = Some(context);
|
||||
} else {
|
||||
fs::write(&path, ".envsync\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectConfig {
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub env_file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn from_current_dir(project_name: Option<String>, environment: Option<String>) -> Result<Self> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
|
||||
// Look for existing .env file
|
||||
let env_file_path = current_dir.join(".env");
|
||||
|
||||
// If project name not provided, use directory name
|
||||
let project_name = match project_name {
|
||||
Some(name) => name,
|
||||
None => current_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| anyhow!("Could not determine project name from directory"))?
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let environment = environment.unwrap_or_else(|| "development".to_string());
|
||||
|
||||
Ok(ProjectConfig {
|
||||
project_name,
|
||||
environment,
|
||||
env_file_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use anyhow::Result;
|
||||
use comfy_table::{Cell, Color, Table};
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
pub async fn run(limit: u32, api_url: &str) -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let snapshots = vc.list_snapshots(&project.project_id, limit).await?;
|
||||
|
||||
if snapshots.is_empty() {
|
||||
println!("No snapshots found for '{}'.", project.project_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec!["", "Version", "Date", "Message"]);
|
||||
|
||||
for snap in &snapshots {
|
||||
let current_marker = if snap.version == project.latest_version {
|
||||
"*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let msg = snap.message.as_deref().unwrap_or("-");
|
||||
let version_cell = if snap.version == project.latest_version {
|
||||
Cell::new(snap.version).fg(Color::Green)
|
||||
} else {
|
||||
Cell::new(snap.version)
|
||||
};
|
||||
table.add_row(vec![
|
||||
Cell::new(current_marker),
|
||||
version_cell,
|
||||
Cell::new(&snap.created_at),
|
||||
Cell::new(msg),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("Project: {} ({})", project.project_name, project.environment);
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::api::auth;
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{AuthConfig, Config, KeypairConfig};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let mut config = Config::load()?;
|
||||
let keycloak_url = config.keycloak_url.clone();
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
|
||||
println!("Authenticating with EnvSync...");
|
||||
let client = Client::new();
|
||||
let token_resp = auth::authenticate(&client, &keycloak_url, "envsync-app-cli").await?;
|
||||
|
||||
// Decode JWT payload (middle segment, URL-safe base64 without padding)
|
||||
let parts: Vec<&str> = token_resp.access_token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
anyhow::bail!("Invalid JWT format received from Keycloak");
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| anyhow!("Failed to decode JWT payload: {e}"))?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
|
||||
|
||||
let user_id = claims["sub"].as_str().unwrap_or("").to_string();
|
||||
let user_email = claims["email"].as_str().unwrap_or("").to_string();
|
||||
let exp = claims["exp"].as_i64().unwrap_or(0);
|
||||
|
||||
// Generate keypair on first login
|
||||
let keypair_config = if config.keypair.is_none() {
|
||||
println!("Generating a new encryption keypair...");
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Enter a passphrase to protect your private key")
|
||||
.with_confirmation("Confirm passphrase", "Passphrases do not match")
|
||||
.interact()?;
|
||||
|
||||
let salt = crypto::generate_salt();
|
||||
let master_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
let (pub_key, sec_key) = crypto::generate_keypair();
|
||||
let (ct, nonce) = crypto::encrypt_privkey(&sec_key, &master_key)?;
|
||||
|
||||
KeypairConfig {
|
||||
public_key: B64.encode(pub_key.0),
|
||||
private_key_ct: B64.encode(&ct),
|
||||
privkey_nonce: B64.encode(nonce.0),
|
||||
argon2_salt: B64.encode(salt),
|
||||
}
|
||||
} else {
|
||||
println!("Using existing keypair.");
|
||||
config.keypair.clone().unwrap()
|
||||
};
|
||||
|
||||
// Register public key with the backend
|
||||
let vc = VaultClient::new(effective_api_url, &token_resp.access_token);
|
||||
if let Err(e) = vc.register_pubkey(&keypair_config.public_key).await {
|
||||
eprintln!("Warning: failed to register public key with server: {e}");
|
||||
}
|
||||
|
||||
config.auth = Some(AuthConfig {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: exp,
|
||||
user_id: user_id.clone(),
|
||||
user_email: user_email.clone(),
|
||||
});
|
||||
config.keypair = Some(keypair_config);
|
||||
config.save()?;
|
||||
|
||||
println!("Logged in as {user_email} (sub: {user_id})");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let mut config = Config::load()?;
|
||||
|
||||
if config.auth.is_none() {
|
||||
println!("Not currently logged in.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let email = config
|
||||
.auth
|
||||
.as_ref()
|
||||
.map(|a| a.user_email.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
config.auth = None;
|
||||
// Keep the keypair — user can re-authenticate without re-generating keys.
|
||||
config.save()?;
|
||||
|
||||
println!("Logged out ({email}).");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod diff;
|
||||
pub mod init;
|
||||
pub mod log;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod rollback;
|
||||
pub mod set;
|
||||
pub mod status;
|
||||
pub mod unset;
|
||||
@@ -0,0 +1,88 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::{Confirm, Password};
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(force: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
let latest = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
println!("No snapshots found for this project yet.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !force && latest.version <= project.latest_version {
|
||||
println!("Already up to date (v{}).", project.latest_version);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
if !force && env_path.exists() {
|
||||
let proceed = Confirm::new()
|
||||
.with_prompt("This will overwrite your local .env. Continue?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !proceed {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Derive vault key
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Decrypt
|
||||
let ciphertext = B64.decode(&latest.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&latest.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
|
||||
write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = latest.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Pulled v{} → .env ({} keys)", latest.version, env_map.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_env_file(
|
||||
path: &std::path::Path,
|
||||
map: &BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
let content: String = map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}\n"))
|
||||
.collect();
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(message: Option<String>, force: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
if !env_path.exists() {
|
||||
anyhow::bail!("No .env file found in the current directory.");
|
||||
}
|
||||
|
||||
// Prompt passphrase and derive vault key
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Parse .env → BTreeMap (deterministic ordering)
|
||||
let env_map = parse_env_file(&env_path)?;
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
|
||||
// Encrypt
|
||||
let (ciphertext, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Conflict check
|
||||
if !force {
|
||||
if let Some(latest) = vc.get_latest_snapshot(&project.project_id).await? {
|
||||
if latest.version > project.latest_version {
|
||||
anyhow::bail!(
|
||||
"Remote is at v{}, you have v{}. Run 'envsync pull' first or use --force.",
|
||||
latest.version,
|
||||
project.latest_version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ciphertext),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!(
|
||||
"Pushed v{} to '{}' ({})",
|
||||
snap.version, project.project_name, project.environment
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> {
|
||||
let mut map = BTreeMap::new();
|
||||
for item in dotenvy::from_path_iter(path)? {
|
||||
let (k, v) = item?;
|
||||
map.insert(k, v);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::{Confirm, Password};
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(version: u32, yes: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Find the snapshot with the target version
|
||||
let snapshots = vc.list_snapshots(&project.project_id, 1000).await?;
|
||||
let target_meta = snapshots
|
||||
.iter()
|
||||
.find(|s| s.version == version)
|
||||
.ok_or_else(|| anyhow!("Snapshot v{version} not found."))?;
|
||||
|
||||
println!(
|
||||
"Rolling back to v{} — {} — {}",
|
||||
target_meta.version,
|
||||
target_meta.created_at,
|
||||
target_meta.message.as_deref().unwrap_or("-")
|
||||
);
|
||||
|
||||
if !yes {
|
||||
let confirmed = Confirm::new()
|
||||
.with_prompt("This will push a new snapshot with the content from that version. Continue?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !confirmed {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch full snapshot
|
||||
let snap = vc.get_snapshot(&project.project_id, &target_meta.id).await?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Decrypt
|
||||
let ciphertext = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
|
||||
// Re-encrypt with a fresh nonce and push as new snapshot (preserves history)
|
||||
let (new_ct, new_nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
let new_snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&new_ct),
|
||||
nonce: B64.encode(new_nonce.0),
|
||||
message: Some(format!("Rollback to v{version}")),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Write .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = new_snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!(
|
||||
"Rolled back to v{version} content → pushed as v{} ({} keys)",
|
||||
new_snap.version,
|
||||
env_map.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(key: String, value: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Pull current remote state (or start fresh)
|
||||
let mut env_map: BTreeMap<String, String> =
|
||||
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(snap) => {
|
||||
let ct = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||
serde_json::from_slice(&plaintext)?
|
||||
}
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
env_map.insert(key.clone(), value.clone());
|
||||
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let msg = message.unwrap_or_else(|| format!("Set {key}"));
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ct),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message: Some(msg),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Set {key}={value} → pushed v{}", snap.version);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
|
||||
println!("EnvSync Status");
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Auth status
|
||||
match &config.auth {
|
||||
None => {
|
||||
println!("Auth: not logged in");
|
||||
println!(" Run 'envsync login' to authenticate.");
|
||||
}
|
||||
Some(auth) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
let expires_in = auth.expires_at - now;
|
||||
let expiry_str = if expires_in > 0 {
|
||||
let dt = DateTime::<Utc>::from_timestamp(auth.expires_at, 0)
|
||||
.map(|d| d.format("%H:%M:%S UTC").to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
format!("expires at {dt} ({expires_in}s)")
|
||||
} else {
|
||||
"EXPIRED — run 'envsync login'".to_string()
|
||||
};
|
||||
|
||||
println!("Auth: logged in");
|
||||
println!("User: {}", auth.user_email);
|
||||
println!("Token: {expiry_str}");
|
||||
println!("Refresh: {}", if auth.refresh_token.is_some() { "available" } else { "none" });
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Keypair status
|
||||
match &config.keypair {
|
||||
None => println!("Keypair: not generated"),
|
||||
Some(kp) => println!("Keypair: {} (X25519)", &kp.public_key[..16]),
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Local project status
|
||||
match LocalProject::load() {
|
||||
Err(_) => println!("Project: no .envsync file (run 'envsync init')"),
|
||||
Ok(proj) => {
|
||||
println!("Project: {}", proj.project_name);
|
||||
println!("Env: {}", proj.environment);
|
||||
println!("Local v: v{}", proj.latest_version);
|
||||
println!("ID: {}", proj.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
println!("API: {}", config.api_url);
|
||||
println!("Keycloak: {}", config.keycloak_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(key: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
let mut env_map: BTreeMap<String, String> =
|
||||
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(snap) => {
|
||||
let ct = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||
serde_json::from_slice(&plaintext)?
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("No remote snapshot found; nothing to unset.");
|
||||
}
|
||||
};
|
||||
|
||||
if !env_map.contains_key(&key) {
|
||||
anyhow::bail!("Key '{key}' not found in the vault.");
|
||||
}
|
||||
env_map.remove(&key);
|
||||
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let msg = message.unwrap_or_else(|| format!("Unset {key}"));
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ct),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message: Some(msg),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Unset {key} → pushed v{}", snap.version);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine as _;
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn default_api_url() -> String {
|
||||
"http://localhost:8081".to_string()
|
||||
}
|
||||
|
||||
fn default_keycloak_url() -> String {
|
||||
"http://localhost:8180/realms/envsync".to_string()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AuthConfig {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Unix timestamp (seconds since epoch)
|
||||
pub expires_at: i64,
|
||||
pub user_id: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
/// X25519 keypair — private key is encrypted with the Argon2id-derived master key.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KeypairConfig {
|
||||
/// base64 X25519 public key (32 bytes)
|
||||
pub public_key: String,
|
||||
/// base64 encrypted private key ciphertext
|
||||
pub private_key_ct: String,
|
||||
/// base64 nonce used to encrypt the private key (24 bytes)
|
||||
pub privkey_nonce: String,
|
||||
/// base64 Argon2id salt (32 bytes)
|
||||
pub argon2_salt: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_api_url")]
|
||||
pub api_url: String,
|
||||
#[serde(default = "default_keycloak_url")]
|
||||
pub keycloak_url: String,
|
||||
pub auth: Option<AuthConfig>,
|
||||
pub keypair: Option<KeypairConfig>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_url: default_api_url(),
|
||||
keycloak_url: default_keycloak_url(),
|
||||
auth: None,
|
||||
keypair: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
ProjectDirs::from("com", "envsync", "cli")
|
||||
.expect("Failed to get config directory")
|
||||
.config_dir()
|
||||
.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn require_auth(&self) -> Result<&AuthConfig> {
|
||||
self.auth
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))
|
||||
}
|
||||
|
||||
/// Returns a valid AuthConfig, automatically refreshing the access token if it has
|
||||
/// expired (or will expire within 60 seconds) and a refresh token is available.
|
||||
pub async fn require_valid_auth(&mut self) -> Result<&AuthConfig> {
|
||||
let auth = self
|
||||
.auth
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Token still valid (with 60-second buffer)
|
||||
if auth.expires_at > now + 60 {
|
||||
return Ok(self.auth.as_ref().unwrap());
|
||||
}
|
||||
|
||||
// Attempt refresh if we have a refresh token
|
||||
let refresh_tok = match auth.refresh_token.clone() {
|
||||
Some(rt) if !rt.is_empty() => rt,
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Access token expired. Run 'envsync login' to re-authenticate."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let keycloak_url = self.keycloak_url.clone();
|
||||
|
||||
let new_tokens =
|
||||
crate::api::auth::refresh_token(&client, &keycloak_url, "envsync-app-cli", &refresh_tok)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Session expired. Run 'envsync login' to re-authenticate. ({e})"))?;
|
||||
|
||||
// Decode new expiry from JWT
|
||||
let exp = decode_jwt_exp(&new_tokens.access_token).unwrap_or(now + new_tokens.expires_in as i64);
|
||||
|
||||
let existing = self.auth.as_ref().unwrap();
|
||||
self.auth = Some(AuthConfig {
|
||||
access_token: new_tokens.access_token,
|
||||
refresh_token: new_tokens.refresh_token.or_else(|| existing.refresh_token.clone()),
|
||||
expires_at: exp,
|
||||
user_id: existing.user_id.clone(),
|
||||
user_email: existing.user_email.clone(),
|
||||
});
|
||||
self.save()?;
|
||||
|
||||
Ok(self.auth.as_ref().unwrap())
|
||||
}
|
||||
|
||||
pub fn require_keypair(&self) -> Result<&KeypairConfig> {
|
||||
self.keypair
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No keypair found. Run 'envsync login' first."))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jwt_exp(token: &str) -> Option<i64> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.ok()?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
claims["exp"].as_i64()
|
||||
}
|
||||
|
||||
/// Per-repository state stored in `.envsync` (gitignored).
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct LocalProject {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub latest_version: u32,
|
||||
}
|
||||
|
||||
impl LocalProject {
|
||||
pub fn path() -> PathBuf {
|
||||
std::env::current_dir()
|
||||
.expect("Failed to get current directory")
|
||||
.join(".envsync")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"No project found in current directory. Run 'envsync init' first."
|
||||
);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use sodiumoxide::crypto::{box_, secretbox};
|
||||
|
||||
use crate::error::EnvSyncError;
|
||||
|
||||
/// Derive a 32-byte vault key from a passphrase using Argon2id.
|
||||
pub fn derive_key(passphrase: &str, salt: &[u8; 32]) -> Result<[u8; 32], EnvSyncError> {
|
||||
let params = Params::new(65536, 3, 1, Some(32))
|
||||
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a random 32-byte Argon2id salt.
|
||||
pub fn generate_salt() -> [u8; 32] {
|
||||
let mut salt = [0u8; 32];
|
||||
sodiumoxide::randombytes::randombytes_into(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
/// Generate an X25519 keypair.
|
||||
pub fn generate_keypair() -> (box_::PublicKey, box_::SecretKey) {
|
||||
box_::gen_keypair()
|
||||
}
|
||||
|
||||
/// Encrypt plaintext with XSalsa20-Poly1305. Returns (ciphertext, nonce).
|
||||
pub fn encrypt_vault(
|
||||
plaintext: &[u8],
|
||||
key: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||
let sk = secretbox::Key::from_slice(key)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||
let nonce = secretbox::gen_nonce();
|
||||
let ciphertext = secretbox::seal(plaintext, &nonce, &sk);
|
||||
Ok((ciphertext, nonce))
|
||||
}
|
||||
|
||||
/// Decrypt ciphertext produced by `encrypt_vault`.
|
||||
pub fn decrypt_vault(
|
||||
ciphertext: &[u8],
|
||||
nonce: &secretbox::Nonce,
|
||||
key: &[u8; 32],
|
||||
) -> Result<Vec<u8>, EnvSyncError> {
|
||||
let sk = secretbox::Key::from_slice(key)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||
secretbox::open(ciphertext, nonce, &sk)
|
||||
.map_err(|_| EnvSyncError::CryptoError("Decryption failed — wrong passphrase?".to_string()))
|
||||
}
|
||||
|
||||
/// Encrypt an X25519 private key with the master key.
|
||||
pub fn encrypt_privkey(
|
||||
privkey: &box_::SecretKey,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||
encrypt_vault(&privkey.0, master_key)
|
||||
}
|
||||
|
||||
/// Decrypt an X25519 private key with the master key.
|
||||
pub fn decrypt_privkey(
|
||||
ct: &[u8],
|
||||
nonce: &secretbox::Nonce,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<box_::SecretKey, EnvSyncError> {
|
||||
let bytes = decrypt_vault(ct, nonce, master_key)?;
|
||||
box_::SecretKey::from_slice(&bytes)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid private key length".to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_vault() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let passphrase = "test-passphrase";
|
||||
let salt = generate_salt();
|
||||
let key = derive_key(passphrase, &salt).unwrap();
|
||||
let plaintext = b"FOO=bar\nDB=postgres://localhost/test";
|
||||
let (ct, nonce) = encrypt_vault(plaintext, &key).unwrap();
|
||||
let decrypted = decrypt_vault(&ct, &nonce, &key).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let salt = generate_salt();
|
||||
let key = derive_key("correct", &salt).unwrap();
|
||||
let (ct, nonce) = encrypt_vault(b"secret", &key).unwrap();
|
||||
let wrong_key = derive_key("wrong", &salt).unwrap();
|
||||
assert!(decrypt_vault(&ct, &nonce, &wrong_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_privkey() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let salt = generate_salt();
|
||||
let master_key = derive_key("passphrase", &salt).unwrap();
|
||||
let (_, sec) = generate_keypair();
|
||||
let (ct, nonce) = encrypt_privkey(&sec, &master_key).unwrap();
|
||||
let recovered = decrypt_privkey(&ct, &nonce, &master_key).unwrap();
|
||||
assert_eq!(sec.0, recovered.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EnvSyncError {
|
||||
#[error("Not authenticated. Run 'envsync login' first.")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("No project found. Run 'envsync init' in your project directory.")]
|
||||
NoProject,
|
||||
|
||||
#[error("API error ({status}): {message}")]
|
||||
ApiError { status: u16, message: String },
|
||||
|
||||
#[error("Crypto error: {0}")]
|
||||
CryptoError(String),
|
||||
|
||||
#[error("Config error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("Push conflict: remote has newer version. Use --force to override.")]
|
||||
PushConflict,
|
||||
|
||||
#[error("Snapshot version {0} not found.")]
|
||||
SnapshotNotFound(u32),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Http(#[from] reqwest::Error),
|
||||
}
|
||||
+31
-20
@@ -1,27 +1,38 @@
|
||||
// main.rs
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use clap::Parser;
|
||||
|
||||
// Import your authenticate function from the module where you defined it
|
||||
mod api;
|
||||
use api::auth::authenticate;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod error;
|
||||
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let keycloak_url = std::env::var("KEYCLOAK_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8180/realms/envsync".to_string());
|
||||
let client_id = std::env::var("KEYCLOAK_CLIENT_ID")
|
||||
.unwrap_or_else(|_| "envsync-app-cli".to_string());
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Create an HTTP client
|
||||
let client = Client::new();
|
||||
let api_url = cli.api_url.as_deref().unwrap_or("");
|
||||
|
||||
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser
|
||||
let token = authenticate(&client, &keycloak_url, &client_id).await?;
|
||||
let result = match cli.command {
|
||||
Commands::Login => commands::login::run(api_url).await,
|
||||
Commands::Logout => commands::logout::run(),
|
||||
Commands::Init { name, env } => commands::init::run(name, env, api_url).await,
|
||||
Commands::Push { message, force } => commands::push::run(message, force, api_url).await,
|
||||
Commands::Pull { force } => commands::pull::run(force, api_url).await,
|
||||
Commands::Diff { show_values } => commands::diff::run(show_values, api_url).await,
|
||||
Commands::Log { limit } => commands::log::run(limit, api_url).await,
|
||||
Commands::Set { key, value, message } => {
|
||||
commands::set::run(key, value, message, api_url).await
|
||||
}
|
||||
Commands::Unset { key, message } => commands::unset::run(key, message, api_url).await,
|
||||
Commands::Rollback { version, yes } => commands::rollback::run(version, yes, api_url).await,
|
||||
Commands::Status => async { commands::status::run() }.await,
|
||||
};
|
||||
|
||||
println!("Access Token: {}", token);
|
||||
|
||||
Ok(())
|
||||
|
||||
|
||||
}
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user