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,
};
fn public_repo() -> RepoRef {
RepoRef::new("nordisk", "open").expect("legal")
}
fn private_repo() -> RepoRef {
RepoRef::new("nordisk", "shut").expect("legal")
}
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
}
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))
}
#[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();
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");
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"
);
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"
);
}
}
#[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"
);
let session = common::authenticate(closed.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("a registered key must still authenticate with anonymous access off");
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"
);
}
#[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");
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"
);
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));
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"
);
}
#[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");
let absent = exec(&session, COMMAND).await.expect("exec");
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"
);
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");
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),
}
);
}
#[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",
"gunnar-control",
"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:?}"
);
}
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:?}"),
}
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),
}
);
}
#[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;
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");
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),
}
);
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),
}
);
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"));
}
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"
);
}
#[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"
);
}
#[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"
);
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}"
);
assert!(err
.client_message()
.expect("a git client is listening")
.starts_with("repository not found, or you do not have access:"));
}
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");
}
}