Author:
Hash:
Timestamp:
+114 -113 +/-7 browse
Kevin Schoon [me@kevinschoon.com]
32e63078700254c5e70fc5fe54350e68cdd14b16
Sat, 26 Jul 2025 19:31:32 +0000 (3 months ago)
| 1 | diff --git a/Cargo.lock b/Cargo.lock |
| 2 | index d3fc63c..798cd97 100644 |
| 3 | --- a/Cargo.lock |
| 4 | +++ b/Cargo.lock |
| 5 | @@ -384,12 +384,12 @@ version = "0.1.0" |
| 6 | dependencies = [ |
| 7 | "ayllu_config", |
| 8 | "ayllu_git", |
| 9 | + "ayllu_identity", |
| 10 | "clap 4.5.41", |
| 11 | "dialoguer", |
| 12 | "nix", |
| 13 | "serde", |
| 14 | "thiserror 2.0.12", |
| 15 | - "tracing", |
| 16 | "url", |
| 17 | ] |
| 18 | |
| 19 | diff --git a/ayllu-keys/src/main.rs b/ayllu-keys/src/main.rs |
| 20 | index 914bdf4..02e38b9 100644 |
| 21 | --- a/ayllu-keys/src/main.rs |
| 22 | +++ b/ayllu-keys/src/main.rs |
| 23 | @@ -58,6 +58,31 @@ fn authorized_keys(home_dir: &Path) -> ExitCode { |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | + fn identify<'a>(config: &'a Config, ca_key_type: &str, certificate: &str) -> Option<&'a Identity> { |
| 28 | + let user_key = |
| 29 | + match ayllu_identity::PublicKey::parse(&format!("{} {}", ca_key_type, certificate)) { |
| 30 | + Ok(key) => key, |
| 31 | + Err(e) => { |
| 32 | + eprintln!("Cannot parse SSH key from sshd: {e:?}"); |
| 33 | + return None; |
| 34 | + } |
| 35 | + }; |
| 36 | + |
| 37 | + match config.identities.iter().find(|identity| { |
| 38 | + identity |
| 39 | + .authorized_keys |
| 40 | + .iter() |
| 41 | + .find(|authorized_key| authorized_key.0 == user_key) |
| 42 | + .is_some() |
| 43 | + }) { |
| 44 | + Some(identity) => Some(identity), |
| 45 | + None => { |
| 46 | + eprintln!("Cannot identity a valid Ayllu user"); |
| 47 | + None |
| 48 | + } |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | fn main() -> ExitCode { |
| 53 | let args = Arguments::parse(); |
| 54 | |
| 55 | @@ -85,37 +110,44 @@ fn main() -> ExitCode { |
| 56 | args.username, args.home_dir, args.ca_key_type, args.certificate |
| 57 | ); |
| 58 | |
| 59 | - let user_key = match ayllu_identity::PublicKey::parse(&format!( |
| 60 | - "{} {}", |
| 61 | - args.ca_key_type, args.certificate |
| 62 | - )) { |
| 63 | - Ok(key) => key, |
| 64 | - Err(e) => { |
| 65 | - eprintln!("Cannot parse SSH key from sshd: {e:?}"); |
| 66 | - return ExitCode::FAILURE; |
| 67 | - } |
| 68 | - }; |
| 69 | - |
| 70 | - let identity = match config.identities.iter().find(|identity| { |
| 71 | - identity |
| 72 | - .authorized_keys |
| 73 | - .iter() |
| 74 | - .find(|authorized_key| authorized_key.0 == user_key) |
| 75 | - .is_some() |
| 76 | - }) { |
| 77 | - Some(identity) => identity, |
| 78 | - None => { |
| 79 | - eprintln!("Cannot identity a valid Ayllu user"); |
| 80 | - return ExitCode::FAILURE; |
| 81 | - } |
| 82 | - }; |
| 83 | - |
| 84 | - identity.authorized_keys.iter().for_each(|authorized_key| { |
| 85 | + if let Some(identity) = identify(&config, &args.ca_key_type, &args.certificate) { |
| 86 | + identity.authorized_keys.iter().for_each(|authorized_key| { |
| 87 | println!( |
| 88 | - "restrict,pty,command=\"{ayllu_shell_path} --config {GLOBAL_AYLLU_CONFIG}\",environment=\"AYLLU_USERNAME={}\" {}", |
| 89 | + "restrict,pty,command=\"{ayllu_shell_path} --config {GLOBAL_AYLLU_CONFIG} --username={}\" {}", |
| 90 | identity.username, authorized_key.0 |
| 91 | ); |
| 92 | }); |
| 93 | + } |
| 94 | |
| 95 | ExitCode::SUCCESS |
| 96 | } |
| 97 | + |
| 98 | + #[cfg(test)] |
| 99 | + mod test { |
| 100 | + |
| 101 | + use ayllu_identity::{PublicKey, WrappedKey}; |
| 102 | + |
| 103 | + use super::*; |
| 104 | + |
| 105 | + #[test] |
| 106 | + fn identify_user() { |
| 107 | + let public_key = PublicKey::parse("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDk35l66zSsC9hgnL5T5svFHLauTZNZ595ihvfBGj81+ demo@example.org").unwrap(); |
| 108 | + let cfg = Config { |
| 109 | + ayllu_shell: None, |
| 110 | + identities: vec![Identity { |
| 111 | + username: String::from("hello"), |
| 112 | + authorized_keys: vec![WrappedKey(public_key)], |
| 113 | + email: String::from("hello@example.org"), |
| 114 | + avatar: None, |
| 115 | + tagline: None, |
| 116 | + profiles: Vec::new(), |
| 117 | + }], |
| 118 | + }; |
| 119 | + assert!(identify( |
| 120 | + &cfg, |
| 121 | + "ssh-ed25519", |
| 122 | + "AAAAC3NzaC1lZDI1NTE5AAAAIDk35l66zSsC9hgnL5T5svFHLauTZNZ595ihvfBGj81+" |
| 123 | + ) |
| 124 | + .is_some_and(|identity| { identity.username == "hello" })); |
| 125 | + } |
| 126 | + } |
| 127 | diff --git a/ayllu-shell/Cargo.toml b/ayllu-shell/Cargo.toml |
| 128 | index 76f0b2b..ad9b18f 100644 |
| 129 | --- a/ayllu-shell/Cargo.toml |
| 130 | +++ b/ayllu-shell/Cargo.toml |
| 131 | @@ -7,11 +7,11 @@ edition = "2021" |
| 132 | |
| 133 | ayllu_config = { path = "../crates/config" } |
| 134 | ayllu_git = { path = "../crates/git"} |
| 135 | + ayllu_identity = { path = "../crates/identity" } |
| 136 | |
| 137 | clap = { workspace = true } |
| 138 | dialoguer = { version = "0.11.0", default-features = false } |
| 139 | nix = { version = "0.30.1", default-features = false, features = ["user"] } |
| 140 | serde = { workspace = true } |
| 141 | thiserror.workspace = true |
| 142 | - tracing = { workspace = true } |
| 143 | - url = "2.5.4" |
| 144 | + url = {workspace = true} |
| 145 | diff --git a/ayllu-shell/src/config.rs b/ayllu-shell/src/config.rs |
| 146 | index c1709fd..0737340 100644 |
| 147 | --- a/ayllu-shell/src/config.rs |
| 148 | +++ b/ayllu-shell/src/config.rs |
| 149 | @@ -1,6 +1,7 @@ |
| 150 | use std::path::{Path, PathBuf}; |
| 151 | |
| 152 | use ayllu_config::Configurable; |
| 153 | + use ayllu_identity::Identity; |
| 154 | use serde::{Deserialize, Serialize}; |
| 155 | |
| 156 | #[derive(Default, Deserialize, Serialize, Clone, Debug)] |
| 157 | @@ -11,37 +12,26 @@ pub struct Collection { |
| 158 | pub hidden: Option<bool>, |
| 159 | } |
| 160 | |
| 161 | - /// An identity defines a known user in the Ayllu environment |
| 162 | - #[derive(Serialize, Deserialize, Clone)] |
| 163 | - pub struct Identity { |
| 164 | - pub username: String, |
| 165 | - #[serde(default = "Identity::default_shell")] |
| 166 | - pub shell: PathBuf, |
| 167 | - pub authorized_keys: Option<Vec<String>>, |
| 168 | - #[serde(default = "Identity::default_repositories_path")] |
| 169 | - pub repositories_path: PathBuf, |
| 170 | - } |
| 171 | - |
| 172 | - impl Identity { |
| 173 | - fn default_shell() -> PathBuf { |
| 174 | - Path::new("/bin/sh").to_path_buf() |
| 175 | - } |
| 176 | - |
| 177 | - fn default_repositories_path() -> PathBuf { |
| 178 | - Path::new("repos").to_path_buf() |
| 179 | - } |
| 180 | - } |
| 181 | - |
| 182 | /// Various Shell configuration |
| 183 | #[derive(Serialize, Deserialize, Clone, Default)] |
| 184 | pub struct Shell { |
| 185 | /// Message of the Day displayed for each interactive Ayllu session |
| 186 | pub motd: String, |
| 187 | + /// Path to remove removed repositories |
| 188 | + #[serde(default = "Shell::default_trash_path")] |
| 189 | + pub trash_path: PathBuf, |
| 190 | } |
| 191 | |
| 192 | - #[derive(Serialize, Deserialize, Clone, Default)] |
| 193 | + impl Shell { |
| 194 | + fn default_trash_path() -> PathBuf { |
| 195 | + Path::new("/usr/share/ayllu/.trash").to_path_buf() |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + #[derive(Serialize, Deserialize, Clone)] |
| 200 | pub struct Config { |
| 201 | pub log_level: String, |
| 202 | + #[serde(default = "Vec::new")] |
| 203 | pub identities: Vec<Identity>, |
| 204 | #[serde(default = "Shell::default")] |
| 205 | pub shell: Shell, |
| 206 | diff --git a/ayllu-shell/src/main.rs b/ayllu-shell/src/main.rs |
| 207 | index 6a05851..1ffff2f 100644 |
| 208 | --- a/ayllu-shell/src/main.rs |
| 209 | +++ b/ayllu-shell/src/main.rs |
| 210 | @@ -7,27 +7,41 @@ mod error; |
| 211 | mod ui; |
| 212 | |
| 213 | #[derive(Parser, Debug)] |
| 214 | - #[clap(version, about, long_about = "Ayllu Shell Access")] |
| 215 | + #[clap( |
| 216 | + version, |
| 217 | + about, |
| 218 | + long_about = r#" |
| 219 | + |
| 220 | + Ayllu Shell |
| 221 | + |
| 222 | + ayllu-shell provides a restricted shell for performing operations against |
| 223 | + an Ayllu server. Although it may be invoked directly, typically it is |
| 224 | + expected to be paired with ayllu-keys and an OpenSSH server. |
| 225 | + "# |
| 226 | + )] |
| 227 | struct Arguments { |
| 228 | /// logging level [ERROR,WARN,INFO,DEBUG,TRACE] |
| 229 | #[clap(short, long)] |
| 230 | level: Option<String>, |
| 231 | #[clap(short, long)] |
| 232 | config: Option<PathBuf>, |
| 233 | + /// Specify the user identity to assume |
| 234 | + #[clap(short, long)] |
| 235 | + username: Option<String>, |
| 236 | } |
| 237 | |
| 238 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 239 | let args = Arguments::parse(); |
| 240 | let config: config::Config = ayllu_config::Reader::load(args.config.as_deref())?; |
| 241 | - // If AYLLU_USERNAME is set then it's authenticated via ayllu-keys and we assume that identity |
| 242 | - // otherwise we assume the identity of the user who is calling it. |
| 243 | - let username = std::env::var("AYLLU_USERNAME").unwrap_or(std::env::var("USER").unwrap()); |
| 244 | + let username = args |
| 245 | + .username |
| 246 | + .unwrap_or(std::env::var("USER").expect("USER is not set")); |
| 247 | let identity = config |
| 248 | .identities |
| 249 | .iter() |
| 250 | - .find(|identity| identity.username == username) |
| 251 | - .unwrap(); |
| 252 | + .find(|identity| identity.username == username); |
| 253 | print!("{}", config.shell.motd); |
| 254 | + println!("\nYou are authenticated as: {}\n", username); |
| 255 | let menu = ui::Prompt { |
| 256 | config: &config, |
| 257 | identity, |
| 258 | diff --git a/ayllu-shell/src/ui.rs b/ayllu-shell/src/ui.rs |
| 259 | index c612791..9adeb54 100644 |
| 260 | --- a/ayllu-shell/src/ui.rs |
| 261 | +++ b/ayllu-shell/src/ui.rs |
| 262 | @@ -4,10 +4,11 @@ use std::{ |
| 263 | }; |
| 264 | |
| 265 | use ayllu_git::{Scanner, Sites, Wrapper}; |
| 266 | + use ayllu_identity::Identity; |
| 267 | use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select}; |
| 268 | |
| 269 | use crate::{ |
| 270 | - config::{Collection, Config, Identity}, |
| 271 | + config::{Collection, Config}, |
| 272 | error::Error, |
| 273 | }; |
| 274 | |
| 275 | @@ -53,27 +54,6 @@ mod helpers { |
| 276 | |
| 277 | use super::*; |
| 278 | |
| 279 | - fn get_collection_path(identity: &Identity, collection: &Path) -> PathBuf { |
| 280 | - if collection.is_absolute() { |
| 281 | - collection.to_path_buf() |
| 282 | - } else { |
| 283 | - // Commands have to be run as the configured user validated by ayllu-keys |
| 284 | - let current_user = std::env::var("USER").expect("USER is not set"); |
| 285 | - let user = nix::unistd::User::from_name(¤t_user) |
| 286 | - .expect("Cannot find user") |
| 287 | - .expect("Unknown User"); |
| 288 | - if identity.repositories_path.is_relative() { |
| 289 | - user.dir.join(&identity.repositories_path).join(collection) |
| 290 | - } else { |
| 291 | - identity.repositories_path.clone().join(collection) |
| 292 | - } |
| 293 | - } |
| 294 | - } |
| 295 | - |
| 296 | - fn get_trash(identity: &Identity) -> PathBuf { |
| 297 | - get_collection_path(identity, Path::new(".trash")) |
| 298 | - } |
| 299 | - |
| 300 | pub struct Repository<'a> { |
| 301 | pub collection: &'a Collection, |
| 302 | pub name: String, |
| 303 | @@ -81,15 +61,11 @@ mod helpers { |
| 304 | pub path: PathBuf, |
| 305 | } |
| 306 | |
| 307 | - pub fn repositories<'a>( |
| 308 | - identity: &Identity, |
| 309 | - collections: &'a [Collection], |
| 310 | - ) -> Result<Vec<Repository<'a>>, Error> { |
| 311 | + pub fn repositories<'a>(collections: &'a [Collection]) -> Result<Vec<Repository<'a>>, Error> { |
| 312 | Ok(collections |
| 313 | .iter() |
| 314 | .try_fold(Vec::new(), |mut accm, collection| { |
| 315 | - let collection_path = get_collection_path(identity, &collection.path); |
| 316 | - let scanner: Scanner = TryInto::try_into(collection_path.as_path())?; |
| 317 | + let scanner: Scanner = TryInto::try_into(collection.path.as_path())?; |
| 318 | let repositories = |
| 319 | scanner |
| 320 | .into_iter() |
| 321 | @@ -110,45 +86,32 @@ mod helpers { |
| 322 | })?) |
| 323 | } |
| 324 | |
| 325 | - pub fn open( |
| 326 | - identity: &Identity, |
| 327 | - collection: &Collection, |
| 328 | - name: &str, |
| 329 | - ) -> Result<Wrapper, Error> { |
| 330 | - let collection_path = get_collection_path(identity, &collection.path); |
| 331 | - let repository = Wrapper::new(&collection_path.join(name))?; |
| 332 | + pub fn open(collection: &Collection, name: &str) -> Result<Wrapper, Error> { |
| 333 | + let repository = Wrapper::new(&collection.path.join(name))?; |
| 334 | Ok(repository) |
| 335 | } |
| 336 | |
| 337 | pub fn create( |
| 338 | - identity: &Identity, |
| 339 | collection: &Collection, |
| 340 | config: &ayllu_git::Config, |
| 341 | name: &str, |
| 342 | bare: bool, |
| 343 | ) -> Result<Wrapper, Error> { |
| 344 | - let collection_path = get_collection_path(identity, &collection.path); |
| 345 | - let repository = Wrapper::create(&collection_path.join(Path::new(name)), bare)?; |
| 346 | + let repository = Wrapper::create(&collection.path.join(Path::new(name)), bare)?; |
| 347 | repository.apply_config(config)?; |
| 348 | Ok(repository) |
| 349 | } |
| 350 | |
| 351 | - pub fn r#move( |
| 352 | - identity: &Identity, |
| 353 | - src: &Collection, |
| 354 | - dst: &Collection, |
| 355 | - name: &str, |
| 356 | - ) -> Result<(), Error> { |
| 357 | - let src_path = get_collection_path(identity, src.path.as_path()).join(Path::new(name)); |
| 358 | - let dst_path = get_collection_path(identity, dst.path.as_path()).join(Path::new(name)); |
| 359 | + pub fn r#move(src: &Collection, dst: &Collection, name: &str) -> Result<(), Error> { |
| 360 | + let src_path = src.path.join(Path::new(name)); |
| 361 | + let dst_path = dst.path.join(Path::new(name)); |
| 362 | std::fs::rename(&src_path, &dst_path)?; |
| 363 | println!("Moved {:?} --> {:?}", src_path, dst_path); |
| 364 | Ok(()) |
| 365 | } |
| 366 | |
| 367 | - pub fn remove(identity: &Identity, src: &Collection, name: &str) -> Result<(), Error> { |
| 368 | - let src_path = get_collection_path(identity, &src.path.as_path()).join(Path::new(name)); |
| 369 | - let trash_base = get_trash(identity); |
| 370 | + pub fn remove(src: &Collection, name: &str, trash_base: &Path) -> Result<(), Error> { |
| 371 | + let src_path = src.path.join(Path::new(name)); |
| 372 | if !trash_base.exists() { |
| 373 | std::fs::create_dir_all(&trash_base)?; |
| 374 | } |
| 375 | @@ -258,7 +221,8 @@ mod menu { |
| 376 | |
| 377 | pub struct Prompt<'a> { |
| 378 | pub config: &'a Config, |
| 379 | - pub identity: &'a Identity, |
| 380 | + #[allow(dead_code)] |
| 381 | + pub identity: Option<&'a Identity>, |
| 382 | } |
| 383 | |
| 384 | impl Prompt<'_> { |
| 385 | @@ -304,12 +268,12 @@ impl Prompt<'_> { |
| 386 | branch: Some(branch), |
| 387 | }); |
| 388 | } |
| 389 | - helpers::create(self.identity, collection, &configuration, &name, bare)?; |
| 390 | + helpers::create(collection, &configuration, &name, bare)?; |
| 391 | println!("Repository Created Successfully"); |
| 392 | self.execute(None, None) |
| 393 | } |
| 394 | Some(menu::Item::Edit { collection, name }) => { |
| 395 | - let repository = helpers::open(self.identity, collection, &name)?; |
| 396 | + let repository = helpers::open(collection, &name)?; |
| 397 | let mut config = repository.config()?; |
| 398 | config.description = maybe_string(&string_input_default!( |
| 399 | "Description", |
| 400 | @@ -365,7 +329,7 @@ impl Prompt<'_> { |
| 401 | .interact() |
| 402 | .unwrap() |
| 403 | { |
| 404 | - helpers::remove(self.identity, collection, name)?; |
| 405 | + helpers::remove(collection, name, &self.config.shell.trash_path)?; |
| 406 | self.execute(None, None) |
| 407 | } else { |
| 408 | self.execute(None, None) |
| 409 | @@ -373,7 +337,7 @@ impl Prompt<'_> { |
| 410 | } |
| 411 | Some(menu::Item::Browse) => { |
| 412 | let repositories: Vec<menu::Item> = |
| 413 | - helpers::repositories(self.identity, self.config.collections.as_slice())? |
| 414 | + helpers::repositories(self.config.collections.as_slice())? |
| 415 | .iter() |
| 416 | .map(|repo| menu::Item::Repository { |
| 417 | collection: repo.collection, |
| 418 | @@ -390,7 +354,7 @@ impl Prompt<'_> { |
| 419 | Some(menu::Item::Move { collection, name }) => { |
| 420 | match menu::select_collection(self.config.collections.as_slice()) { |
| 421 | Some(other) => { |
| 422 | - helpers::r#move(self.identity, collection, &other, name)?; |
| 423 | + helpers::r#move(collection, &other, name)?; |
| 424 | self.execute(None, None) |
| 425 | } |
| 426 | None => self.execute(None, None), |
| 427 | diff --git a/scripts/ayllu_shell_test.sh b/scripts/ayllu_shell_test.sh |
| 428 | index f03e69a..c015cc0 100755 |
| 429 | --- a/scripts/ayllu_shell_test.sh |
| 430 | +++ b/scripts/ayllu_shell_test.sh |
| 431 | @@ -30,6 +30,7 @@ chmod 644 /home/demo/.ssh/authorized_keys |
| 432 | cat /etc/ayllu/config.example.toml > /etc/ayllu/config.toml |
| 433 | echo "[[identities]]" >> /etc/ayllu/config.toml |
| 434 | echo username = \"demo\" >> /etc/ayllu/config.toml |
| 435 | + echo email = \"demo@example.org\" >> /etc/ayllu/config.toml |
| 436 | echo authorized_keys = [\"$PUBLIC_KEY\"] >> /etc/ayllu/config.toml |
| 437 | /usr/sbin/sshd $DEBUGGING -D -o PermitTTY=yes -o PermitUserEnvironment=AYLLU_USERNAME -o PasswordAuthentication=no -o AuthorizedKeysCommand="$KEYS_COMMAND" -o AuthorizedKeysCommandUser=root |
| 438 | EOF |