gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
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
//! Shared scaffolding for the SSH integration tests.
//!
//! Everything here is a *fixture*, not a fake of the thing under test. The
//! server, the parser, the sandbox, the dispatch, the host-key path **and now
//! the identity table and repository catalog** are all the real ones:
//! `gunnar-core`'s, over `gunnar-store`'s in-memory backend. The only double
//! left is [`BannerTransport`], because `gunnar-wire` is not written yet
//! (the two lookup seams and their doubles are deleted).
//!
//! That is a real gain in what these tests prove. A key now has to be
//! registered with `AuthTable::register_key` from an `authorized_keys` line and
//! then be found again by the fingerprint the *russh* key produces. That is the
//! the `ssh-key` integration, across two semver-incompatible copies, rather
//! than a `BTreeMap` lookup the test itself populated.

#![allow(dead_code)]

use std::sync::Arc;
use std::time::Duration;

use gunnar_core::model::{Grant, Level, PrincipalKind, Visibility};
use gunnar_core::{AuthTable, ControlStore, NewRepo, RepoCatalog, RepoRef};
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, Control, Fingerprint, GunnarSsh, Principal, SshConfig};
use russh::keys::ssh_key::{Algorithm, PrivateKey};
use russh::keys::PrivateKeyWithHashAlg;

/// Mint a throwaway ed25519 key from the OS generator the host key is minted
/// from (`gunnar_core::os_rng`).
pub fn mint_key() -> PrivateKey {
    PrivateKey::random(&mut gunnar_core::os_rng(), Algorithm::Ed25519).expect("mint an ed25519 key")
}

/// The SHA-256 fingerprint of a private key's public half, computed the way the
/// listener computes it: over the wire blob of *russh's* key type.
pub fn fingerprint_of(key: &PrivateKey) -> Fingerprint {
    gunnar_ssh::fingerprint_of_key(key.public_key()).expect("a freshly minted key re-encodes")
}

/// The `authorized_keys` line for a key, as an operator would paste it.
///
/// Written by russh's `ssh-key 0.7.0-rc.10` and parsed by `gunnar-core`'s
/// `ssh-key 0.6.7`. That crossing is the point, not an accident.
pub fn authorized_keys_line(key: &PrivateKey) -> String {
    key.public_key()
        .to_openssh()
        .expect("encode an authorized_keys line")
}

/// A principal for tests.
pub fn principal(id: &str) -> Principal {
    Principal::new(id, id, PrincipalKind::User).expect("a legal principal id")
}

/// `nordisk/osm-katana`, the repository every test uses.
pub fn the_repo() -> RepoRef {
    RepoRef::new("nordisk", "osm-katana").expect("a legitimate path")
}

/// A real control plane over an in-memory control store, kept reachable so a
/// test can corrupt it on purpose.
pub struct Plane {
    pub control: Control,
    pub store: Arc<dyn ControlStore>,
}

impl Plane {
    /// An empty control plane: no principals, no keys, no repositories.
    pub fn empty() -> Self {
        let store = gunnar_core::memory_control_store().expect("control store");
        Plane {
            control: Control::open(Arc::clone(&store)).expect("control plane"),
            store,
        }
    }

    /// Point one control document at bytes that are not a document.
    ///
    /// Through the real compare-and-swap, with the document's current version
    /// as the expectation, so this is a *write another writer could have made*
    /// and not a back door into the store.
    fn corrupt(&self, name: &str, junk: &[u8]) {
        let id = self.store.put_blob(junk).expect("store the junk");
        let current = self.store.read_doc(name).expect("read the document");
        assert!(
            self.store
                .commit_doc(name, current, id)
                .expect("commit the junk"),
            "the compare-and-swap refused; the fixture did not manage to corrupt {name}"
        );
    }

    pub fn auth(&self) -> &AuthTable {
        self.control.auth()
    }

    pub fn catalog(&self) -> &RepoCatalog {
        self.control.catalog()
    }

    /// Register [`the_repo`] in the catalog.
    pub fn with_the_repo(self) -> Self {
        let path = the_repo();
        self.catalog()
            .create_namespace(path.namespace())
            .expect("create namespace");
        self.catalog()
            .create(NewRepo::new(path).visibility(Visibility::Private))
            .expect("create repository");
        self
    }

    /// Add a principal, with no keys and no grants.
    pub fn with_principal(self, id: &str) -> Self {
        self.auth().add_principal(principal(id)).expect("principal");
        self
    }

    /// Register `key` to an existing principal, from its `authorized_keys`
    /// line, the same door an operator or `Access.RegisterKey` uses.
    pub fn with_key(self, id: &str, key: &PrivateKey) -> Self {
        self.auth()
            .register_key(id, &authorized_keys_line(key), None)
            .expect("register key");
        self
    }

    /// Grant `id` `level` on [`the_repo`].
    pub fn with_grant(self, id: &str, level: Access) -> Self {
        self.auth()
            .grant(Grant::repo(id, the_repo(), level))
            .expect("grant");
        self
    }

    /// Grant `id` `level` on a whole namespace.
    pub fn with_namespace_grant(self, id: &str, namespace: &str, level: Level) -> Self {
        self.auth()
            .grant(Grant::namespace(id, namespace, level))
            .expect("grant");
        self
    }

    /// **Break the catalog on purpose**, by pointing its document at something
    /// that is not a catalog.
    ///
    /// This is the red half of [`gunnar_ssh::DispatchError::Store`]: without a
    /// way to make a lookup *fail*, there is no way to see that a storage fault
    /// is reported as a fault rather than as a permission denial.
    pub fn corrupt_the_catalog(&self) {
        self.corrupt(self.catalog().ref_name(), b"not a catalog");
    }

    /// Break the identity table the same way.
    pub fn corrupt_the_auth_table(&self) {
        self.corrupt(self.auth().ref_name(), b"not an identity table");
    }
}

/// The ordinary case: one repository, one principal, one registered key, one
/// grant at `level`.
pub fn plane_with_key(key: &PrivateKey, level: Access) -> Plane {
    Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_key("principal-1", key)
        .with_grant("principal-1", level)
}

/// A server with a bound ephemeral loopback port and the banner transport.
pub struct Fixture {
    pub server: gunnar_ssh::BoundServer,
    pub transport: Arc<BannerTransport>,
}

/// Start a server on an ephemeral loopback port over `control`.
pub async fn start(control: Control, grace: Option<Duration>) -> Fixture {
    start_with_transport(control, grace, BannerTransport::new()).await
}

/// The same, with a transport double chosen by the caller.
pub async fn start_with_transport(
    control: Control,
    grace: Option<Duration>,
    transport: BannerTransport,
) -> Fixture {
    let mut config = SshConfig::ephemeral_loopback();
    if let Some(grace) = grace {
        config.exec_grace = Some(grace);
    }
    start_with_config(control, config, transport).await
}

/// The same again, with the whole [`SshConfig`] chosen by the caller. A test of
/// the admission limits needs that, because the shipped defaults
/// (256 / 32) cannot be reached from a test without opening 257 real SSH
/// channels.
pub async fn start_with_config(
    control: Control,
    config: SshConfig,
    transport: BannerTransport,
) -> Fixture {
    let transport = Arc::new(transport);
    let server = GunnarSsh::new(config, control, transport.clone())
        .bind(mint_key())
        .await
        .expect("bind an ephemeral loopback port");
    Fixture { server, transport }
}

/// The ordinary case, started: one key, one principal, one repository, `level`
/// access.
pub async fn start_with_key(key: &PrivateKey, level: Access) -> Fixture {
    start(plane_with_key(key, level).control, None).await
}

/// The same, watching a readiness flag the caller owns (`tests/readiness.rs`).
///
/// The flag is the seam `gunnar-server` publishes the boot window through; a
/// test owns one directly so the window can be held open for as long as an
/// assertion needs instead of being raced against a real disk test.
pub async fn start_with_readiness(
    key: &PrivateKey,
    level: Access,
    ready: Arc<std::sync::atomic::AtomicBool>,
) -> Fixture {
    start_with_readiness_and_config(key, level, ready, SshConfig::ephemeral_loopback()).await
}

/// [`start_with_readiness`] with the whole [`SshConfig`] chosen by the caller.
pub async fn start_with_readiness_and_config(
    key: &PrivateKey,
    level: Access,
    ready: Arc<std::sync::atomic::AtomicBool>,
    config: SshConfig,
) -> Fixture {
    let transport = Arc::new(BannerTransport::new());
    let server = GunnarSsh::new(
        config,
        plane_with_key(key, level).control,
        transport.clone(),
    )
    .with_readiness(ready)
    .bind(mint_key())
    .await
    .expect("bind an ephemeral loopback port");
    Fixture { server, transport }
}

/// A russh client that trusts whatever host key it is given. Host-key
/// verification is the *client's* job and lives in `gunnar-client`; asserting it
/// here would be asserting something this crate does not do.
pub struct TrustingClient;

impl russh::client::Handler for TrustingClient {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        _server_public_key: &russh::keys::PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        Ok(true)
    }
}

/// Connect and authenticate with `key`. `Ok(false)` means the server refused
/// the key; `Err` means the connection itself failed.
pub async fn authenticate(
    addr: std::net::SocketAddr,
    key: &PrivateKey,
    user: &str,
) -> Result<Option<russh::client::Handle<TrustingClient>>, russh::Error> {
    let config = Arc::new(russh::client::Config {
        inactivity_timeout: Some(Duration::from_secs(15)),
        ..Default::default()
    });
    let mut session = russh::client::connect(config, addr, TrustingClient).await?;
    let result = session
        .authenticate_publickey(
            user,
            PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
        )
        .await?;
    Ok(result.success().then_some(session))
}

/// Offer `keys` in order on ONE connection, and report how far the server let
/// the client get.
///
/// `Ok(n)` is the number of offers the server answered — with a refusal, or, on
/// the last one, possibly with an acceptance. `Err` at offer `n` means the
/// server dropped the connection rather than answering, which is what a
/// per-connection attempt limit looks like from the client's side.
///
/// Returns the acceptance too, so a caller can tell "refused every key and
/// stayed up" from "accepted one".
pub async fn offer_keys(
    addr: std::net::SocketAddr,
    keys: &[PrivateKey],
    user: &str,
) -> (usize, bool, Option<russh::Error>) {
    let config = Arc::new(russh::client::Config {
        inactivity_timeout: Some(Duration::from_secs(15)),
        ..Default::default()
    });
    let mut session = match russh::client::connect(config, addr, TrustingClient).await {
        Ok(session) => session,
        Err(err) => return (0, false, Some(err)),
    };
    let mut answered = 0usize;
    for key in keys {
        match session
            .authenticate_publickey(
                user,
                PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
            )
            .await
        {
            Ok(result) => {
                answered += 1;
                if result.success() {
                    return (answered, true, None);
                }
            }
            Err(err) => return (answered, false, Some(err)),
        }
    }
    (answered, false, None)
}

/// What the server answered an exec request with.
#[derive(Debug, PartialEq, Eq)]
pub enum ExecOutcome {
    /// `channel_failure`: the request was refused outright.
    Refused,
    /// `channel_success`, plus whatever the server then wrote and the exit
    /// status it reported.
    Accepted {
        /// Everything received on the data band.
        stdout: String,
        /// The reported exit status, if one arrived.
        exit_status: Option<u32>,
    },
}

/// How the server answered a channel `env` request.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum EnvOutcome {
    /// `channel_success`. The variable was allowlisted and kept.
    Accepted,
    /// `channel_failure`: refused, which is the answer for everything that is
    /// not on [`gunnar_ssh::ENV_ALLOWLIST`].
    Refused,
    /// Nothing came back at all before the channel ended. A *distinct* outcome
    /// from `Refused`, because a server that silently ignores an env request
    /// leaves a real client waiting, and "refused" and "ignored" must not be
    /// spelled the same way in a test.
    Unanswered,
}

/// Open a channel, set `variables` on it, exec `command`, and read it out.
///
/// Returns what the server answered each env request with, in order, alongside
/// the exec outcome, so a test can assert on the *refusal* as well as on what
/// the request did afterwards.
pub async fn exec_with_env(
    session: &russh::client::Handle<TrustingClient>,
    variables: &[(&str, &str)],
    command: &str,
) -> Result<(Vec<EnvOutcome>, ExecOutcome), russh::Error> {
    let mut channel = session.channel_open_session().await?;

    let mut env_outcomes = Vec::new();
    for (name, value) in variables {
        channel.set_env(true, *name, *value).await?;
        let mut answer = EnvOutcome::Unanswered;
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => {
                    answer = EnvOutcome::Accepted;
                    break;
                }
                russh::ChannelMsg::Failure => {
                    answer = EnvOutcome::Refused;
                    break;
                }
                _ => {}
            }
        }
        env_outcomes.push(answer);
    }

    channel.exec(true, command).await?;
    Ok((env_outcomes, drain(channel).await))
}

/// Open a channel, exec `command`, and read the channel out.
pub async fn exec(
    session: &russh::client::Handle<TrustingClient>,
    command: &str,
) -> Result<ExecOutcome, russh::Error> {
    let channel = session.channel_open_session().await?;
    channel.exec(true, command).await?;
    Ok(drain(channel).await)
}

/// Read a channel to its end, collecting the data band and the exit status.
async fn drain(mut channel: russh::Channel<russh::client::Msg>) -> ExecOutcome {
    let mut stdout = Vec::new();
    let mut exit_status = None;
    let mut accepted = None;
    while let Some(msg) = channel.wait().await {
        match msg {
            russh::ChannelMsg::Success => accepted = Some(true),
            russh::ChannelMsg::Failure => {
                accepted = Some(false);
                break;
            }
            russh::ChannelMsg::Data { ref data } => stdout.extend_from_slice(data),
            // stderr is appended to the same text: no verb but
            // `git-lfs-authenticate` writes any, and its test reads the words.
            russh::ChannelMsg::ExtendedData { ref data, .. } => stdout.extend_from_slice(data),
            russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
            _ => {}
        }
    }

    match accepted {
        Some(true) => ExecOutcome::Accepted {
            stdout: String::from_utf8_lossy(&stdout).into_owned(),
            exit_status,
        },
        _ => ExecOutcome::Refused,
    }
}