Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-ssh/tests/anonymous.rs

//! **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");
    }
}