urma-node 0.6.0

Optional URMA archive node: Litecoin follower, record archive, libp2p overlay and loopback service
Documentation
use crate::{
    config::SERVICE_NAME,
    install::{Error, Runner, System, system::text},
    platform::Paths,
};
use std::{fs, io::Write, path::Path};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Ids {
    pub uid: u32,
    pub gid: u32,
}

pub struct Account;

impl Account {
    pub const NAME: &'static str = SERVICE_NAME;
    const NOLOGIN: [&'static str; 3] = ["/usr/sbin/nologin", "/sbin/nologin", "/usr/bin/nologin"];
    const NO_SHELL: &'static str = "/bin/false";
    const ROOT: &'static str = "root";
}

fn number(field: &str) -> Result<u32, Error> {
    field
        .parse()
        .map_err(|error| Error::Account(format!("bad id {field:?}: {error}")))
}

impl<R: Runner> System<R> {
    fn table(&self, file: &str) -> Result<Vec<Vec<String>>, Error> {
        let content = fs::read_to_string(self.at(Path::new(file)))?;
        Ok(content
            .lines()
            .map(|line| line.split(':').map(str::to_owned).collect::<Vec<_>>())
            .filter(|fields| fields.len() >= 4)
            .collect())
    }

    fn entries(&self, file: &str) -> Result<Vec<Vec<String>>, Error> {
        Ok(self
            .table(file)?
            .into_iter()
            .filter(|fields| fields[0] == Account::NAME)
            .collect())
    }

    fn has(&self, file: &str) -> Result<bool, Error> {
        Ok(!self.entries(file)?.is_empty())
    }

    fn ids(&self) -> Result<Ids, Error> {
        let entries = self.entries("/etc/passwd")?;
        let Some(fields) = entries.first() else {
            return Err(Error::Account(format!(
                "{} is missing from /etc/passwd",
                Account::NAME
            )));
        };
        Ok(Ids {
            uid: number(&fields[2])?,
            gid: number(&fields[3])?,
        })
    }

    fn members(&self) -> Result<Vec<String>, Error> {
        let entries = self.entries("/etc/group")?;
        let Some(fields) = entries.first() else {
            return Err(Error::Account(format!(
                "group {} is missing",
                Account::NAME
            )));
        };
        Ok(fields[3]
            .split(',')
            .filter(|member| !member.is_empty())
            .map(str::to_owned)
            .collect())
    }

    fn nologin(&self) -> &'static str {
        for shell in Account::NOLOGIN {
            if self.at(Path::new(shell)).is_file() {
                return shell;
            }
        }
        Account::NO_SHELL
    }

    pub fn ensure_account(&mut self) -> Result<Ids, Error> {
        if self.has("/etc/passwd")? {
            return self.ids();
        }
        let grouped = self.has("/etc/group")?;
        let home = Paths::linux_under(Path::new("/")).state_dir;
        let home = text(&home)?.to_owned();
        let shell = self.nologin();
        if self.has_program("useradd") {
            let group: &[&str] = match grouped {
                true => &["--gid", Account::NAME],
                false => &["--user-group"],
            };
            let mut args = vec!["--system", "--home-dir", &home, "--no-create-home"];
            args.extend(["--shell", shell]);
            args.extend(group);
            args.push(Account::NAME);
            self.exec("useradd", &args)?;
        } else if self.has_program("adduser") {
            if !grouped {
                self.exec("addgroup", &["-S", Account::NAME])?;
            }
            let mut args = vec!["-S", "-D", "-H", "-h", &home, "-s", shell, "-G"];
            args.extend([Account::NAME, Account::NAME]);
            self.exec("adduser", &args)?;
        } else {
            return Err(Error::Account(
                "neither useradd nor adduser is installed".into(),
            ));
        }
        self.ids()
    }

    pub fn remove_account(&mut self) -> Result<(), Error> {
        if self.has("/etc/passwd")? {
            let program = match self.has_program("userdel") {
                true => "userdel",
                false => "deluser",
            };
            self.exec(program, &[Account::NAME])?;
        }
        if self.has("/etc/group")? {
            let program = match self.has_program("groupdel") {
                true => "groupdel",
                false => "delgroup",
            };
            self.exec(program, &[Account::NAME])?;
        }
        Ok(())
    }

    fn pkexec_invoker(&self) -> Result<Option<String>, Error> {
        let uid = number(self.host.pkexec_uid.trim())?;
        for fields in self.table("/etc/passwd")? {
            if number(&fields[2])? == uid && fields[0] != Account::ROOT {
                return Ok(Some(fields[0].clone()));
            }
        }
        Ok(None)
    }

    fn sudo_invoker(&self) -> Result<Option<String>, Error> {
        let user = self.host.sudo_user.trim();
        let claimed = self.host.sudo_uid.trim();
        if user.is_empty() || user == Account::ROOT || claimed.is_empty() {
            return Ok(None);
        }
        let claimed = number(claimed)?;
        for fields in self.table("/etc/passwd")? {
            if fields[0] == user && number(&fields[2])? == claimed {
                return Ok(Some(user.to_owned()));
            }
        }
        Ok(None)
    }

    fn invoker(&self) -> Result<Option<String>, Error> {
        match self.host.pkexec_uid.trim().is_empty() {
            false => self.pkexec_invoker(),
            true => self.sudo_invoker(),
        }
    }

    pub fn join_group(&mut self, out: &mut impl Write) -> Result<(), Error> {
        let Some(user) = self.invoker()? else {
            writeln!(
                out,
                "To read the node token as your desktop user, run: sudo usermod -a -G {} <user>, then log in again.",
                Account::NAME
            )?;
            return Ok(());
        };
        if self.members()?.contains(&user) {
            return Ok(());
        }
        match self.has_program("usermod") {
            true => self.exec("usermod", &["-a", "-G", Account::NAME, &user])?,
            false => self.exec("adduser", &[&user, Account::NAME])?,
        };
        writeln!(
            out,
            "Added {user} to group {}; log in again to read the node token.",
            Account::NAME
        )?;
        Ok(())
    }
}