umshctl/command/
provision.rs

1//! `provision`: establish the host domain — the keys, filters, and
2//! delegation policy for the host that will tether to this device.
3//!
4//! The one command that needs a tethered attach. Everything else this
5//! tool does administers a device without becoming its host, and the
6//! handle refuses host-domain writes to keep that honest.
7
8use anyhow::{Context as _, Result, bail};
9
10use umsh::core::PublicKey;
11use umsh::ulcp::{HostOwnership, HostProvisioning};
12
13use super::values::{FilterArg, KeyArg, OnOffArg, PeerArg, parse_bool, parse_key32};
14use crate::App;
15use crate::connection::confirm;
16use crate::output::note;
17
18#[derive(Debug, clap::Args)]
19pub struct ProvisionArgs {
20    /// Host identity public key. Required, by flag or by file.
21    #[arg(long, value_name = "KEY", action = clap::ArgAction::Append)]
22    pub host_key: Vec<KeyArg>,
23
24    /// Channel key. Repeatable.
25    #[arg(long, value_name = "KEY", action = clap::ArgAction::Append)]
26    pub channel_key: Vec<KeyArg>,
27
28    /// Peer public key plus its two 16-byte hex pairwise secrets.
29    /// Repeatable.
30    #[arg(long, value_name = "PUB,KENC,KMIC", action = clap::ArgAction::Append)]
31    pub peer: Vec<PeerArg>,
32
33    /// Receive filter: `dest-hint:HHHHHH`, `channel-id:HHHH`, or
34    /// `pkt-type:N`. Repeatable.
35    #[arg(long, value_name = "SPEC", action = clap::ArgAction::Append)]
36    pub filter: Vec<FilterArg>,
37
38    /// Delegated MAC acknowledgements.
39    #[arg(long, value_name = "on|off", action = clap::ArgAction::Append)]
40    pub auto_ack: Vec<OnOffArg>,
41
42    /// Read the same settings from a `setting = value` file.
43    #[arg(long, value_name = "PATH")]
44    pub file: Option<std::path::PathBuf>,
45
46    /// Displace another host's provisioning.
47    #[arg(long)]
48    pub force: bool,
49}
50
51impl ProvisionArgs {
52    /// Fold flags and the optional file into one desired state. Both
53    /// sources share the same vocabulary on purpose.
54    pub fn desired(&self) -> Result<HostProvisioning> {
55        let mut settings = Settings::default();
56        for key in &self.host_key {
57            if settings.host_key.is_some() {
58                bail!("host-key given more than once");
59            }
60            settings.host_key = Some(key.0);
61        }
62        for key in &self.channel_key {
63            settings.channel_keys.push(key.0);
64        }
65        for peer in &self.peer {
66            settings.peer_keys.push(peer.0);
67        }
68        for filter in &self.filter {
69            settings.filters.push(filter.0);
70        }
71        for value in &self.auto_ack {
72            if settings.auto_ack.is_some() {
73                bail!("auto-ack given more than once");
74            }
75            settings.auto_ack = Some(value.0);
76        }
77        if let Some(path) = &self.file {
78            let text = std::fs::read_to_string(path)
79                .with_context(|| format!("reading {}", path.display()))?;
80            parse_file(&text, &mut settings).with_context(|| path.display().to_string())?;
81        }
82        settings.finish()
83    }
84}
85
86/// Provisioning inputs accumulated from flags and file lines.
87#[derive(Debug, Default)]
88struct Settings {
89    host_key: Option<[u8; 32]>,
90    channel_keys: Vec<[u8; 32]>,
91    peer_keys: Vec<umsh::ulcp_wire::items::PeerKeyEntry>,
92    filters: Vec<umsh::ulcp_wire::items::Filter>,
93    auto_ack: Option<bool>,
94}
95
96impl Settings {
97    fn add(&mut self, setting: &str, value: &str) -> Result<()> {
98        let text = |error: String| anyhow::anyhow!(error);
99        match setting {
100            "host-key" => {
101                if self.host_key.is_some() {
102                    bail!("host-key given more than once");
103                }
104                self.host_key = Some(parse_key32(value).map_err(text)?);
105            }
106            "channel-key" => self.channel_keys.push(parse_key32(value).map_err(text)?),
107            "peer" => self
108                .peer_keys
109                .push(value.parse::<PeerArg>().map_err(text)?.0),
110            "filter" => self
111                .filters
112                .push(value.parse::<FilterArg>().map_err(text)?.0),
113            "auto-ack" => {
114                if self.auto_ack.is_some() {
115                    bail!("auto-ack given more than once");
116                }
117                self.auto_ack = Some(parse_bool(value).map_err(text)?);
118            }
119            other => bail!("unknown provisioning setting {other:?}"),
120        }
121        Ok(())
122    }
123
124    fn finish(self) -> Result<HostProvisioning> {
125        let Some(host_key) = self.host_key else {
126            bail!("provisioning requires a host-key (flag or file)");
127        };
128        Ok(HostProvisioning {
129            host_key,
130            filters: self.filters,
131            channel_keys: self.channel_keys,
132            peer_keys: self.peer_keys,
133            auto_ack: self.auto_ack.unwrap_or(true),
134        })
135    }
136}
137
138fn parse_file(text: &str, settings: &mut Settings) -> Result<()> {
139    for (number, raw) in text.lines().enumerate() {
140        let line = raw.split('#').next().unwrap_or("").trim();
141        if line.is_empty() {
142            continue;
143        }
144        let Some((setting, value)) = line.split_once('=') else {
145            bail!("line {}: expected `setting = value`", number + 1);
146        };
147        settings
148            .add(setting.trim(), value.trim())
149            .with_context(|| format!("line {}", number + 1))?;
150    }
151    Ok(())
152}
153
154pub async fn run(app: &mut App, args: ProvisionArgs) -> Result<()> {
155    let desired = args.desired()?;
156
157    // Host-domain writes need a tethered handle. One-shot mode attaches
158    // that way to begin with; the REPL is administrative, so it borrows
159    // the open link for the duration of this one command rather than
160    // making the user restart the tool.
161    let borrowed = app.session()?.is_administrative();
162    if borrowed {
163        app.reattach(true).await?;
164    }
165    let outcome = provision(app, desired, args.force).await;
166    if borrowed && let Err(error) = app.reattach(false).await {
167        crate::output::warn(format!(
168            "could not return to an administrative attach ({error}); reconnect with `connect`"
169        ));
170    }
171    outcome
172}
173
174async fn provision(app: &mut App, desired: HostProvisioning, force: bool) -> Result<()> {
175    let interactive = app.interactive;
176    let device = app.device()?;
177    let sync = device.sync(Some(&desired.host_key)).await?;
178    match sync.ownership {
179        HostOwnership::Unsupported => {
180            bail!("this device does not support host provisioning (no CAP_HOST_FILTER)");
181        }
182        HostOwnership::OtherHost(other) if !force => {
183            let message = format!(
184                "the device is provisioned for another host ({})",
185                PublicKey(other)
186            );
187            if !interactive {
188                bail!(
189                    "{message}; re-run with --force to displace it (its host domain will be \
190                     wiped)"
191                );
192            }
193            println!("{message}.");
194            if !confirm("displace it, wiping its host domain?")? {
195                println!("cancelled");
196                return Ok(());
197            }
198        }
199        _ => {}
200    }
201    let device = app.device()?;
202    let report = device.provision(&desired).await?;
203    if report.host_replaced {
204        println!("host identity replaced; the previous host domain was discarded");
205    }
206    println!("filter table written ({} entries)", desired.filters.len());
207    if report.channels_replaced {
208        println!(
209            "channel-key table replaced ({} keys)",
210            desired.channel_keys.len()
211        );
212    } else {
213        println!("channel keys written: {}", report.channels_inserted);
214    }
215    println!("peer entries written: {}", report.peers_inserted);
216    if report.peers_removed > 0 {
217        println!("peer entries removed: {}", report.peers_removed);
218    }
219    println!(
220        "auto-ack set to {}",
221        if desired.auto_ack { "on" } else { "off" }
222    );
223    // Host provisioning is deliberately not saved: the host domain is
224    // volatile across power cycles by design, and a host re-provisions
225    // in full on every attach.
226    note("host provisioning is live only — it is re-established on every attach");
227    Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use umsh::ulcp_wire::items::Filter;
234
235    const KEY_HEX: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
236
237    #[test]
238    fn file_lines_share_the_flag_vocabulary() {
239        let mut settings = Settings::default();
240        let text = format!(
241            "# operator provisioning\n\
242             host-key = {KEY_HEX}\n\
243             auto-ack = on\n\
244             channel-key = {KEY_HEX}   # primary channel\n\
245             peer = {KEY_HEX} {} {}\n\
246             filter = pkt-type 1\n",
247            "e0".repeat(16),
248            "50".repeat(16),
249        );
250        parse_file(&text, &mut settings).unwrap();
251        let desired = settings.finish().unwrap();
252        assert_eq!(desired.host_key, [0xC4; 32]);
253        assert!(desired.auto_ack);
254        assert_eq!(desired.channel_keys.len(), 1);
255        assert_eq!(desired.peer_keys.len(), 1);
256        assert_eq!(desired.filters, vec![Filter::PktType(1)]);
257    }
258
259    #[test]
260    fn duplicate_scalar_settings_are_rejected() {
261        let mut settings = Settings::default();
262        settings.add("host-key", KEY_HEX).unwrap();
263        let error = settings.add("host-key", KEY_HEX).unwrap_err().to_string();
264        assert!(error.contains("more than once"), "{error}");
265    }
266
267    #[test]
268    fn a_file_without_setting_syntax_names_the_line() {
269        let mut settings = Settings::default();
270        let error = parse_file("host-key\n", &mut settings)
271            .unwrap_err()
272            .to_string();
273        assert!(error.contains("line 1"), "{error}");
274    }
275
276    #[test]
277    fn provisioning_requires_a_host_key() {
278        let error = Settings::default().finish().unwrap_err();
279        assert!(error.to_string().contains("host-key"), "{error}");
280    }
281}