[pmg-devel] [PATCH perl-rs 6/7] pmg: add tfa module
Wolfgang Bumiller
w.bumiller at proxmox.com
Fri Nov 26 14:55:17 CET 2021
Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
pmg-rs/Cargo.toml | 5 +-
pmg-rs/Makefile | 5 +-
pmg-rs/debian/control | 9 +-
pmg-rs/src/lib.rs | 1 +
pmg-rs/src/tfa.rs | 603 ++++++++++++++++++++++++++++++++++++++++++
5 files changed, 618 insertions(+), 5 deletions(-)
create mode 100644 pmg-rs/src/tfa.rs
diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml
index 659456c..1d66bb5 100644
--- a/pmg-rs/Cargo.toml
+++ b/pmg-rs/Cargo.toml
@@ -21,13 +21,16 @@ crate-type = [ "cdylib" ]
[dependencies]
anyhow = "1.0"
hex = "0.4"
+libc = "0.2"
+nix = "0.19"
openssl = "0.10.32"
serde = "1.0"
serde_bytes = "0.11.3"
serde_json = "1.0"
+url = "2"
perlmod = { version = "0.9", features = [ "exporter" ] }
proxmox-acme-rs = { version = "0.3.1", features = ["client"] }
-
proxmox-apt = "0.8.0"
+proxmox-tfa = { version = "2", features = ["api"] }
diff --git a/pmg-rs/Makefile b/pmg-rs/Makefile
index a290544..69b2798 100644
--- a/pmg-rs/Makefile
+++ b/pmg-rs/Makefile
@@ -18,9 +18,10 @@ PM_DIRS := \
PMG/RS/APT
PM_FILES := \
- PMG/RS/Acme.pm \
PMG/RS/APT/Repositories.pm \
- PMG/RS/CSR.pm
+ PMG/RS/Acme.pm \
+ PMG/RS/CSR.pm \
+ PMG/RS/TFA.pm
ifeq ($(BUILD_MODE), release)
CARGO_BUILD_ARGS += --release
diff --git a/pmg-rs/debian/control b/pmg-rs/debian/control
index be632ba..8c22fae 100644
--- a/pmg-rs/debian/control
+++ b/pmg-rs/debian/control
@@ -6,15 +6,20 @@ Build-Depends:
debhelper (>= 12),
librust-anyhow-1+default-dev,
librust-hex-0.4+default-dev,
+ librust-libc-0.2+default-dev,
+ librust-nix-0.19+default-dev,
librust-openssl-0.10+default-dev (>= 0.10.32-~~),
- librust-perlmod-0.8+default-dev,
- librust-perlmod-0.8+exporter-dev,
+ librust-perlmod-0.8+default-dev (>= 0.8.1-~~),
+ librust-perlmod-0.8+exporter-dev (>= 0.8.1-~~),
librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~),
librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~),
librust-proxmox-apt-0.8+default-dev,
+ librust-proxmox-tfa-2+api-dev,
+ librust-proxmox-tfa-2+default-dev,
librust-serde-1+default-dev,
librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~),
librust-serde-json-1+default-dev,
+ librust-url-2+default-dev,
Standards-Version: 4.3.0
Homepage: https://www.proxmox.com
diff --git a/pmg-rs/src/lib.rs b/pmg-rs/src/lib.rs
index 47e61b5..e3c9593 100644
--- a/pmg-rs/src/lib.rs
+++ b/pmg-rs/src/lib.rs
@@ -1,3 +1,4 @@
pub mod acme;
pub mod apt;
pub mod csr;
+pub mod tfa;
diff --git a/pmg-rs/src/tfa.rs b/pmg-rs/src/tfa.rs
new file mode 100644
index 0000000..404ddb2
--- /dev/null
+++ b/pmg-rs/src/tfa.rs
@@ -0,0 +1,603 @@
+//! This implements the `tfa.cfg` parser & TFA API calls for PMG.
+//!
+//! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`.
+//! Contrary to the PVE implementation, this does not need to provide any backward compatible
+//! entries.
+//!
+//! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in
+//! order to avoid losing the rust magic-ref.
+
+use std::fs::File;
+use std::io::{self, Read};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, format_err, Error};
+use nix::errno::Errno;
+use nix::sys::stat::Mode;
+
+pub(self) use proxmox_tfa::api::{
+ RecoveryState, TfaChallenge, TfaConfig, TfaResponse, U2fConfig, WebauthnConfig,
+};
+
+#[perlmod::package(name = "PMG::RS::TFA")]
+mod export {
+ use std::convert::TryInto;
+ use std::sync::Mutex;
+
+ use anyhow::{bail, format_err, Error};
+ use serde_bytes::ByteBuf;
+ use url::Url;
+
+ use perlmod::Value;
+ use proxmox_tfa::api::methods;
+
+ use super::{TfaConfig, UserAccess};
+
+ perlmod::declare_magic!(Box<Tfa> : &Tfa as "PMG::RS::TFA");
+
+ /// A TFA Config instance.
+ pub struct Tfa {
+ inner: Mutex<TfaConfig>,
+ }
+
+ /// Prevent 'dclone'.
+ #[export(name = "STORABLE_freeze", raw_return)]
+ fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> {
+ bail!("freezing TFA config not supported!");
+ }
+
+ /// Parse a TFA configuration.
+ #[export(raw_return)]
+ fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
+ let mut inner: TfaConfig = serde_json::from_slice(config)
+ .map_err(|err| format_err!("failed to parse TFA file: {}", err))?;
+
+ // PMG does not support U2F.
+ inner.u2f = None;
+ Ok(perlmod::instantiate_magic!(
+ &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) })
+ ))
+ }
+
+ /// Write the configuration out into a JSON string.
+ #[export]
+ fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
+ let inner = this.inner.lock().unwrap();
+ Ok(ByteBuf::from(serde_json::to_vec(&*inner)?))
+ }
+
+ /// Debug helper: serialize the TFA user data into a perl value.
+ #[export]
+ fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
+ let inner = this.inner.lock().unwrap();
+ Ok(perlmod::to_value(&*inner)?)
+ }
+
+ /// Get a list of all the user names in this config.
+ /// PMG uses this to verify users and purge the invalid ones.
+ #[export]
+ fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
+ Ok(this.inner.lock().unwrap().users.keys().cloned().collect())
+ }
+
+ /// Remove a user from the TFA configuration.
+ #[export]
+ fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
+ Ok(this.inner.lock().unwrap().users.remove(userid).is_some())
+ }
+
+ /// Get the TFA data for a specific user.
+ #[export(raw_return)]
+ fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
+ perlmod::to_value(&this.inner.lock().unwrap().users.get(userid))
+ }
+
+ /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
+ /// written out.
+ #[export]
+ fn add_u2f_registration(
+ #[raw] raw_this: Value,
+ //#[try_from_ref] this: &Tfa,
+ userid: &str,
+ description: String,
+ ) -> Result<String, Error> {
+ let this: &Tfa = (&raw_this).try_into()?;
+ let mut inner = this.inner.lock().unwrap();
+ inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description)
+ }
+
+ /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
+ /// needs to be written out!
+ #[export]
+ fn finish_u2f_registration(
+ #[raw] raw_this: Value,
+ //#[try_from_ref] this: &Tfa,
+ userid: &str,
+ challenge: &str,
+ response: &str,
+ ) -> Result<String, Error> {
+ let this: &Tfa = (&raw_this).try_into()?;
+ let mut inner = this.inner.lock().unwrap();
+ inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response)
+ }
+
+ /// Check if a user has any TFA entries of a given type.
+ #[export]
+ fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
+ Ok(match this.inner.lock().unwrap().users.get(userid) {
+ Some(user) => match typename {
+ "totp" | "oath" => !user.totp.is_empty(),
+ "u2f" => !user.u2f.is_empty(),
+ "webauthn" => !user.webauthn.is_empty(),
+ "yubico" => !user.yubico.is_empty(),
+ "recovery" => match &user.recovery {
+ Some(r) => r.count_available() > 0,
+ None => false,
+ },
+ _ => bail!("unrecognized TFA type {:?}", typename),
+ },
+ None => false,
+ })
+ }
+
+ /// Generates a space separated list of yubico keys of this account.
+ #[export]
+ fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
+ Ok(this.inner.lock().unwrap().users.get(userid).map(|user| {
+ user.enabled_yubico_entries()
+ .fold(String::new(), |mut s, k| {
+ if !s.is_empty() {
+ s.push(' ');
+ }
+ s.push_str(k);
+ s
+ })
+ }))
+ }
+
+ #[export]
+ fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
+ this.inner.lock().unwrap().u2f = config;
+ }
+
+ #[export]
+ fn set_webauthn_config(
+ #[try_from_ref] this: &Tfa,
+ config: Option<super::WebauthnConfig>,
+ ) -> Result<(), Error> {
+ this.inner.lock().unwrap().webauthn = config.map(TryInto::try_into).transpose()?;
+ Ok(())
+ }
+
+ #[export]
+ fn get_webauthn_config(
+ #[try_from_ref] this: &Tfa,
+ ) -> Result<(Option<String>, Option<super::WebauthnConfig>), Error> {
+ Ok(match this.inner.lock().unwrap().webauthn.clone() {
+ Some(config) => (Some(hex::encode(&config.digest())), Some(config.into())),
+ None => (None, None),
+ })
+ }
+
+ #[export]
+ fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool {
+ match &this.inner.lock().unwrap().webauthn {
+ Some(wa) => wa.origin.is_some(),
+ None => false,
+ }
+ }
+
+ /// Create an authentication challenge.
+ ///
+ /// Returns the challenge as a json string.
+ /// Returns `undef` if no second factor is configured.
+ #[export]
+ fn authentication_challenge(
+ #[raw] raw_this: Value,
+ //#[try_from_ref] this: &Tfa,
+ userid: &str,
+ origin: Option<Url>,
+ ) -> Result<Option<String>, Error> {
+ let this: &Tfa = (&raw_this).try_into()?;
+ let mut inner = this.inner.lock().unwrap();
+ match inner.authentication_challenge(
+ UserAccess::new(&raw_this)?,
+ userid,
+ origin.as_ref(),
+ )? {
+ Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)),
+ None => Ok(None),
+ }
+ }
+
+ /// Get the recovery state (suitable for a challenge object).
+ #[export]
+ fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
+ this.inner
+ .lock()
+ .unwrap()
+ .users
+ .get(userid)
+ .and_then(|user| {
+ let state = user.recovery_state();
+ state.is_available().then(move || state)
+ })
+ }
+
+ /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
+ /// it.
+ ///
+ /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
+ /// (to use up recovery keys!).
+ #[export]
+ fn authentication_verify(
+ #[raw] raw_this: Value,
+ //#[try_from_ref] this: &Tfa,
+ userid: &str,
+ challenge: &str, //super::TfaChallenge,
+ response: &str,
+ origin: Option<Url>,
+ ) -> Result<bool, Error> {
+ let this: &Tfa = (&raw_this).try_into()?;
+ let challenge: super::TfaChallenge = serde_json::from_str(challenge)?;
+ let response: super::TfaResponse = response.parse()?;
+ let mut inner = this.inner.lock().unwrap();
+ inner
+ .verify(
+ UserAccess::new(&raw_this)?,
+ userid,
+ &challenge,
+ response,
+ origin.as_ref(),
+ )
+ .map(|save| save.needs_saving())
+ }
+
+ /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
+ #[export]
+ fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> {
+ let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?;
+ Ok(totp.time(std::time::SystemTime::now())?.to_string())
+ }
+
+ #[export]
+ fn api_list_user_tfa(
+ #[try_from_ref] this: &Tfa,
+ userid: &str,
+ ) -> Result<Vec<methods::TypedTfaInfo>, Error> {
+ methods::list_user_tfa(&this.inner.lock().unwrap(), userid)
+ }
+
+ #[export]
+ fn api_get_tfa_entry(
+ #[try_from_ref] this: &Tfa,
+ userid: &str,
+ id: &str,
+ ) -> Option<methods::TypedTfaInfo> {
+ methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
+ }
+
+ /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
+ /// more tfa entries.
+ #[export]
+ fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
+ let mut this = this.inner.lock().unwrap();
+ match methods::delete_tfa(&mut this, userid, &id) {
+ Ok(has_entries_left) => Ok(has_entries_left),
+ Err(methods::EntryNotFound) => bail!("no such entry"),
+ }
+ }
+
+ #[export]
+ fn api_list_tfa(
+ #[try_from_ref] this: &Tfa,
+ authid: &str,
+ top_level_allowed: bool,
+ ) -> Result<Vec<methods::TfaUser>, Error> {
+ methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
+ }
+
+ #[export]
+ fn api_add_tfa_entry(
+ #[raw] raw_this: Value,
+ //#[try_from_ref] this: &Tfa,
+ userid: &str,
+ description: Option<String>,
+ totp: Option<String>,
+ value: Option<String>,
+ challenge: Option<String>,
+ ty: methods::TfaType,
+ origin: Option<Url>,
+ ) -> Result<methods::TfaUpdateInfo, Error> {
+ let this: &Tfa = (&raw_this).try_into()?;
+ methods::add_tfa_entry(
+ &mut this.inner.lock().unwrap(),
+ UserAccess::new(&raw_this)?,
+ userid,
+ description,
+ totp,
+ value,
+ challenge,
+ ty,
+ origin.as_ref(),
+ )
+ }
+
+ /// Add a totp entry without validating it, used for user.cfg keys.
+ /// Returns the ID.
+ #[export]
+ fn add_totp_entry(
+ #[try_from_ref] this: &Tfa,
+ userid: &str,
+ description: String,
+ totp: String,
+ ) -> Result<String, Error> {
+ Ok(this
+ .inner
+ .lock()
+ .unwrap()
+ .add_totp(userid, description, totp.parse()?))
+ }
+
+ /// Add a yubico entry without validating it, used for user.cfg keys.
+ /// Returns the ID.
+ #[export]
+ fn add_yubico_entry(
+ #[try_from_ref] this: &Tfa,
+ userid: &str,
+ description: String,
+ yubico: String,
+ ) -> String {
+ this.inner
+ .lock()
+ .unwrap()
+ .add_yubico(userid, description, yubico)
+ }
+
+ #[export]
+ fn api_update_tfa_entry(
+ #[try_from_ref] this: &Tfa,
+ userid: &str,
+ id: &str,
+ description: Option<String>,
+ enable: Option<bool>,
+ ) -> Result<(), Error> {
+ match methods::update_tfa_entry(
+ &mut this.inner.lock().unwrap(),
+ userid,
+ id,
+ description,
+ enable,
+ ) {
+ Ok(()) => Ok(()),
+ Err(methods::EntryNotFound) => bail!("no such entry"),
+ }
+ }
+}
+
+/// Attach the path to errors from [`nix::mkir()`].
+pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> {
+ let path = path.as_ref();
+ match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) {
+ Ok(()) => Ok(()),
+ Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()),
+ Err(err) => bail!("failed to create directory {:?}: {}", path, err),
+ }
+}
+
+#[cfg(debug_assertions)]
+#[derive(Clone)]
+#[repr(transparent)]
+pub struct UserAccess(perlmod::Value);
+
+#[cfg(debug_assertions)]
+impl UserAccess {
+ #[inline]
+ fn new(value: &perlmod::Value) -> Result<Self, Error> {
+ value
+ .dereference()
+ .ok_or_else(|| format_err!("bad TFA config object"))
+ .map(Self)
+ }
+
+ #[inline]
+ fn is_debug(&self) -> bool {
+ self.0
+ .as_hash()
+ .and_then(|v| v.get("-debug"))
+ .map(|v| v.iv() != 0)
+ .unwrap_or(false)
+ }
+}
+
+#[cfg(not(debug_assertions))]
+#[derive(Clone, Copy)]
+#[repr(transparent)]
+pub struct UserAccess;
+
+#[cfg(not(debug_assertions))]
+impl UserAccess {
+ #[inline]
+ const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> {
+ Ok(Self)
+ }
+
+ #[inline]
+ const fn is_debug(&self) -> bool {
+ false
+ }
+}
+
+/// Build the path to the challenge data file for a user.
+fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
+ if debug {
+ PathBuf::from(format!("./local-tfa-challenges/{}", userid))
+ } else {
+ PathBuf::from(format!("/run/pmg-private/tfa-challenges/{}", userid))
+ }
+}
+
+impl proxmox_tfa::api::OpenUserChallengeData for UserAccess {
+ type Data = UserChallengeData;
+
+ fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
+ if self.is_debug() {
+ mkdir("./local-tfa-challenges", 0o700)?;
+ } else {
+ mkdir("/run/pmg-private", 0o700)?;
+ mkdir("/run/pmg-private/tfa-challenges", 0o700)?;
+ }
+
+ let path = challenge_data_path(userid, self.is_debug());
+
+ let mut file = std::fs::OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(&path)
+ .map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?;
+
+ UserChallengeData::lock_file(file.as_raw_fd())?;
+
+ // the file may be empty, so read to a temporary buffer first:
+ let mut data = Vec::with_capacity(4096);
+
+ file.read_to_end(&mut data).map_err(|err| {
+ format_err!("failed to read challenge data for user {}: {}", userid, err)
+ })?;
+
+ let inner = if data.is_empty() {
+ Default::default()
+ } else {
+ match serde_json::from_slice(&data) {
+ Ok(inner) => inner,
+ Err(err) => {
+ eprintln!(
+ "failed to parse challenge data for user {}: {}",
+ userid, err
+ );
+ Default::default()
+ }
+ }
+ };
+
+ Ok(UserChallengeData {
+ inner,
+ path,
+ lock: file,
+ })
+ }
+
+ /// `open` without creating the file if it doesn't exist, to finish WA authentications.
+ fn open_no_create(&self, userid: &str) -> Result<Option<UserChallengeData>, Error> {
+ let path = challenge_data_path(userid, self.is_debug());
+
+ let mut file = match std::fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(&path)
+ {
+ Ok(file) => file,
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(err) => return Err(err.into()),
+ };
+
+ UserChallengeData::lock_file(file.as_raw_fd())?;
+
+ let inner = serde_json::from_reader(&mut file).map_err(|err| {
+ format_err!("failed to read challenge data for user {}: {}", userid, err)
+ })?;
+
+ Ok(Some(UserChallengeData {
+ inner,
+ path,
+ lock: file,
+ }))
+ }
+
+ fn remove(&self, userid: &str) -> Result<bool, Error> {
+ let path = challenge_data_path(userid, self.is_debug());
+ match std::fs::remove_file(&path) {
+ Ok(()) => Ok(true),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err.into()),
+ }
+ }
+}
+
+/// Container of `TfaUserChallenges` with the corresponding file lock guard.
+///
+/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
+/// challenges.
+pub struct UserChallengeData {
+ inner: proxmox_tfa::api::TfaUserChallenges,
+ path: PathBuf,
+ lock: File,
+}
+
+impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData {
+ fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges {
+ &mut self.inner
+ }
+
+ fn save(self) -> Result<(), Error> {
+ UserChallengeData::save(self)
+ }
+}
+
+impl UserChallengeData {
+ fn lock_file(fd: RawFd) -> Result<(), Error> {
+ let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
+
+ if rc != 0 {
+ let err = io::Error::last_os_error();
+ bail!("failed to lock tfa user challenge data: {}", err);
+ }
+
+ Ok(())
+ }
+
+ /// Rewind & truncate the file for an update.
+ fn rewind(&mut self) -> Result<(), Error> {
+ use std::io::{Seek, SeekFrom};
+
+ let pos = self.lock.seek(SeekFrom::Start(0))?;
+ if pos != 0 {
+ bail!(
+ "unexpected result trying to rewind file, position is {}",
+ pos
+ );
+ }
+
+ let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) };
+ if rc != 0 {
+ let err = io::Error::last_os_error();
+ bail!("failed to truncate challenge data: {}", err);
+ }
+
+ Ok(())
+ }
+
+ /// Save the current data. Note that we do not replace the file here since we lock the file
+ /// itself, as it is in `/run`, and the typical error case for this particular situation
+ /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
+ /// other reasons then...
+ ///
+ /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
+ /// way also unlocks early.
+ fn save(mut self) -> Result<(), Error> {
+ self.rewind()?;
+
+ serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
+ format_err!("failed to update challenge file {:?}: {}", self.path, err)
+ })?;
+
+ Ok(())
+ }
+}
--
2.30.2
More information about the pmg-devel
mailing list