diff options
| author | David Lönnhager <david.l@mullvad.net> | 2024-01-10 13:06:03 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2024-01-10 13:06:03 +0100 |
| commit | 75eb89c820f12d488a76934f59ba29fe999cf59c (patch) | |
| tree | dee82ab3f307f8d1a72a0cf01fceb8fa51612ac1 | |
| parent | edbd1f52c44ba6ee9a290146f714453fc87e689d (diff) | |
| parent | 01bcf9ed16a903aa80ff8bc48cc6f2aaf3d6e80d (diff) | |
| download | mullvadvpn-75eb89c820f12d488a76934f59ba29fe999cf59c.tar.xz mullvadvpn-75eb89c820f12d488a76934f59ba29fe999cf59c.zip | |
Merge branch 'add-settings-json-export'
| -rw-r--r-- | CHANGELOG.md | 2 | ||||
| -rw-r--r-- | mullvad-cli/src/cmds/import_settings.rs | 101 | ||||
| -rw-r--r-- | mullvad-cli/src/cmds/mod.rs | 2 | ||||
| -rw-r--r-- | mullvad-cli/src/cmds/patch.rs | 60 | ||||
| -rw-r--r-- | mullvad-cli/src/main.rs | 13 | ||||
| -rw-r--r-- | mullvad-daemon/src/lib.rs | 10 | ||||
| -rw-r--r-- | mullvad-daemon/src/management_interface.rs | 8 | ||||
| -rw-r--r-- | mullvad-daemon/src/settings/patch.rs | 63 | ||||
| -rw-r--r-- | mullvad-management-interface/proto/management_interface.proto | 2 | ||||
| -rw-r--r-- | mullvad-management-interface/src/client.rs | 5 |
10 files changed, 160 insertions, 106 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e5499df3..efd3fa6c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th ### Added - Add account UUID to verbose 'mullvad account get -v' output. - Respect OS prefer-reduced-motion setting +- Add CLI command for exporting settings patches: `mullvad export-settings`. Currently, it generates + a patch containing all patchable settings, which only includes relay IP overrides. #### Android - Add support for all screen orientations. diff --git a/mullvad-cli/src/cmds/import_settings.rs b/mullvad-cli/src/cmds/import_settings.rs deleted file mode 100644 index a89c6814c3..0000000000 --- a/mullvad-cli/src/cmds/import_settings.rs +++ /dev/null @@ -1,101 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use mullvad_management_interface::MullvadProxyClient; -use std::{ - fs::File, - io::{stdin, BufRead, BufReader}, - path::Path, -}; - -/// Maximum size of a settings patch. Bigger files/streams cause the read to fail. -const MAX_PATCH_BYTES: usize = 10 * 1024; - -/// If source is specified, read from the provided file and send it as a settings patch to the -/// daemon. Otherwise, read the patch from standard input. -pub async fn handle(source: String) -> Result<()> { - let json_blob = tokio::task::spawn_blocking(|| get_blob(source)) - .await - .unwrap()?; - - let mut rpc = MullvadProxyClient::new().await?; - rpc.apply_json_settings(json_blob) - .await - .context("Error applying patch")?; - - println!("Settings applied"); - - Ok(()) -} - -fn get_blob(source: String) -> Result<String> { - match source.as_str() { - "-" => read_settings_from_stdin().context("Failed to read from stdin"), - _ => read_settings_from_file(source).context("Failed to read from path: {source}"), - } -} - -/// Read settings from standard input -fn read_settings_from_stdin() -> Result<String> { - read_settings_from_reader(BufReader::new(stdin())) -} - -/// Read settings from a path -fn read_settings_from_file(path: impl AsRef<Path>) -> Result<String> { - read_settings_from_reader(BufReader::new(File::open(path)?)) -} - -/// Read until EOF or until newline when the last pair of braces has been closed -fn read_settings_from_reader(mut reader: impl BufRead) -> Result<String> { - let mut buf = [0u8; MAX_PATCH_BYTES]; - - let mut was_open = false; - let mut close_after_newline = false; - let mut brace_count: usize = 0; - let mut cursor_pos = 0; - - loop { - let Some(cursor) = buf.get_mut(cursor_pos..) else { - return Err(anyhow!( - "Patch too long: maximum length is {MAX_PATCH_BYTES} bytes" - )); - }; - - let prev_cursor_pos = cursor_pos; - let read_n = reader.read(cursor)?; - if read_n == 0 { - // EOF - break; - } - cursor_pos += read_n; - - let additional_bytes = &buf[prev_cursor_pos..cursor_pos]; - - if !close_after_newline { - for next in additional_bytes { - match next { - b'{' => brace_count += 1, - b'}' => { - brace_count = brace_count.checked_sub(1).with_context(|| { - // exit: too many closing braces - "syntax error: unexpected '}'" - })? - } - _ => (), - } - was_open |= brace_count > 0; - } - if brace_count == 0 && was_open { - // complete settings - close_after_newline = true; - } - } - - if close_after_newline && additional_bytes.contains(&b'\n') { - // done - break; - } - } - - Ok(std::str::from_utf8(&buf[0..cursor_pos]) - .context("settings must be utf8 encoded")? - .to_owned()) -} diff --git a/mullvad-cli/src/cmds/mod.rs b/mullvad-cli/src/cmds/mod.rs index 29e0508d80..5a9cede691 100644 --- a/mullvad-cli/src/cmds/mod.rs +++ b/mullvad-cli/src/cmds/mod.rs @@ -9,10 +9,10 @@ pub mod bridge; pub mod custom_list; pub mod debug; pub mod dns; -pub mod import_settings; pub mod lan; pub mod lockdown; pub mod obfuscation; +pub mod patch; pub mod proxies; pub mod relay; pub mod relay_constraints; diff --git a/mullvad-cli/src/cmds/patch.rs b/mullvad-cli/src/cmds/patch.rs new file mode 100644 index 0000000000..bec686e56d --- /dev/null +++ b/mullvad-cli/src/cmds/patch.rs @@ -0,0 +1,60 @@ +use anyhow::{Context, Result}; +use mullvad_management_interface::MullvadProxyClient; +use std::{ + fs::File, + io::{stdin, BufReader, Read}, +}; + +/// If source is specified, read from the provided file and send it as a settings patch to the +/// daemon. Otherwise, read the patch from standard input. +pub async fn import(source: String) -> Result<()> { + let json_blob = tokio::task::spawn_blocking(|| get_blob(source)) + .await + .unwrap()?; + + let mut rpc = MullvadProxyClient::new().await?; + rpc.apply_json_settings(json_blob) + .await + .context("Error applying patch")?; + + println!("Settings applied"); + + Ok(()) +} + +/// If source is specified, write a patch to the file. Otherwise, write the patch to standard +/// output. +pub async fn export(dest: String) -> Result<()> { + let mut rpc = MullvadProxyClient::new().await?; + let blob = rpc + .export_json_settings() + .await + .context("Error exporting patch")?; + + match dest.as_str() { + "-" => { + println!("{blob}"); + Ok(()) + } + _ => tokio::fs::write(&dest, blob) + .await + .context(format!("Failed to write to path {dest}")), + } +} + +fn get_blob(source: String) -> Result<String> { + match source.as_str() { + "-" => { + read_settings_from_reader(BufReader::new(stdin())).context("Failed to read from stdin") + } + _ => read_settings_from_reader(File::open(&source)?) + .context(format!("Failed to read from path: {source}")), + } +} + +/// Read until EOF or until newline when the last pair of braces has been closed +fn read_settings_from_reader(mut reader: impl Read) -> Result<String> { + let mut s = String::new(); + reader.read_to_string(&mut s)?; + Ok(s) +} diff --git a/mullvad-cli/src/main.rs b/mullvad-cli/src/main.rs index 270d3c293e..c0a8a8b992 100644 --- a/mullvad-cli/src/main.rs +++ b/mullvad-cli/src/main.rs @@ -138,11 +138,19 @@ enum Cli { #[clap(subcommand)] CustomList(custom_list::CustomList), - /// Apply a JSON patch + /// Apply a JSON patch generated by 'export-settings' + #[clap(arg_required_else_help = true)] ImportSettings { /// File to read from. If this is "-", read from standard input file: String, }, + + /// Export a JSON patch based on the current settings + #[clap(arg_required_else_help = true)] + ExportSettings { + /// File to write to. If this is "-", write to standard output + file: String, + }, } #[tokio::main] @@ -169,7 +177,8 @@ async fn main() -> Result<()> { Cli::SplitTunnel(cmd) => cmd.handle().await, Cli::Status { cmd, args } => status::handle(cmd, args).await, Cli::CustomList(cmd) => cmd.handle().await, - Cli::ImportSettings { file } => import_settings::handle(file).await, + Cli::ImportSettings { file } => patch::import(file).await, + Cli::ExportSettings { file } => patch::export(file).await, #[cfg(all(unix, not(target_os = "android")))] Cli::ShellCompletions { shell, dir } => { diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index 0d02a068c4..06e289d22d 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -353,8 +353,10 @@ pub enum DaemonCommand { /// Verify that a google play payment was successful through the API. #[cfg(target_os = "android")] VerifyPlayPurchase(ResponseTx<(), Error>, PlayPurchase), - /// Patch the settings using a blob of JSON settings + /// Patch the settings using a JSON patch ApplyJsonSettings(ResponseTx<(), settings::patch::Error>, String), + /// Return a JSON blob containing all overridable settings, if there are any + ExportJsonSettings(ResponseTx<String, settings::patch::Error>), } /// All events that can happen in the daemon. Sent from various threads and exposed interfaces. @@ -1275,6 +1277,7 @@ where self.on_verify_play_purchase(tx, play_purchase) } ApplyJsonSettings(tx, blob) => self.on_apply_json_settings(tx, blob).await, + ExportJsonSettings(tx) => self.on_export_json_settings(tx), } } @@ -2626,6 +2629,11 @@ where Self::oneshot_send(tx, result, "apply_json_settings response"); } + fn on_export_json_settings(&mut self, tx: ResponseTx<String, settings::patch::Error>) { + let result = settings::patch::export_settings(&self.settings); + Self::oneshot_send(tx, result, "export_json_settings response"); + } + /// Set the target state of the client. If it changed trigger the operations needed to /// progress towards that state. /// Returns a bool representing whether or not a state change was initiated. diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs index 8b8d840cfb..261aa52853 100644 --- a/mullvad-daemon/src/management_interface.rs +++ b/mullvad-daemon/src/management_interface.rs @@ -892,6 +892,14 @@ impl ManagementService for ManagementServiceImpl { self.wait_for_result(rx).await??; Ok(Response::new(())) } + + async fn export_json_settings(&self, _: Request<()>) -> ServiceResult<String> { + log::debug!("export_json_settings"); + let (tx, rx) = oneshot::channel(); + self.send_command_to_daemon(DaemonCommand::ExportJsonSettings(tx))?; + let blob = self.wait_for_result(rx).await??; + Ok(Response::new(blob)) + } } impl ManagementServiceImpl { diff --git a/mullvad-daemon/src/settings/patch.rs b/mullvad-daemon/src/settings/patch.rs index 50c0da6304..ad4826812b 100644 --- a/mullvad-daemon/src/settings/patch.rs +++ b/mullvad-daemon/src/settings/patch.rs @@ -11,6 +11,9 @@ //! existing settings. //! //! Permitted settings and merge strategies are defined in the [PERMITTED_SUBKEYS] constant. +//! +//! This implementation must be kept in sync with the +//! [spec](../../../docs/settings-patch-format.md). use super::SettingsPersister; use mullvad_types::settings::Settings; @@ -33,6 +36,9 @@ pub enum Error { /// Failed to serialize settings #[error(display = "Failed to serialize current settings")] SerializeSettings(#[error(source)] serde_json::Error), + /// Failed to serialize field + #[error(display = "Failed to serialize value")] + SerializeValue(#[error(source)] serde_json::Error), /// Recursion limit reached #[error(display = "Maximum JSON object depth reached")] RecursionLimit, @@ -54,7 +60,9 @@ impl From<Error> for mullvad_management_interface::Status { | Error::DeserializePatched(_) | Error::RecursionLimit => Status::invalid_argument(error.to_string()), Error::Settings(error) => Status::from(error), - Error::SerializeSettings(error) => Status::internal(error.to_string()), + Error::SerializeSettings(error) | Error::SerializeValue(error) => { + Status::internal(error.to_string()) + } } } } @@ -125,6 +133,40 @@ const PERMITTED_SUBKEYS: &PermittedKey = &PermittedKey::object(&[( /// tail-call optimization can be enforced? const RECURSE_LIMIT: usize = 15; +/// Export a patch containing all currently supported settings. +pub fn export_settings(settings: &Settings) -> Result<String, Error> { + let patch = export_settings_inner(settings)?; + serde_json::to_string_pretty(&patch).map_err(Error::SerializeValue) +} + +fn export_settings_inner(settings: &Settings) -> Result<serde_json::Value, Error> { + let mut out = serde_json::Map::new(); + let mut overrides = vec![]; + + for relay_override in &settings.relay_overrides { + let mut relay_override = + serde_json::to_value(relay_override).map_err(Error::SerializeValue)?; + if let Some(relay_overrides) = relay_override.as_object_mut() { + // prune empty override entries + relay_overrides.retain(|_k, v| !v.is_null()); + let has_overrides = relay_overrides.iter().any(|(key, _)| key != "hostname"); + if !has_overrides { + continue; + } + } + overrides.push(relay_override); + } + + if !overrides.is_empty() { + out.insert( + "relay_overrides".to_owned(), + serde_json::Value::Array(overrides), + ); + } + + Ok(serde_json::Value::Object(out)) +} + /// Update the settings with the supplied patch. Only settings specified in `PERMITTED_SUBKEYS` can /// be updated. All other changes are rejected pub async fn merge_validate_patch( @@ -418,6 +460,25 @@ fn test_valid_patch_files() { } #[test] +fn test_patch_export() { + use mullvad_types::relay_constraints::RelayOverride; + + let mut settings = Settings::default(); + + let mut relay_override = RelayOverride::empty("test".to_owned()); + relay_override.ipv4_addr_in = Some("1.2.3.4".parse().unwrap()); + relay_override.ipv6_addr_in = Some("::1".parse().unwrap()); + settings.relay_overrides.push(relay_override); + + let exported = export_settings_inner(&settings).expect("patch export failed"); + + let expected = r#"{ "relay_overrides": [ { "hostname": "test", "ipv4_addr_in": "1.2.3.4", "ipv6_addr_in": "::1" } ] }"#; + let expected: serde_json::Value = serde_json::from_str(expected).unwrap(); + + assert_eq!(exported, expected); +} + +#[test] fn test_patch_relay_override() { const PERMITTED_SUBKEYS: &PermittedKey = &PermittedKey::object(&[( "relay_overrides", diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto index e4ef20cbcc..5bf0c69f00 100644 --- a/mullvad-management-interface/proto/management_interface.proto +++ b/mullvad-management-interface/proto/management_interface.proto @@ -102,6 +102,8 @@ service ManagementService { // Apply a JSON blob to the settings // See ../../docs/settings-patch-format.md for a description of the format rpc ApplyJsonSettings(google.protobuf.StringValue) returns (google.protobuf.Empty) {} + // Return a JSON blob containing all overridable settings, if there are any + rpc ExportJsonSettings(google.protobuf.Empty) returns (google.protobuf.StringValue) {} } message UUID { string value = 1; } diff --git a/mullvad-management-interface/src/client.rs b/mullvad-management-interface/src/client.rs index 2c0ca2fecf..7ff26e0371 100644 --- a/mullvad-management-interface/src/client.rs +++ b/mullvad-management-interface/src/client.rs @@ -678,6 +678,11 @@ impl MullvadProxyClient { self.0.apply_json_settings(blob).await.map_err(Error::Rpc)?; Ok(()) } + + pub async fn export_json_settings(&mut self) -> Result<String> { + let blob = self.0.export_json_settings(()).await.map_err(Error::Rpc)?; + Ok(blob.into_inner()) + } } fn map_device_error(status: Status) -> Error { |
