256 lines
10 KiB
Rust
256 lines
10 KiB
Rust
//! `relicario device {add, revoke, list}` — device key management.
|
|
//!
|
|
//! Note: command bodies live here as `crate::commands::device`. Local key
|
|
//! storage and git-signing config live separately in `crate::device`.
|
|
|
|
use anyhow::Result;
|
|
|
|
use crate::DeviceAction;
|
|
|
|
/// Build a `GiteaClient` from flags or environment variables.
|
|
fn load_gitea_client(
|
|
gitea_url: Option<String>,
|
|
gitea_token: Option<String>,
|
|
owner: Option<String>,
|
|
repo: Option<String>,
|
|
) -> Result<crate::gitea::GiteaClient> {
|
|
let url = gitea_url
|
|
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
|
.ok_or_else(|| anyhow::anyhow!(
|
|
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
|
))?;
|
|
let token = gitea_token
|
|
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
|
.ok_or_else(|| anyhow::anyhow!(
|
|
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
|
))?;
|
|
let owner = owner
|
|
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
|
.ok_or_else(|| anyhow::anyhow!(
|
|
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
|
))?;
|
|
let repo = repo
|
|
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
|
.ok_or_else(|| anyhow::anyhow!(
|
|
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
|
))?;
|
|
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
|
}
|
|
|
|
pub fn cmd_device(action: DeviceAction) -> Result<()> {
|
|
use std::fs;
|
|
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
|
|
|
let root = crate::helpers::vault_dir()?;
|
|
let relicario_dir = root.join(".relicario");
|
|
let devices_path = relicario_dir.join("devices.json");
|
|
|
|
match action {
|
|
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
|
// Guard: don't overwrite an already-registered device name.
|
|
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default();
|
|
if existing.iter().any(|d| d.name == name) {
|
|
anyhow::bail!("a device named '{}' is already registered", name);
|
|
}
|
|
|
|
eprintln!("Generating signing keypair...");
|
|
let (signing_priv, signing_pub) = generate_keypair()
|
|
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
|
|
|
eprintln!("Generating deploy keypair...");
|
|
let (deploy_priv, deploy_pub) = generate_keypair()
|
|
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
|
|
|
// Optionally register deploy key with Gitea.
|
|
let gitea_key_id: u64 = if no_gitea {
|
|
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
|
0
|
|
} else {
|
|
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
|
let key_title = format!("relicario-{}", name);
|
|
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
|
client.create_deploy_key(&key_title, &deploy_pub)?
|
|
};
|
|
|
|
// Store keys locally with proper permissions.
|
|
crate::device::store_device_keys(
|
|
&name,
|
|
&signing_priv,
|
|
&signing_pub,
|
|
&deploy_priv,
|
|
&deploy_pub,
|
|
gitea_key_id,
|
|
)?;
|
|
|
|
// Mark as current device.
|
|
crate::device::set_current_device(&name)?;
|
|
|
|
// Configure git signing + SSH deploy key in the vault repo.
|
|
crate::device::configure_git_signing(&root, &name)?;
|
|
|
|
// Update devices.json.
|
|
let current_name = name.clone();
|
|
let mut devices = existing;
|
|
devices.push(DeviceEntry {
|
|
name: name.clone(),
|
|
public_key: signing_pub.clone(),
|
|
added_at: relicario_core::now_unix(),
|
|
added_by: current_name,
|
|
});
|
|
fs::create_dir_all(&relicario_dir)?;
|
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
|
|
|
// Commit the update.
|
|
crate::helpers::git_run(
|
|
&root,
|
|
&["add", ".relicario/devices.json"],
|
|
&format!("device register \"{name}\": git add .relicario/devices.json"),
|
|
)?;
|
|
let msg = format!("device: register {}", name);
|
|
crate::helpers::git_run(
|
|
&root,
|
|
&["commit", "-m", &msg],
|
|
&format!("device register \"{name}\": git commit"),
|
|
)?;
|
|
|
|
eprintln!("Device '{}' registered.", name);
|
|
eprintln!("Signing public key:");
|
|
eprintln!(" {}", signing_pub);
|
|
if gitea_key_id != 0 {
|
|
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
DeviceAction::Revoke { name } => {
|
|
// Guard: refuse to revoke the currently active device (would lock
|
|
// the user out). They must add another device first.
|
|
if let Some(current) = crate::device::current_device()? {
|
|
if current == name {
|
|
anyhow::bail!(
|
|
"cannot revoke the current device '{}' — you would lose \
|
|
push access. Register another device first.",
|
|
name
|
|
);
|
|
}
|
|
}
|
|
|
|
// Load devices.json.
|
|
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default();
|
|
|
|
let device = devices
|
|
.iter()
|
|
.find(|d| d.name == name)
|
|
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
|
.clone();
|
|
|
|
// Remove from devices.json.
|
|
devices.retain(|d| d.name != name);
|
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
|
|
|
// Append to revoked.json.
|
|
let revoked_path = relicario_dir.join("revoked.json");
|
|
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default();
|
|
|
|
let revoked_by = crate::device::current_device()?
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
revoked.push(RevokedEntry {
|
|
name: name.clone(),
|
|
public_key: device.public_key.clone(),
|
|
revoked_at: relicario_core::now_unix(),
|
|
revoked_by,
|
|
});
|
|
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
|
|
|
// Delete deploy key from Gitea (best-effort — don't fail if it
|
|
// was already deleted or the config is missing).
|
|
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
|
if key_id != 0 {
|
|
// Build client from env vars only (no flags in revoke).
|
|
match load_gitea_client(None, None, None, None) {
|
|
Ok(client) => {
|
|
if let Err(e) = client.delete_deploy_key(key_id) {
|
|
eprintln!(
|
|
"warning: failed to delete Gitea deploy key {}: {}",
|
|
key_id, e
|
|
);
|
|
} else {
|
|
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
|
}
|
|
}
|
|
Err(_) => {
|
|
eprintln!(
|
|
"warning: Gitea env vars not set — deploy key {} \
|
|
not deleted from Gitea.",
|
|
key_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commit devices.json + revoked.json (always both — revoked.json
|
|
// was just written above so it is guaranteed to exist).
|
|
let add_args = [
|
|
"add",
|
|
".relicario/devices.json",
|
|
".relicario/revoked.json",
|
|
];
|
|
crate::helpers::git_run(
|
|
&root,
|
|
&add_args,
|
|
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
|
|
)?;
|
|
let msg = format!("device: revoke {}", name);
|
|
crate::helpers::git_run(
|
|
&root,
|
|
&["commit", "-m", &msg],
|
|
&format!("device revoke \"{name}\": git commit"),
|
|
)?;
|
|
|
|
eprintln!("Device '{}' revoked.", name);
|
|
eprintln!("Revoked signing key: {}", device.public_key);
|
|
Ok(())
|
|
}
|
|
|
|
DeviceAction::List => {
|
|
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default();
|
|
|
|
let current = crate::device::current_device()?.unwrap_or_default();
|
|
|
|
if devices.is_empty() {
|
|
println!("No registered devices.");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
|
println!("{}", "-".repeat(72));
|
|
for d in &devices {
|
|
let marker = if d.name == current { " *" } else { "" };
|
|
let added = crate::helpers::iso8601(d.added_at);
|
|
// Show only the first 40 chars of the public key line for readability.
|
|
let key_prefix: String = d.public_key.chars().take(40).collect();
|
|
println!("{:<20} {:<20} {}{}",
|
|
d.name, added, key_prefix, marker);
|
|
}
|
|
if !current.is_empty() {
|
|
println!("\n* = current device");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|