gitaway 0.1.0-alpha.1

Reach your self-hosted git server from anywhere, with a tunnel only gitaway uses
Documentation
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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! `gitaway profile …`: add, list, show, remove, set the default, and change
//! single settings.
//!
//! `add` also creates the profile's device key, and `remove` deletes it (see
//! [`crate::keys`]).
//!
//! All file access goes through [`crate::config::store::Store`], so every
//! write is atomic and every change is validated before it's saved.
//!
//! # Extending
//!
//! A new profile field that `profile add` should ask for: add a flag to
//! [`super::args::AddArgs`], a prompt in the private `add` function, and set it in
//! `build_profile`.

use toml_edit::{DocumentMut, Item, Key, Table, Value, value};

use crate::address::HostPort;
use crate::config::name::ProfileName;
use crate::config::profile::{Transport, WireGuard};
use crate::config::store::{Store, StoreError};
use crate::config::value::{HostKey, InterfaceAddress, WireGuardPublicKey};
use crate::error::Error;

use crate::keys::device_key::DeviceKey;
use crate::keys::store::{DeviceSecrets, KeyStore};

use super::args::{AddArgs, ProfileCommand};
use super::key_cmd::{print_peer_block, warn_if_fallback};
use super::prompt;

/// Runs a `gitaway profile` command.
pub fn run(command: ProfileCommand) -> Result<i32, Error> {
    let store = super::open_store()?;
    match command {
        ProfileCommand::Add(args) => add(&store, args),
        ProfileCommand::List => list(&store),
        ProfileCommand::Show { name } => show(&store, &parse_name(&name)?),
        ProfileCommand::Remove { name, yes } => remove(&store, &parse_name(&name)?, yes),
        ProfileCommand::Default { name } => set_default(&store, &parse_name(&name)?),
        ProfileCommand::Set { name, key, value } => set(&store, &parse_name(&name)?, &key, &value),
    }?;
    Ok(0)
}

/// Everything a new profile holds, already validated. Built by `profile add`
/// and `setup`.
pub(crate) struct NewProfile {
    /// How to reach home.
    pub transport: Transport,
    /// The `[home]` SSH server and its host key, if known.
    pub home: Option<(HostPort, Option<HostKey>)>,
    /// The `[hosts]` entries: LAN address, address through the tunnel.
    pub hosts: Vec<(HostPort, HostPort)>,
}

fn add(store: &Store, args: AddArgs) -> Result<(), Error> {
    let name: ProfileName = prompt::required(args.name, "the profile name", "Profile name", |s| {
        s.parse().map_err(|e| format!("{e}"))
    })?;
    if store.profile_exists(&name) {
        return Err(StoreError::AlreadyExists(name).into());
    }

    let endpoint = prompt::required(
        args.endpoint,
        "--endpoint",
        "WireGuard server's public address (host:port)",
        parse_endpoint,
    )?;
    let server_public_key = prompt::required(
        args.server_public_key,
        "--server-public-key",
        "WireGuard server's public key (in a client config: the PublicKey under [Peer])",
        |s| s.parse::<WireGuardPublicKey>().map_err(|e| e.to_string()),
    )?;
    let address = prompt::optional(
        args.address,
        "--address",
        "This device's tunnel address, such as 10.0.0.2/32",
        |s| s.parse::<InterfaceAddress>().map_err(|e| e.to_string()),
    )?;

    let home_ssh = prompt::optional(
        args.home_ssh,
        "--home-ssh",
        "SSH server that proves you're home (host:port)",
        |s| s.parse::<HostPort>().map_err(|e| e.to_string()),
    )?;
    let home = match (home_ssh, args.host_key) {
        (Some(ssh), host_key) => {
            let question = format!(
                "Its host key (one line of `ssh-keyscan -p {} {}` run at home, without the host \
                 name). Leave it blank to pin it later through the tunnel with `gitaway check`",
                ssh.port.unwrap_or(22),
                ssh.host
            );
            let key = prompt::optional(host_key, "--host-key", &question, |s| {
                s.parse::<HostKey>().map_err(|e| e.to_string())
            })?;
            Some((ssh, key))
        }
        (None, Some(_)) => return Err(Error::msg("--host-key needs --home-ssh")),
        (None, None) => None,
    };

    let mut hosts = args
        .hosts
        .iter()
        .map(|entry| {
            parse_host_entry(entry).map_err(|e| Error::msg(format!("--host {entry}: {e}")))
        })
        .collect::<Result<Vec<_>, _>>()?;
    if hosts.is_empty()
        && let Some((ssh, _)) = &home
    {
        let lan = HostPort {
            host: ssh.host.clone(),
            port: None,
        };
        let question = format!("Address of {lan} through the tunnel, such as 10.0.0.1");
        if let Some(tunnel) = prompt::optional(None, "--host", &question, |s| {
            s.parse::<HostPort>()
                .map_err(|e| e.to_string())
                .and_then(|t| match (t.port, t.host.parse::<std::net::IpAddr>()) {
                    (None, Ok(_)) => Ok(t),
                    (Some(_), _) => Err("give an address without a port".to_owned()),
                    (None, Err(_)) => Err("give an IP address, such as 10.0.0.1".to_owned()),
                })
        })? {
            hosts.push((lan, tunnel));
        }
    }

    let new = NewProfile {
        transport: Transport::WireGuard(WireGuard {
            endpoint,
            server_public_key,
            address,
            mtu: None,
        }),
        home,
        hosts,
    };
    let keys = KeyStore::from_env(store.dir().clone())?;
    store.create_profile(&name, &build_profile(&new).to_string())?;
    let (key_message, public_key) = match ensure_device_key(&keys, &name) {
        Ok(created) => created,
        Err(e) => {
            // Without a key the profile can't be used, so don't leave it half made.
            store.remove_profile(&name)?;
            return Err(e);
        }
    };
    if args.default {
        set_default(store, &name)?;
    }

    println!(
        "Created profile `{name}` ({}).",
        store.dir().profile_file(&name).display()
    );
    println!("{key_message}");
    warn_if_fallback(&keys, &name);
    match &new.home {
        Some((_, Some(key))) => println!(
            "Home host key fingerprint: {}\n  Compare it on the server with `ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub`.",
            key.fingerprint()
        ),
        Some((_, None)) => println!(
            "No home host key yet, so this profile will always use the tunnel until\n\
             `gitaway check {name}` pins it (through the tunnel, once the server has this device)."
        ),
        None => println!(
            "No [home] section: gitaway can't tell when you're home, so it will always use the tunnel for this profile."
        ),
    }
    if new.hosts.is_empty() {
        println!(
            "No [hosts]: no remotes will use this profile. Add one with\n  gitaway profile set {name} 'hosts.\"192.0.2.10\"' 10.0.0.1"
        );
    }

    println!(
        "\nNext: add this device to your WireGuard server. The tunnel won't connect until\n\
         the server has this peer. (If the server or a WireGuard app already made a key\n\
         pair for this device, use `gitaway key import {name}` instead.)\n"
    );
    let profile = store.load_profile(&name)?;
    print_peer_block(&name, &profile, &public_key);
    println!("\nThen run `gitaway check {name}` to test the tunnel.");
    Ok(())
}

/// Creates the profile's device key, or keeps one left over from an earlier
/// profile with the same name (the server may already know it). Returns a
/// line saying which, and the key's public half.
fn ensure_device_key(
    keys: &KeyStore,
    name: &ProfileName,
) -> Result<(String, WireGuardPublicKey), Error> {
    if let Some(existing) = keys.load(name)? {
        let public_key = existing.key.public_key();
        let message =
            format!("Kept the existing device key for `{name}` (public key {public_key}).");
        return Ok((message, public_key));
    }
    let key = DeviceKey::generate();
    let public_key = key.public_key();
    keys.save(name, &DeviceSecrets::new(key))?;
    let message = format!("Created a device key, stored in {}.", keys.location(name));
    Ok((message, public_key))
}

/// Builds the profile file. Validation happens when the store saves it.
pub(crate) fn build_profile(new: &NewProfile) -> DocumentMut {
    let mut transport = Table::new();
    transport.set_implicit(true);
    match &new.transport {
        Transport::WireGuard(wg) => {
            let mut wireguard = Table::new();
            wireguard["endpoint"] = value(wg.endpoint.to_string());
            wireguard["server_public_key"] = value(wg.server_public_key.to_string());
            if let Some(address) = &wg.address {
                wireguard["address"] = value(address.to_string());
            }
            if let Some(mtu) = wg.mtu {
                wireguard["mtu"] = value(i64::from(mtu));
            }
            transport["wireguard"] = Item::Table(wireguard);
        }
    }

    let mut doc = DocumentMut::new();
    doc["transport"] = Item::Table(transport);

    if let Some((ssh, host_key)) = &new.home {
        let mut home = Table::new();
        home["ssh"] = value(ssh.to_string());
        if let Some(host_key) = host_key {
            home["host_key"] = value(host_key.to_string());
        }
        doc["home"] = Item::Table(home);
    }
    if !new.hosts.is_empty() {
        let mut hosts = Table::new();
        for (lan, tunnel) in &new.hosts {
            hosts[lan.to_string().as_str()] = value(tunnel.to_string());
        }
        doc["hosts"] = Item::Table(hosts);
    }
    doc
}

fn list(store: &Store) -> Result<(), Error> {
    let profiles = store.load_profiles()?;
    if profiles.is_empty() {
        println!("No profiles yet. Create one with `gitaway profile add`.");
        return Ok(());
    }
    let default = store.load_global()?.default_profile;
    for (name, profile) in &profiles {
        let marker = if default.as_ref() == Some(name) {
            "*"
        } else {
            " "
        };
        let Transport::WireGuard(wg) = &profile.transport;
        println!("{marker} {name}  (wireguard via {})", wg.endpoint);
    }
    Ok(())
}

fn show(store: &Store, name: &ProfileName) -> Result<(), Error> {
    // Loading first validates the file and gives a clear error if it's missing.
    let profile = store.load_profile(name)?;
    let path = store.dir().profile_file(name);
    let text = std::fs::read_to_string(&path).map_err(|source| StoreError::Io {
        path: path.clone(),
        source,
    })?;
    println!("# {}\n{}", path.display(), text.trim_end());
    match profile.home.as_ref().map(|home| &home.host_key) {
        Some(Some(key)) => println!("\n# home host key fingerprint: {}", key.fingerprint()),
        Some(None) => println!("\n# home host key: not pinned yet; `gitaway check {name}` pins it"),
        None => {}
    }
    let keys = KeyStore::from_env(store.dir().clone())?;
    match keys.load(name)? {
        Some(secrets) => {
            println!("# device public key: {}", secrets.key.public_key());
            if secrets.preshared_key.is_some() {
                println!("# preshared key: yes (kept with the device key)");
            }
        }
        None => println!("# no device key: create one with `gitaway key rotate {name}`"),
    }
    Ok(())
}

fn remove(store: &Store, name: &ProfileName, yes: bool) -> Result<(), Error> {
    if !store.profile_exists(name) {
        return Err(StoreError::NotFound(name.clone()).into());
    }
    if !yes {
        if !prompt::interactive() {
            return Err(Error::msg(
                "pass --yes to remove a profile when not running in a terminal",
            ));
        }
        if !prompt::confirm(&format!("Remove profile `{name}` and its device key?"))? {
            println!("Nothing removed.");
            return Ok(());
        }
    }
    // The key goes first: if that fails, the profile is still there to retry with.
    let had_key = KeyStore::from_env(store.dir().clone())?.delete(name)?;
    store.remove_profile(name)?;
    let what = if had_key {
        format!("profile `{name}` and its device key")
    } else {
        format!("profile `{name}`")
    };
    if store.load_global()?.default_profile.as_ref() == Some(name) {
        store.update_global(|doc| {
            doc.remove("default_profile");
            Ok(())
        })?;
        println!("Removed {what}. It was the default, so there's no default now.");
    } else {
        println!("Removed {what}.");
    }
    Ok(())
}

/// Makes `name` the default profile.
pub(crate) fn set_default(store: &Store, name: &ProfileName) -> Result<(), Error> {
    if !store.profile_exists(name) {
        return Err(StoreError::NotFound(name.clone()).into());
    }
    store.update_global(|doc| {
        doc["default_profile"] = value(name.as_str());
        Ok(())
    })?;
    println!("`{name}` is now the default profile.");
    Ok(())
}

fn set(store: &Store, name: &ProfileName, key: &str, raw_value: &str) -> Result<(), Error> {
    let path = Key::parse(key).map_err(|e| Error::msg(format!("invalid key `{key}`: {e}")))?;
    let new_value = parse_value(raw_value);
    store.update_profile(name, |doc| set_path(doc, &path, new_value))?;
    println!("Updated `{key}` in profile `{name}`.");
    Ok(())
}

/// Sets a dotted key path in the document, creating tables as needed.
fn set_path(doc: &mut DocumentMut, path: &[Key], new_value: Value) -> Result<(), String> {
    let (last, parents) = path.split_last().ok_or("the key is empty")?;
    let mut table = doc.as_table_mut();
    for part in parents {
        let entry = table.entry(part.get()).or_insert_with(|| {
            let mut child = Table::new();
            child.set_implicit(true);
            Item::Table(child)
        });
        table = entry.as_table_mut().ok_or_else(|| {
            format!(
                "`{}` isn't a table, so it can't contain other settings",
                part.get()
            )
        })?;
    }
    table[last.get()] = Item::Value(new_value);
    Ok(())
}

/// Reads a value as TOML if it is one (`1420`, `true`, `"text"`), otherwise
/// as a plain string, so `192.0.2.10:22` needs no quotes.
fn parse_value(raw: &str) -> Value {
    match raw.parse::<Value>() {
        Ok(mut parsed) => {
            parsed.decor_mut().clear();
            parsed
        }
        Err(_) => Value::from(raw),
    }
}

fn parse_name(name: &str) -> Result<ProfileName, Error> {
    name.parse().map_err(|e| Error::msg(format!("{e}")))
}

fn parse_endpoint(s: &str) -> Result<HostPort, String> {
    let endpoint: HostPort = s.parse().map_err(|e| format!("{e}"))?;
    if endpoint.port.is_none() {
        return Err(format!("`{s}` needs a port, such as {s}:51820"));
    }
    Ok(endpoint)
}

/// `LAN=TUNNEL`, both host or both host:port.
fn parse_host_entry(entry: &str) -> Result<(HostPort, HostPort), String> {
    let (lan, tunnel) = entry.split_once('=').ok_or("expected LAN=TUNNEL")?;
    let lan: HostPort = lan.trim().parse().map_err(|e| format!("{e}"))?;
    let tunnel: HostPort = tunnel.trim().parse().map_err(|e| format!("{e}"))?;
    if lan.port.is_some() != tunnel.port.is_some() {
        return Err("give both sides a port, or neither".to_owned());
    }
    Ok((lan, tunnel))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn values_are_typed_when_they_are_toml() {
        assert_eq!(parse_value("1420").as_integer(), Some(1420));
        assert_eq!(parse_value("true").as_bool(), Some(true));
        assert_eq!(parse_value("\"quoted\"").as_str(), Some("quoted"));
        assert_eq!(parse_value("192.0.2.10:22").as_str(), Some("192.0.2.10:22"));
        assert_eq!(
            parse_value("ssh-ed25519 AAAA x").as_str(),
            Some("ssh-ed25519 AAAA x")
        );
    }

    #[test]
    fn set_path_creates_tables_and_refuses_non_tables() {
        let mut doc: DocumentMut = "[home]\nssh = \"192.0.2.10\"\n".parse().unwrap();
        let path = Key::parse("hosts.\"192.0.2.10\"").unwrap();
        set_path(&mut doc, &path, Value::from("10.0.0.1")).unwrap();
        assert_eq!(doc["hosts"]["192.0.2.10"].as_str(), Some("10.0.0.1"));

        let through_string = Key::parse("home.ssh.port").unwrap();
        assert!(set_path(&mut doc, &through_string, Value::from(22)).is_err());
    }

    #[test]
    fn host_entries() {
        let (lan, tunnel) = parse_host_entry("192.0.2.10=10.0.0.1").unwrap();
        assert_eq!(
            (lan.to_string(), tunnel.to_string()),
            ("192.0.2.10".into(), "10.0.0.1".into())
        );
        assert!(parse_host_entry("192.0.2.10:22=10.0.0.1:22").is_ok());
        for bad in [
            "192.0.2.10",
            "192.0.2.10:22=10.0.0.1",
            "=10.0.0.1",
            "bad host=10.0.0.1",
        ] {
            assert!(parse_host_entry(bad).is_err(), "{bad}");
        }
    }
}