umshctl/command/
provision.rs1use 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 #[arg(long, value_name = "KEY", action = clap::ArgAction::Append)]
22 pub host_key: Vec<KeyArg>,
23
24 #[arg(long, value_name = "KEY", action = clap::ArgAction::Append)]
26 pub channel_key: Vec<KeyArg>,
27
28 #[arg(long, value_name = "PUB,KENC,KMIC", action = clap::ArgAction::Append)]
31 pub peer: Vec<PeerArg>,
32
33 #[arg(long, value_name = "SPEC", action = clap::ArgAction::Append)]
36 pub filter: Vec<FilterArg>,
37
38 #[arg(long, value_name = "on|off", action = clap::ArgAction::Append)]
40 pub auto_ack: Vec<OnOffArg>,
41
42 #[arg(long, value_name = "PATH")]
44 pub file: Option<std::path::PathBuf>,
45
46 #[arg(long)]
48 pub force: bool,
49}
50
51impl ProvisionArgs {
52 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#[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 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 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}