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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
//! **Anonymous read for public repositories, over a real SSH handshake**
//! (the ACL model).
//!
//! `tests/sandbox.rs` drives [`Dispatch::authorise`] directly, which is the
//! right level for the *decision*. This file is about the thing a decision
//! cannot be tested for: whether an unauthenticated caller can get a session at
//! all, and whether it is the session it is supposed to have. Every test here
//! completes a real russh handshake against a real listener, so the answer to
//! "how does an anonymous caller arrive" is measured rather than designed.
//!
//! The mechanism, stated once: `auth_none` is accepted for the single username
//! [`ANONYMOUS_SSH_USER`], and refused for every other. See
//! `gunnar_ssh::server`'s module docs for why the two alternatives — accepting
//! `none` for everybody, and a published well-known key — were rejected.
//!
//! **Both directions, everywhere.** An anonymous path that refused everything
//! would pass every "a stranger may not" test in this file and serve nobody;
//! one that allowed everything would pass every "a stranger may" test. Each is
//! written next to its opposite for that reason.

mod common;

use gunnar_core::model::{Grant, Level, Visibility};
use gunnar_core::{NewRepo, RepoRef, ANONYMOUS_PRINCIPAL_ID};
use gunnar_ssh::{Access, SshConfig, ANONYMOUS_SSH_USER};

use common::{
    authorized_keys_line, exec, mint_key, principal, start_with_config, ExecOutcome, Fixture, Plane,
};

/// The public repository every test here reads, and the private one it must
/// not.
fn public_repo() -> RepoRef {
    RepoRef::new("nordisk", "open").expect("legal")
}

fn private_repo() -> RepoRef {
    RepoRef::new("nordisk", "shut").expect("legal")
}

/// A control plane with `nordisk/open` public and `nordisk/shut` private, plus
/// one registered principal holding `level` on **both** — the authenticated
/// caller whose behaviour must not change.
fn plane(level: Access, key: &russh::keys::ssh_key::PrivateKey) -> Plane {
    let plane = Plane::empty().with_principal("principal-1");
    plane
        .catalog()
        .create_namespace("nordisk")
        .expect("namespace");
    plane
        .catalog()
        .create(NewRepo::new(public_repo()).visibility(Visibility::Public))
        .expect("create the public repository");
    plane
        .catalog()
        .create(NewRepo::new(private_repo()).visibility(Visibility::Private))
        .expect("create the private repository");
    plane
        .auth()
        .register_key("principal-1", &authorized_keys_line(key), None)
        .expect("register");
    for path in [public_repo(), private_repo()] {
        plane
            .auth()
            .grant(Grant::repo("principal-1", path, level))
            .expect("grant");
    }
    plane
}

async fn start(plane: Plane, allow_anonymous: bool) -> Fixture {
    let mut config = SshConfig::ephemeral_loopback();
    config.allow_anonymous = allow_anonymous;
    start_with_config(
        plane.control,
        config,
        gunnar_ssh::seam::BannerTransport::new(),
    )
    .await
}

/// Complete a handshake with **no key at all**, as `username`.
///
/// `Ok(None)` means the server refused; `Err` means the connection failed.
async fn authenticate_anonymously(
    addr: std::net::SocketAddr,
    username: &str,
) -> Result<Option<russh::client::Handle<common::TrustingClient>>, russh::Error> {
    let config = std::sync::Arc::new(russh::client::Config {
        inactivity_timeout: Some(std::time::Duration::from_secs(15)),
        ..Default::default()
    });
    let mut session = russh::client::connect(config, addr, common::TrustingClient).await?;
    let result = session.authenticate_none(username).await?;
    Ok(result.success().then_some(session))
}

// ───────────────────────────────────────────────────────────────────────────
// Arriving
// ───────────────────────────────────────────────────────────────────────────

/// **The prize, and its opposite.** No key, username `anonymous` → a session
/// serving the public repository. No key, any other username → no session.
#[tokio::test]
async fn a_keyless_caller_gets_in_as_anonymous_and_only_under_that_username() {
    let key = mint_key();
    let fx = start(plane(Access::Write, &key), true).await;
    let addr = fx.server.local_addr();

    // Green: the reserved username, with nothing offered.
    let session = authenticate_anonymously(addr, ANONYMOUS_SSH_USER)
        .await
        .expect("connect")
        .expect("the anonymous username must be admitted with no key");
    let outcome = exec(&session, "git-upload-pack 'nordisk/open.git'")
        .await
        .expect("exec");
    // Applied output, not "it was accepted": the banner names the service, the
    // repository and the PRINCIPAL the request was served as. A session that
    // got in as somebody else would be green on acceptance alone.
    assert_eq!(
        outcome,
        ExecOutcome::Accepted {
            stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
            exit_status: Some(0),
        },
        "an anonymous clone of a public repository must reach the wire, as the anonymous \
         principal"
    );

    // Red: every other username. `git` is what a git URL carries by default, so
    // this is the case that would break every existing client if `none` were
    // accepted blanket-fashion.
    for username in ["git", "", "root", "principal-1", "Anonymous", "anonymous "] {
        assert!(
            authenticate_anonymously(addr, username)
                .await
                .expect("connect")
                .is_none(),
            "the `none` method was accepted for username {username:?}; it is reserved for \
             {ANONYMOUS_SSH_USER:?} exactly, and accepting it for `git` would silently \
             downgrade every authenticated client"
        );
    }
}

/// The listener switch closes the door, and the same server with it open is the
/// red half.
#[tokio::test]
async fn allow_anonymous_false_refuses_even_a_public_repository() {
    let key = mint_key();

    let closed = start(plane(Access::Write, &key), false).await;
    assert!(
        authenticate_anonymously(closed.server.local_addr(), ANONYMOUS_SSH_USER)
            .await
            .expect("connect")
            .is_none(),
        "`allow_anonymous: false` must refuse the anonymous session at the listener"
    );

    // …and the registered key still works on that same server, so the switch
    // closed one door rather than the building.
    let session = common::authenticate(closed.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("a registered key must still authenticate with anonymous access off");
    // The banner, not `Accepted { .. }` — a denial is `Accepted` too, because a
    // refused git command is answered in the git protocol. See the note in
    // `an_anonymous_session_reaches_no_command_but_the_two_git_verbs`.
    assert_eq!(
        exec(&session, "git-upload-pack 'nordisk/open.git'")
            .await
            .expect("exec"),
        ExecOutcome::Accepted {
            stdout: "git-upload-pack nordisk/open principal-1\n".to_string(),
            exit_status: Some(0),
        }
    );

    let open = start(plane(Access::Write, &key), true).await;
    assert!(
        authenticate_anonymously(open.server.local_addr(), ANONYMOUS_SSH_USER)
            .await
            .expect("connect")
            .is_some(),
        "the same configuration with the switch on must admit the anonymous session, or the \
         assertion above is about a server that refuses everybody"
    );
}

// ───────────────────────────────────────────────────────────────────────────
// What an anonymous session may do — and may not
// ───────────────────────────────────────────────────────────────────────────

/// **Read only, and only upload-pack.** A push is refused, and the refusal is
/// the ordinary access denial rather than a crash or a channel failure.
#[tokio::test]
async fn an_anonymous_session_may_fetch_a_public_repository_and_may_not_push_to_it() {
    let key = mint_key();
    let fx = start(plane(Access::Write, &key), true).await;
    let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
        .await
        .expect("connect")
        .expect("anonymous session");

    // Green — asserted on the banner and a ZERO exit status. `Accepted { .. }`
    // alone would not do: a denial is also `Accepted`, with `EXIT_DENIED`.
    assert_eq!(
        exec(&session, "git-upload-pack 'nordisk/open.git'")
            .await
            .expect("exec"),
        ExecOutcome::Accepted {
            stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
            exit_status: Some(0),
        },
        "anonymous fetch of a public repository must be served"
    );

    // Red — and it must be refused with the SAME string a missing repository
    // gets, so `git-receive-pack` cannot be used to probe what exists either.
    let ExecOutcome::Accepted {
        stdout,
        exit_status,
    } = exec(&session, "git-receive-pack 'nordisk/open.git'")
        .await
        .expect("exec")
    else {
        panic!("a denied git command is answered in the git protocol, not with a channel failure")
    };
    assert!(
        stdout.contains("ERR repository not found, or you do not have access"),
        "an anonymous PUSH must be refused: {stdout:?}"
    );
    assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));

    // The transport is the applied-output witness: it saw the fetch and did
    // NOT see the push. A dispatch that authorised the push and then failed
    // somewhere later would still be a server that let a stranger write.
    assert_eq!(
        fx.transport.served(),
        vec![format!(
            "git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}"
        )],
        "the wire was reached by something other than the one anonymous fetch"
    );
}

/// A private repository is refused to an anonymous caller **byte for byte** the
/// way a repository that does not exist is.
///
/// This is the whole of `Visibility::Private` from outside: a different answer
/// for "exists but you may not" hands a stranger a repository-name oracle, and
/// names are cheap to guess.
///
/// # Why the same path twice, and not two different names
///
/// The first draft of this test asked for `nordisk/shut` and `nordisk/ghost`
/// and compared the answers with the names blanked. It failed on the pkt-line
/// **length prefix** — `0046` against `0047` — because the two names are
/// different lengths. That is not a leak (the client chose the name and knows
/// how long it is), but it does mean a comparison across two names can never be
/// byte-for-byte, and blanking enough of the string to make it pass would blank
/// the thing being measured.
///
/// So: **one name, one server, two states.** The repository is asked for while
/// it does not exist, then created — private — and asked for again with the
/// identical command. Anything that differs is something a stranger can use.
#[tokio::test]
async fn a_private_repository_and_an_absent_one_are_indistinguishable_byte_for_byte() {
    let key = mint_key();
    let plane = plane(Access::Write, &key);
    let catalog = plane.catalog().clone();
    let fx = start(plane, true).await;
    let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
        .await
        .expect("connect")
        .expect("anonymous session");

    const COMMAND: &str = "git-upload-pack 'nordisk/hidden.git'";
    let hidden = RepoRef::new("nordisk", "hidden").expect("legal");

    // 1. It does not exist.
    let absent = exec(&session, COMMAND).await.expect("exec");

    // 2. It exists, and is private.
    catalog
        .create(NewRepo::new(hidden.clone()).visibility(Visibility::Private))
        .expect("create it private");
    let private = exec(&session, COMMAND).await.expect("exec");

    assert_eq!(
        private, absent,
        "a private repository answers differently from an absent one under the identical \
         command, which lets a stranger enumerate every private repository on this server one \
         guess at a time"
    );

    // …and both really are refusals, not two equal successes — the identity
    // value that would satisfy the comparison above without meaning anything.
    let ExecOutcome::Accepted {
        stdout,
        exit_status,
    } = &private
    else {
        panic!("expected a protocol answer, got a channel failure")
    };
    assert!(
        stdout.contains("ERR repository not found, or you do not have access"),
        "{stdout:?}"
    );
    assert_eq!(*exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
    assert!(fx.transport.served().is_empty(), "the wire was reached");

    // 3. **The red half of the whole test.** The same command, the same
    // session, the same repository — made public. If this did not change, the
    // two equal answers above would be equal because gunnar refuses everything.
    catalog
        .set_visibility(&hidden, Visibility::Public)
        .expect("publish it");
    let published = exec(&session, COMMAND).await.expect("exec");
    assert_ne!(
        published, private,
        "publishing the repository changed nothing an anonymous caller can see, so `visibility` \
         is still not being honoured"
    );
    assert_eq!(
        published,
        ExecOutcome::Accepted {
            stdout: format!("git-upload-pack nordisk/hidden {ANONYMOUS_PRINCIPAL_ID}\n"),
            exit_status: Some(0),
        }
    );
}

/// **The whole control surface, from an anonymous session.** No shell, no
/// subsystem, no arbitrary command — the same "no shell, ever" answer an
/// authenticated session gets, asserted from the one session that has no key
/// behind it.
///
/// gunnar's control plane is a separate listener on a separate port speaking
/// gRPC, and it is not reachable from an SSH channel at all; this is the SSH
/// half of "an anonymous caller cannot reach a mutation by any route". The
/// gRPC half is `gunnar-grpc`'s `tests/auth.rs` and `gunnar-cli`'s
/// `tests/anonymous_access.rs`.
#[tokio::test]
async fn an_anonymous_session_reaches_no_command_but_the_two_git_verbs() {
    let key = mint_key();
    let fx = start(plane(Access::Write, &key), true).await;
    let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
        .await
        .expect("connect")
        .expect("anonymous session");

    for command in [
        "bash",
        "sh -c 'gunnar repo create nordisk/mine'",
        "gunnar",
        "git",
        "scp -t /tmp",
        // The control plane is gRPC and not an exec verb; if one is ever added,
        // this line is what says an anonymous session must not have it.
        "gunnar-control",
        // The one control verb there is (2026-09-21, T17): server Admin only,
        // so an anonymous session is refused the channel like a shell.
        "gunnar-tunnel-address",
        "gunnar-tunnel-address 10.77.0.2/16",
        "",
    ] {
        assert_eq!(
            exec(&session, command).await.expect("exec"),
            ExecOutcome::Refused,
            "an anonymous session got something other than a channel failure for {command:?}"
        );
    }

    // `git archive --remote=` is a git verb gunnar knows the name of and
    // does not serve. It is refused BY NAME, in git's own protocol, so the
    // client prints the sentence rather than `exec request failed on channel
    // 0` (the site test, 2026-08-30) — still no archive, still nothing
    // served, and the sentence names the alternative.
    let outcome = exec(&session, "git-upload-archive 'nordisk/open.git'")
        .await
        .expect("exec");
    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert!(
                stdout.contains("ERR git-upload-archive: upload-archive is not served; clone and archive locally"),
                "the by-name refusal must carry its sentence, got {stdout:?}"
            );
            assert_ne!(exit_status, Some(0), "a refused verb must not exit 0");
        }
        other => panic!("upload-archive must be refused by name with a sentence, got {other:?}"),
    }

    // The identity-value guard for the loop: the one command that IS allowed
    // still is, on the same session. Without it, a server that refused every
    // exec would pass everything above.
    //
    // Asserted on the **banner**, not on `ExecOutcome::Accepted` — a *denied*
    // git command is also `Accepted`, because gunnar answers a denial in the
    // git protocol (`channel_success`, then an `ERR` pkt-line) so that stock
    // git prints the reason. `matches!(…, Accepted { .. })` here was green
    // under a deliberate break that refused every anonymous read; it is not a
    // guard, it is a coin toss.
    assert_eq!(
        exec(&session, "git-upload-pack 'nordisk/open.git'")
            .await
            .expect("exec"),
        ExecOutcome::Accepted {
            stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
            exit_status: Some(0),
        }
    );
}

// ───────────────────────────────────────────────────────────────────────────
// The authenticated caller is unaffected
// ───────────────────────────────────────────────────────────────────────────

/// **The regression guard.** A registered key with a `Read` grant behaves
/// exactly as it did before the anonymous path existed — it authenticates as
/// *itself*, it may fetch, and it may not push.
#[tokio::test]
async fn a_registered_read_grant_is_unaffected_by_the_anonymous_path() {
    let key = mint_key();
    let fx = start(plane(Access::Read, &key), true).await;

    // The username is still decorative for a key: `git`, `anything`, whatever.
    for username in ["git", "whatever"] {
        let session = common::authenticate(fx.server.local_addr(), &key, username)
            .await
            .expect("connect")
            .expect("a registered key authenticates regardless of the username");

        // It is served as ITSELF, not as anonymous. A publickey handler that
        // fell through to the anonymous branch would be green on "it cloned".
        assert_eq!(
            exec(&session, "git-upload-pack 'nordisk/open.git'")
                .await
                .expect("exec"),
            ExecOutcome::Accepted {
                stdout: "git-upload-pack nordisk/open principal-1\n".to_string(),
                exit_status: Some(0),
            }
        );

        // Its grant still reaches the PRIVATE repository, which the anonymous
        // principal cannot see at all.
        assert_eq!(
            exec(&session, "git-upload-pack 'nordisk/shut.git'")
                .await
                .expect("exec"),
            ExecOutcome::Accepted {
                stdout: "git-upload-pack nordisk/shut principal-1\n".to_string(),
                exit_status: Some(0),
            }
        );

        // …and Read is still not Write.
        let ExecOutcome::Accepted { stdout, .. } =
            exec(&session, "git-receive-pack 'nordisk/open.git'")
                .await
                .expect("exec")
        else {
            panic!("expected a protocol answer")
        };
        assert!(stdout.contains("ERR repository not found, or you do not have access"));
    }

    // An unregistered key is still refused, with the anonymous door open beside
    // it. "No key" is admitted; "a key nobody registered" is not, and the two
    // must not have collapsed into one another.
    assert!(
        common::authenticate(fx.server.local_addr(), &mint_key(), ANONYMOUS_SSH_USER)
            .await
            .expect("connect")
            .is_none(),
        "an unregistered key authenticated. Offering a key is a claim to be somebody, and it \
         must be refused even under the anonymous username"
    );
}

/// The counter an operator reads to answer *"how much of my traffic is
/// strangers"* — both halves of it.
#[tokio::test]
async fn anonymous_and_authenticated_connections_are_counted_apart() {
    let key = mint_key();
    let fx = start(plane(Access::Read, &key), true).await;
    let addr = fx.server.local_addr();

    let before = fx.server.metrics();
    assert_eq!(before.connections_anonymous, 0);

    let _anon = authenticate_anonymously(addr, ANONYMOUS_SSH_USER)
        .await
        .expect("connect")
        .expect("anonymous");
    let _keyed = common::authenticate(addr, &key, "git")
        .await
        .expect("connect")
        .expect("keyed");

    let after = fx.server.metrics();
    assert_eq!(
        (
            after.connections_anonymous - before.connections_anonymous,
            after.connections_accepted - before.connections_accepted,
        ),
        (1, 1),
        "one anonymous session and one keyed session must move one counter each; folding them \
         together would make `how much anonymous traffic is this` unanswerable"
    );
}

/// The dispatch answer for the anonymous principal, without a handshake — the
/// same question `sandbox.rs` asks for registered principals, so the two files
/// cover the same decision from both sides.
#[test]
fn the_dispatch_serves_anonymous_a_public_repo_and_refuses_it_everything_else() {
    let key = mint_key();
    let dispatch = gunnar_ssh::server::Dispatch::new(plane(Access::Write, &key).control);
    let anonymous = gunnar_core::Principal::anonymous();

    let request = dispatch
        .authorise(&anonymous, None, b"git-upload-pack 'nordisk/open.git'")
        .expect("anonymous may read a public repository");
    assert_eq!(request.repo.key(), "nordisk/open");
    assert_eq!(request.principal.id, ANONYMOUS_PRINCIPAL_ID);
    assert_eq!(
        request.access,
        Level::Read,
        "an anonymous request must be served at Read and never above it"
    );

    // `held` differs between these on purpose and is asserted, because it is
    // the clearest statement of what rule 0 gives anonymous: `read` on the
    // public repository and literally nothing on the private one. It is a
    // server-side field — `client_message()` does not carry it — so saying so
    // here leaks nothing to a client.
    for (raw, why, held_now) in [
        (
            &b"git-receive-pack 'nordisk/open.git'"[..],
            "a push to a public repository",
            "read",
        ),
        (
            b"git-upload-pack 'nordisk/shut.git'",
            "a private repository",
            "none",
        ),
        (
            b"git-receive-pack 'nordisk/shut.git'",
            "a push to a private repository",
            "none",
        ),
    ] {
        let err = dispatch
            .authorise(&anonymous, None, raw)
            .expect_err(&format!("anonymous must be refused {why}"));
        assert!(
            matches!(
                &err,
                gunnar_ssh::server::DispatchError::Denied { held, .. } if held == held_now
            ),
            "{why}: {err}"
        );
        // Whatever the server knows internally, the client is told one thing.
        assert!(err
            .client_message()
            .expect("a git client is listening")
            .starts_with("repository not found, or you do not have access:"));
    }

    // Whereas the registered principal, on the same dispatch, may do all of it.
    let who = principal("principal-1");
    for raw in [
        &b"git-upload-pack 'nordisk/open.git'"[..],
        b"git-receive-pack 'nordisk/open.git'",
        b"git-upload-pack 'nordisk/shut.git'",
        b"git-receive-pack 'nordisk/shut.git'",
    ] {
        dispatch
            .authorise(&who, None, raw)
            .expect("a Write grant covers all four");
    }
}