Skip to main content

urma_node/install/
account.rs

1use crate::{
2    config::SERVICE_NAME,
3    install::{Error, Runner, System, system::text},
4    platform::Paths,
5};
6use std::{fs, io::Write, path::Path};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct Ids {
10    pub uid: u32,
11    pub gid: u32,
12}
13
14pub struct Account;
15
16impl Account {
17    pub const NAME: &'static str = SERVICE_NAME;
18    const NOLOGIN: [&'static str; 3] = ["/usr/sbin/nologin", "/sbin/nologin", "/usr/bin/nologin"];
19    const NO_SHELL: &'static str = "/bin/false";
20    const ROOT: &'static str = "root";
21}
22
23fn number(field: &str) -> Result<u32, Error> {
24    field
25        .parse()
26        .map_err(|error| Error::Account(format!("bad id {field:?}: {error}")))
27}
28
29impl<R: Runner> System<R> {
30    fn table(&self, file: &str) -> Result<Vec<Vec<String>>, Error> {
31        let content = fs::read_to_string(self.at(Path::new(file)))?;
32        Ok(content
33            .lines()
34            .map(|line| line.split(':').map(str::to_owned).collect::<Vec<_>>())
35            .filter(|fields| fields.len() >= 4)
36            .collect())
37    }
38
39    fn entries(&self, file: &str) -> Result<Vec<Vec<String>>, Error> {
40        Ok(self
41            .table(file)?
42            .into_iter()
43            .filter(|fields| fields[0] == Account::NAME)
44            .collect())
45    }
46
47    fn has(&self, file: &str) -> Result<bool, Error> {
48        Ok(!self.entries(file)?.is_empty())
49    }
50
51    fn ids(&self) -> Result<Ids, Error> {
52        let entries = self.entries("/etc/passwd")?;
53        let Some(fields) = entries.first() else {
54            return Err(Error::Account(format!(
55                "{} is missing from /etc/passwd",
56                Account::NAME
57            )));
58        };
59        Ok(Ids {
60            uid: number(&fields[2])?,
61            gid: number(&fields[3])?,
62        })
63    }
64
65    fn members(&self) -> Result<Vec<String>, Error> {
66        let entries = self.entries("/etc/group")?;
67        let Some(fields) = entries.first() else {
68            return Err(Error::Account(format!(
69                "group {} is missing",
70                Account::NAME
71            )));
72        };
73        Ok(fields[3]
74            .split(',')
75            .filter(|member| !member.is_empty())
76            .map(str::to_owned)
77            .collect())
78    }
79
80    fn nologin(&self) -> &'static str {
81        for shell in Account::NOLOGIN {
82            if self.at(Path::new(shell)).is_file() {
83                return shell;
84            }
85        }
86        Account::NO_SHELL
87    }
88
89    pub fn ensure_account(&mut self) -> Result<Ids, Error> {
90        if self.has("/etc/passwd")? {
91            return self.ids();
92        }
93        let grouped = self.has("/etc/group")?;
94        let home = Paths::linux_under(Path::new("/")).state_dir;
95        let home = text(&home)?.to_owned();
96        let shell = self.nologin();
97        if self.has_program("useradd") {
98            let group: &[&str] = match grouped {
99                true => &["--gid", Account::NAME],
100                false => &["--user-group"],
101            };
102            let mut args = vec!["--system", "--home-dir", &home, "--no-create-home"];
103            args.extend(["--shell", shell]);
104            args.extend(group);
105            args.push(Account::NAME);
106            self.exec("useradd", &args)?;
107        } else if self.has_program("adduser") {
108            if !grouped {
109                self.exec("addgroup", &["-S", Account::NAME])?;
110            }
111            let mut args = vec!["-S", "-D", "-H", "-h", &home, "-s", shell, "-G"];
112            args.extend([Account::NAME, Account::NAME]);
113            self.exec("adduser", &args)?;
114        } else {
115            return Err(Error::Account(
116                "neither useradd nor adduser is installed".into(),
117            ));
118        }
119        self.ids()
120    }
121
122    pub fn remove_account(&mut self) -> Result<(), Error> {
123        if self.has("/etc/passwd")? {
124            let program = match self.has_program("userdel") {
125                true => "userdel",
126                false => "deluser",
127            };
128            self.exec(program, &[Account::NAME])?;
129        }
130        if self.has("/etc/group")? {
131            let program = match self.has_program("groupdel") {
132                true => "groupdel",
133                false => "delgroup",
134            };
135            self.exec(program, &[Account::NAME])?;
136        }
137        Ok(())
138    }
139
140    fn pkexec_invoker(&self) -> Result<Option<String>, Error> {
141        let uid = number(self.host.pkexec_uid.trim())?;
142        for fields in self.table("/etc/passwd")? {
143            if number(&fields[2])? == uid && fields[0] != Account::ROOT {
144                return Ok(Some(fields[0].clone()));
145            }
146        }
147        Ok(None)
148    }
149
150    fn sudo_invoker(&self) -> Result<Option<String>, Error> {
151        let user = self.host.sudo_user.trim();
152        let claimed = self.host.sudo_uid.trim();
153        if user.is_empty() || user == Account::ROOT || claimed.is_empty() {
154            return Ok(None);
155        }
156        let claimed = number(claimed)?;
157        for fields in self.table("/etc/passwd")? {
158            if fields[0] == user && number(&fields[2])? == claimed {
159                return Ok(Some(user.to_owned()));
160            }
161        }
162        Ok(None)
163    }
164
165    fn invoker(&self) -> Result<Option<String>, Error> {
166        match self.host.pkexec_uid.trim().is_empty() {
167            false => self.pkexec_invoker(),
168            true => self.sudo_invoker(),
169        }
170    }
171
172    pub fn join_group(&mut self, out: &mut impl Write) -> Result<(), Error> {
173        let Some(user) = self.invoker()? else {
174            writeln!(
175                out,
176                "To read the node token as your desktop user, run: sudo usermod -a -G {} <user>, then log in again.",
177                Account::NAME
178            )?;
179            return Ok(());
180        };
181        if self.members()?.contains(&user) {
182            return Ok(());
183        }
184        match self.has_program("usermod") {
185            true => self.exec("usermod", &["-a", "-G", Account::NAME, &user])?,
186            false => self.exec("adduser", &[&user, Account::NAME])?,
187        };
188        writeln!(
189            out,
190            "Added {user} to group {}; log in again to read the node token.",
191            Account::NAME
192        )?;
193        Ok(())
194    }
195}