diff --git a/src/api2/config.rs b/src/api2/config.rs index 79db82da..cb114688 100644 --- a/src/api2/config.rs +++ b/src/api2/config.rs @@ -3,10 +3,12 @@ use proxmox::list_subdirs_api_method; pub mod datastore; pub mod remote; +pub mod user; const SUBDIRS: SubdirMap = &[ ("datastore", &datastore::ROUTER), ("remote", &remote::ROUTER), + ("user", &user::ROUTER), ]; pub const ROUTER: Router = Router::new() diff --git a/src/api2/config/user.rs b/src/api2/config/user.rs new file mode 100644 index 00000000..47ca5869 --- /dev/null +++ b/src/api2/config/user.rs @@ -0,0 +1,304 @@ +use failure::*; +use serde_json::Value; + +use proxmox::api::{api, ApiMethod, Router, RpcEnvironment}; +use proxmox::api::schema::{Schema, StringSchema}; + +use crate::api2::types::*; +use crate::config::user; + +pub const PBS_PASSWORD_SCHEMA: Schema = StringSchema::new("User Password.") + .format(&PASSWORD_FORMAT) + .min_length(5) + .max_length(64) + .schema(); + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List users (with config digest).", + type: Array, + items: { + type: Object, + description: "User configuration (without password).", + properties: { + userid: { + schema: PROXMOX_USER_ID_SCHEMA, + }, + comment: { + schema: SINGLE_LINE_COMMENT_SCHEMA, + optional: true, + }, + enable: { + schema: user::ENABLE_USER_SCHEMA, + optional: true, + }, + expire: { + schema: user::EXPIRE_USER_SCHEMA, + optional: true, + }, + firstname: { + schema: user::FIRST_NAME_SCHEMA, + optional: true, + }, + lastname: { + schema: user::LAST_NAME_SCHEMA, + optional: true, + }, + email: { + schema: user::EMAIL_SCHEMA, + optional: true, + }, + }, + }, + }, +)] +/// List all users +pub fn list_users( + _param: Value, + _info: &ApiMethod, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + + let (config, digest) = user::config()?; + + let value = config.convert_to_array("userid", Some(&digest), &[]); + + Ok(value.into()) +} + +#[api( + protected: true, + input: { + properties: { + userid: { + schema: PROXMOX_USER_ID_SCHEMA, + }, + comment: { + schema: SINGLE_LINE_COMMENT_SCHEMA, + optional: true, + }, + password: { + schema: PBS_PASSWORD_SCHEMA, + optional: true, + }, + enable: { + schema: user::ENABLE_USER_SCHEMA, + optional: true, + }, + expire: { + schema: user::EXPIRE_USER_SCHEMA, + optional: true, + }, + firstname: { + schema: user::FIRST_NAME_SCHEMA, + optional: true, + }, + lastname: { + schema: user::LAST_NAME_SCHEMA, + optional: true, + }, + email: { + schema: user::EMAIL_SCHEMA, + optional: true, + }, + }, + }, +)] +/// Create new user. +pub fn create_user(userid: String, param: Value) -> Result<(), Error> { + + let _lock = crate::tools::open_file_locked(user::USER_CFG_LOCKFILE, std::time::Duration::new(10, 0))?; + + let user: user::User = serde_json::from_value(param.clone())?; + + let (mut config, _digest) = user::config()?; + + if let Some(_) = config.sections.get(&userid) { + bail!("user '{}' already exists.", userid); + } + + // fixme: check/store password + // check domain + + config.set_data(&userid, "user", &user)?; + + user::save_config(&config)?; + + Ok(()) +} + +#[api( + input: { + properties: { + userid: { + schema: PROXMOX_USER_ID_SCHEMA, + }, + }, + }, + returns: { + description: "The user configuration (with config digest).", + type: user::User, + }, +)] +/// Read user configuration data. +pub fn read_user(userid: String) -> Result { + let (config, digest) = user::config()?; + let mut data = config.lookup_json("user", &userid)?; + data.as_object_mut().unwrap() + .insert("digest".into(), proxmox::tools::digest_to_hex(&digest).into()); + Ok(data) +} + +#[api( + protected: true, + input: { + properties: { + userid: { + schema: PROXMOX_USER_ID_SCHEMA, + }, + comment: { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + password: { + schema: PBS_PASSWORD_SCHEMA, + optional: true, + }, + enable: { + schema: user::ENABLE_USER_SCHEMA, + optional: true, + }, + expire: { + schema: user::EXPIRE_USER_SCHEMA, + optional: true, + }, + firstname: { + schema: user::FIRST_NAME_SCHEMA, + optional: true, + }, + lastname: { + schema: user::LAST_NAME_SCHEMA, + optional: true, + }, + email: { + schema: user::EMAIL_SCHEMA, + optional: true, + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, +)] +/// Update user configuration. +pub fn update_user( + userid: String, + comment: Option, + enable: Option, + expire: Option, + password: Option, + firstname: Option, + lastname: Option, + email: Option, + digest: Option, +) -> Result<(), Error> { + + let _lock = crate::tools::open_file_locked(user::USER_CFG_LOCKFILE, std::time::Duration::new(10, 0))?; + + let (mut config, expected_digest) = user::config()?; + + if let Some(ref digest) = digest { + let digest = proxmox::tools::hex_to_digest(digest)?; + crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?; + } + + let mut data: user::User = config.lookup("user", &userid)?; + + if let Some(comment) = comment { + let comment = comment.trim().to_string(); + if comment.is_empty() { + data.comment = None; + } else { + data.comment = Some(comment); + } + } + + if let Some(enable) = enable { + data.enable = if enable { None } else { Some(false) }; + } + + if let Some(expire) = expire { + data.expire = if expire > 0 { Some(expire) } else { None }; + } + + if let Some(password) = password { + unimplemented!(); + } + + if let Some(firstname) = firstname { + data.firstname = if firstname.is_empty() { None } else { Some(firstname) }; + } + + if let Some(lastname) = lastname { + data.lastname = if lastname.is_empty() { None } else { Some(lastname) }; + } + if let Some(email) = email { + data.email = if email.is_empty() { None } else { Some(email) }; + } + + config.set_data(&userid, "user", &data)?; + + user::save_config(&config)?; + + Ok(()) +} + +#[api( + protected: true, + input: { + properties: { + userid: { + schema: PROXMOX_USER_ID_SCHEMA, + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, +)] +/// Remove a user from the configuration file. +pub fn delete_user(userid: String, digest: Option) -> Result<(), Error> { + + let _lock = crate::tools::open_file_locked(user::USER_CFG_LOCKFILE, std::time::Duration::new(10, 0))?; + + let (mut config, expected_digest) = user::config()?; + + if let Some(ref digest) = digest { + let digest = proxmox::tools::hex_to_digest(digest)?; + crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?; + } + + match config.sections.get(&userid) { + Some(_) => { config.sections.remove(&userid); }, + None => bail!("user '{}' does not exist.", userid), + } + + user::save_config(&config)?; + + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_READ_USER) + .put(&API_METHOD_UPDATE_USER) + .delete(&API_METHOD_DELETE_USER); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_USERS) + .post(&API_METHOD_CREATE_USER) + .match_all("userid", &ITEM_ROUTER); diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs index e82b3d4a..3424241a 100644 --- a/src/bin/proxmox-backup-manager.rs +++ b/src/bin/proxmox-backup-manager.rs @@ -110,6 +110,67 @@ fn remote_commands() -> CommandLineInterface { cmd_def.into() } +#[api( + input: { + properties: { + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// List configured users. +fn list_users(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result { + + let output_format = get_output_format(¶m); + + let info = &api2::config::user::API_METHOD_LIST_USERS; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options() + .column(ColumnConfig::new("userid")) + .column(ColumnConfig::new("enable")) + .column(ColumnConfig::new("expire")) + .column(ColumnConfig::new("firstname")) + .column(ColumnConfig::new("lastname")) + .column(ColumnConfig::new("email")) + .column(ColumnConfig::new("comment")); + + format_and_print_result_full(&mut data, info.returns, &output_format, &options); + + Ok(Value::Null) +} + +fn user_commands() -> CommandLineInterface { + + let cmd_def = CliCommandMap::new() + .insert("list", CliCommand::new(&&API_METHOD_LIST_USERS)) + .insert( + "create", + // fixme: howto handle password parameter? + CliCommand::new(&api2::config::user::API_METHOD_CREATE_USER) + .arg_param(&["userid"]) + ) + .insert( + "update", + CliCommand::new(&api2::config::user::API_METHOD_UPDATE_USER) + .arg_param(&["userid"]) + .completion_cb("userid", config::user::complete_user_name) + ) + .insert( + "remove", + CliCommand::new(&api2::config::user::API_METHOD_DELETE_USER) + .arg_param(&["userid"]) + .completion_cb("userid", config::user::complete_user_name) + ); + + cmd_def.into() +} + fn datastore_commands() -> CommandLineInterface { let cmd_def = CliCommandMap::new() @@ -479,6 +540,7 @@ fn main() { let cmd_def = CliCommandMap::new() .insert("datastore", datastore_commands()) + .insert("user", user_commands()) .insert("remote", remote_commands()) .insert("garbage-collection", garbage_collection_commands()) .insert("cert", cert_mgmt_cli()) diff --git a/src/config.rs b/src/config.rs index 3d5f1c86..d62de8ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ use crate::buildcfg; pub mod datastore; pub mod remote; +pub mod user; /// Check configuration directory permissions /// diff --git a/src/config/user.rs b/src/config/user.rs new file mode 100644 index 00000000..12ce3680 --- /dev/null +++ b/src/config/user.rs @@ -0,0 +1,165 @@ +use failure::*; +use lazy_static::lazy_static; +use std::collections::HashMap; +use serde::{Serialize, Deserialize}; +use serde_json::json; + +use proxmox::api::{ + api, + schema::*, + section_config::{ + SectionConfig, + SectionConfigData, + SectionConfigPlugin, + } +}; + +use proxmox::tools::{fs::replace_file, fs::CreateOptions}; + +use crate::api2::types::*; + +lazy_static! { + static ref CONFIG: SectionConfig = init(); +} + +pub const ENABLE_USER_SCHEMA: Schema = BooleanSchema::new( + "Enable the account (default). You can set this to '0' to disable the account.") + .default(true) + .schema(); + +pub const EXPIRE_USER_SCHEMA: Schema = IntegerSchema::new( + "Account expiration date (seconds since epoch). '0' means no expiration date.") + .default(0) + .minimum(0) + .schema(); + +pub const FIRST_NAME_SCHEMA: Schema = StringSchema::new("First name.") + .format(&SINGLE_LINE_COMMENT_FORMAT) + .min_length(2) + .max_length(64) + .schema(); + +pub const LAST_NAME_SCHEMA: Schema = StringSchema::new("Last name.") + .format(&SINGLE_LINE_COMMENT_FORMAT) + .min_length(2) + .max_length(64) + .schema(); + +pub const EMAIL_SCHEMA: Schema = StringSchema::new("E-Mail Address.") + .format(&SINGLE_LINE_COMMENT_FORMAT) + .min_length(2) + .max_length(64) + .schema(); + + +#[api( + properties: { + comment: { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + enable: { + optional: true, + schema: ENABLE_USER_SCHEMA, + }, + expire: { + optional: true, + schema: EXPIRE_USER_SCHEMA, + }, + firstname: { + optional: true, + schema: FIRST_NAME_SCHEMA, + }, + lastname: { + schema: LAST_NAME_SCHEMA, + optional: true, + }, + email: { + schema: EMAIL_SCHEMA, + optional: true, + }, + } +)] +#[derive(Serialize,Deserialize)] +/// User properties. +pub struct User { + #[serde(skip_serializing_if="Option::is_none")] + pub comment: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub enable: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub expire: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub firstname: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub lastname: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub email: Option, +} + +fn init() -> SectionConfig { + let obj_schema = match User::API_SCHEMA { + Schema::Object(ref obj_schema) => obj_schema, + _ => unreachable!(), + }; + + let plugin = SectionConfigPlugin::new("user".to_string(), obj_schema); + let mut config = SectionConfig::new(&PROXMOX_USER_ID_SCHEMA); + + config.register_plugin(plugin); + + config +} + +pub const USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg"; +pub const USER_CFG_LOCKFILE: &str = "/etc/proxmox-backup/.user.lck"; + +pub fn config() -> Result<(SectionConfigData, [u8;32]), Error> { + let content = match std::fs::read_to_string(USER_CFG_FILENAME) { + Ok(c) => c, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + String::from("") + } else { + bail!("unable to read '{}' - {}", USER_CFG_FILENAME, err); + } + } + }; + + let digest = openssl::sha::sha256(content.as_bytes()); + let mut data = CONFIG.parse(USER_CFG_FILENAME, &content)?; + + if data.sections.get("root@pam").is_none() { + let user: User = serde_json::from_value(json!({ + "comment": "Superuser", + })).unwrap(); + data.set_data("root@pam", "user", &user).unwrap(); + } + + Ok((data, digest)) +} + +pub fn save_config(config: &SectionConfigData) -> Result<(), Error> { + let raw = CONFIG.write(USER_CFG_FILENAME, &config)?; + + let backup_user = crate::backup::backup_user()?; + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + // set the correct owner/group/permissions while saving file + // owner(rw) = root, group(r)= backup + let options = CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(backup_user.gid); + + replace_file(USER_CFG_FILENAME, raw.as_bytes(), options)?; + + Ok(()) +} + +// shell completion helper +pub fn complete_user_name(_arg: &str, _param: &HashMap) -> Vec { + match config() { + Ok((data, _digest)) => data.sections.iter().map(|(id, _)| id.to_string()).collect(), + Err(_) => return vec![], + } +}