summaryrefslogtreecommitdiffhomepage
path: root/mullvad-cli/src/cmds/custom_list.rs
blob: a2ee07c97018deddb86af5d89d44d657d044846a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
use super::{relay::resolve_location_constraint, relay_constraints::LocationArgs};
use anyhow::{Result, anyhow, bail};
use clap::Subcommand;
use mullvad_management_interface::MullvadProxyClient;
use mullvad_types::{
    constraints::Constraint, relay_constraints::GeographicLocationConstraint, relay_list::RelayList,
};

/// Custom list length, expressed as a number of UTF8 codepoints (i.e. chars).
pub const CUSTOM_LIST_MAX_LEN: usize = 30;

#[derive(Subcommand, Debug)]
pub enum CustomList {
    /// Create a new custom list
    New {
        /// A name for the new custom list
        #[clap(value_parser = parse_custom_list_name)]
        name: String,
    },

    /// Show all custom lists or retrieve a specific custom list
    List {
        // TODO: Would be cool to provide dynamic auto-completion:
        // https://github.com/clap-rs/clap/issues/1232
        /// A custom list. If omitted, all custom lists are shown
        name: Option<String>,
    },

    /// Edit a custom list
    #[clap(subcommand)]
    Edit(EditCommand),

    /// Delete a custom list
    Delete {
        /// A custom list
        name: String,
    },
}

#[derive(Subcommand, Debug)]
pub enum EditCommand {
    /// Add a location to some custom list
    Add {
        /// A custom list
        name: String,
        #[command(flatten)]
        location: LocationArgs,
    },

    /// Remove a location from some custom list
    Remove {
        /// A custom list
        name: String,
        #[command(flatten)]
        location: LocationArgs,
    },

    /// Rename a custom list
    Rename {
        /// Current name of the custom list
        name: String,

        /// A new name for the custom list
        #[clap(value_parser = parse_custom_list_name)]
        new_name: String,
    },
}

impl CustomList {
    pub async fn handle(self) -> Result<()> {
        match self {
            CustomList::List { name: None } => Self::list().await,
            CustomList::List { name: Some(name) } => Self::get(name).await,
            CustomList::New { name } => Self::create_list(name).await,
            CustomList::Delete { name } => Self::delete_list(name).await,
            CustomList::Edit(cmd) => match cmd {
                EditCommand::Add { name, location } => Self::add_location(name, location).await,
                EditCommand::Rename { name, new_name } => Self::rename_list(name, new_name).await,
                EditCommand::Remove { name, location } => {
                    Self::remove_location(name, location).await
                }
            },
        }
    }

    /// Print all custom lists.
    async fn list() -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;
        let cache = rpc.get_relay_locations().await?;
        for custom_list in rpc.get_settings().await?.custom_lists {
            Self::print_custom_list(&custom_list, &cache)
        }
        Ok(())
    }

    /// Print a specific custom list (if it exists).
    /// If the list does not exist, print an error.
    async fn get(name: String) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;
        let custom_list = find_list_by_name(&mut rpc, &name).await?;
        let cache = rpc.get_relay_locations().await?;
        Self::print_custom_list_content(&custom_list, &cache);
        Ok(())
    }

    async fn create_list(name: String) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;
        rpc.create_custom_list(name).await?;
        Ok(())
    }

    async fn add_location(name: String, location_args: LocationArgs) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;

        // Don't filter out any hosts, i.e. allow adding even inactive ones
        let relay_filter = |_: &_| true;
        let location_constraint =
            resolve_location_constraint(&mut rpc, location_args, relay_filter).await?;

        match location_constraint {
            Constraint::Any => bail!("\"any\" is not a valid location"),
            Constraint::Only(location) => {
                let mut list = find_list_by_name(&mut rpc, &name).await?;
                if list.locations.insert(location) {
                    rpc.update_custom_list(list).await?;
                    println!("Location added to custom-list")
                } else {
                    bail!("Provided location is already present in custom-list")
                };
            }
        }

        Ok(())
    }

    async fn remove_location(name: String, location_args: LocationArgs) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;

        // Don't filter out any hosts, i.e. allow adding even inactive ones
        let relay_filter = |_: &_| true;
        let location_constraint =
            resolve_location_constraint(&mut rpc, location_args, relay_filter).await?;

        match location_constraint {
            Constraint::Any => bail!("\"any\" is not a valid location"),
            Constraint::Only(location) => {
                let mut list = find_list_by_name(&mut rpc, &name).await?;
                if list.locations.remove(&location) {
                    rpc.update_custom_list(list).await?;
                    println!("Location removed from custom-list")
                } else {
                    bail!("Provided location was not present in custom-list")
                };
            }
        }

        Ok(())
    }

    async fn delete_list(name: String) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;
        let list = find_list_by_name(&mut rpc, &name).await?;
        rpc.delete_custom_list(list.id()).await?;
        Ok(())
    }

    async fn rename_list(name: String, new_name: String) -> Result<()> {
        let mut rpc = MullvadProxyClient::new().await?;

        let mut list = find_list_by_name(&mut rpc, &name).await?;
        list.name = new_name;
        rpc.update_custom_list(list).await?;

        Ok(())
    }

    fn print_custom_list(custom_list: &mullvad_types::custom_list::CustomList, cache: &RelayList) {
        println!("{}", custom_list.name);
        Self::print_custom_list_content(custom_list, cache);
    }

    fn print_custom_list_content(
        custom_list: &mullvad_types::custom_list::CustomList,
        cache: &RelayList,
    ) {
        for location in &custom_list.locations {
            println!(
                "\t{}",
                GeographicLocationConstraintFormatter::from_constraint(location, cache)
            );
        }
    }
}

/// Struct used for pretty printing [`GeographicLocationConstraint`] with
/// human-readable names for countries and cities.
pub struct GeographicLocationConstraintFormatter<'a> {
    constraint: &'a GeographicLocationConstraint,
    country: Option<String>,
    city: Option<String>,
}

impl<'a> GeographicLocationConstraintFormatter<'a> {
    fn from_constraint(constraint: &'a GeographicLocationConstraint, cache: &RelayList) -> Self {
        use GeographicLocationConstraint::*;
        let (country_code, city_code) = match constraint {
            Country(country) => (Some(country), None),
            City(country, city) | Hostname(country, city, _) => (Some(country), Some(city)),
        };

        let country =
            country_code.and_then(|country_code| cache.lookup_country(country_code.clone()));
        let city = city_code.and_then(|city_code| {
            country.and_then(|country| country.lookup_city(city_code.clone()))
        });

        Self {
            constraint,
            country: country.map(|x| x.name.clone()),
            city: city.map(|x| x.name.clone()),
        }
    }
}

impl std::fmt::Display for GeographicLocationConstraintFormatter<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
        let unwrap_country = |country: Option<String>, constraint: &str| {
            country.unwrap_or(format!("{constraint} <invalid country>"))
        };

        let unwrap_city = |city: Option<String>, constraint: &str| {
            city.unwrap_or(format!("{constraint} <invalid city>"))
        };

        match &self.constraint {
            GeographicLocationConstraint::Country(country) => {
                let rich_country = unwrap_country(self.country.clone(), country);
                write!(f, "{rich_country} ({country})")
            }
            GeographicLocationConstraint::City(country, city) => {
                let rich_country = unwrap_country(self.country.clone(), country);
                let rich_city = unwrap_city(self.city.clone(), city);
                write!(f, "{rich_city}, {rich_country} ({city}, {country})")
            }
            GeographicLocationConstraint::Hostname(country, city, hostname) => {
                let rich_country = unwrap_country(self.country.clone(), country);
                let rich_city = unwrap_city(self.city.clone(), city);
                write!(
                    f,
                    "{hostname} in {rich_city}, {rich_country} ({city}, {country})"
                )
            }
        }
    }
}

pub async fn find_list_by_name(
    rpc: &mut MullvadProxyClient,
    name: &str,
) -> Result<mullvad_types::custom_list::CustomList> {
    rpc.get_settings()
        .await?
        .custom_lists
        .into_iter()
        .find(|list| list.name == name)
        .ok_or(anyhow!("List not found"))
}

/// Trim the string and validate the length against [CUSTOM_LIST_MAX_LEN].
// NOTE: should only be used when *creating* custom lists, as we don't want to make it impossible
// to reference any custom lists created before the max length and whitespace restrictions were put
// in place.
fn parse_custom_list_name(s: &str) -> Result<String> {
    let s = s.trim();
    let length = s.chars().count();
    if length > CUSTOM_LIST_MAX_LEN {
        bail!("Provided name is too long, {length}/{CUSTOM_LIST_MAX_LEN} characters.");
    }
    Ok(s.to_string())
}