1
1
Fork 0
mirror of https://github.com/NixOS/nix.git synced 2025-11-09 12:06:01 +01:00

Merge pull request #13743 from kip93/fix/lfs-ssh

Fix Git LFS SSH issues
This commit is contained in:
Sergei Zimmerman 2025-08-13 21:50:41 +03:00 committed by GitHub
commit 8e5ca787f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 128 additions and 85 deletions

View file

@ -0,0 +1,11 @@
---
synopsis: "Fix Git LFS SSH issues"
prs: [13743]
issues: [13337]
---
Fixed some outstanding issues with Git LFS and SSH.
* Added support for `NIX_SSHOPTS`.
* Properly use the parsed port from URL.
* Better use of the response of `git-lfs-authenticate` to determine API endpoint when the API is not exposed on port 443.

View file

@ -5,6 +5,7 @@
#include "nix/util/url.hh" #include "nix/util/url.hh"
#include "nix/util/users.hh" #include "nix/util/users.hh"
#include "nix/util/hash.hh" #include "nix/util/hash.hh"
#include "nix/store/ssh.hh"
#include <git2/attr.h> #include <git2/attr.h>
#include <git2/config.h> #include <git2/config.h>
@ -15,10 +16,9 @@
namespace nix::lfs { namespace nix::lfs {
// if authHeader is "", downloadToSink assumes no auth is expected
static void downloadToSink( static void downloadToSink(
const std::string & url, const std::string & url,
const std::string & authHeader, const std::optional<std::string> & authHeader,
// FIXME: passing a StringSink is superfluous, we may as well // FIXME: passing a StringSink is superfluous, we may as well
// return a string. Or use an abstract Sink for streaming. // return a string. Or use an abstract Sink for streaming.
StringSink & sink, StringSink & sink,
@ -27,8 +27,8 @@ static void downloadToSink(
{ {
FileTransferRequest request(url); FileTransferRequest request(url);
Headers headers; Headers headers;
if (!authHeader.empty()) if (authHeader.has_value())
headers.push_back({"Authorization", authHeader}); headers.push_back({"Authorization", *authHeader});
request.headers = headers; request.headers = headers;
getFileTransfer()->download(std::move(request), sink); getFileTransfer()->download(std::move(request), sink);
@ -42,30 +42,53 @@ static void downloadToSink(
"hash mismatch while fetching %s: expected sha256:%s but got sha256:%s", url, sha256Expected, sha256Actual); "hash mismatch while fetching %s: expected sha256:%s but got sha256:%s", url, sha256Expected, sha256Actual);
} }
static std::string getLfsApiToken(const ParsedURL & url) namespace {
struct LfsApiInfo
{
std::string endpoint;
std::optional<std::string> authHeader;
};
} // namespace
static LfsApiInfo getLfsApi(const ParsedURL & url)
{ {
assert(url.authority.has_value()); assert(url.authority.has_value());
if (url.scheme == "ssh") {
auto args = getNixSshOpts();
// FIXME: Not entirely correct. if (url.authority->port)
auto [status, output] = runProgram( args.push_back(fmt("-p%d", *url.authority->port));
RunOptions{
.program = "ssh",
.args = {url.authority->to_string(), "git-lfs-authenticate", url.path, "download"},
});
if (output.empty()) std::ostringstream hostnameAndUser;
throw Error( if (url.authority->user)
"git-lfs-authenticate: no output (cmd: ssh %s git-lfs-authenticate %s download)", hostnameAndUser << *url.authority->user << "@";
url.authority.value_or(ParsedURL::Authority{}).to_string(), hostnameAndUser << url.authority->host;
url.path); args.push_back(std::move(hostnameAndUser).str());
auto queryResp = nlohmann::json::parse(output); args.push_back("--");
if (!queryResp.contains("header")) args.push_back("git-lfs-authenticate");
throw Error("no header in git-lfs-authenticate response"); args.push_back(url.path);
if (!queryResp["header"].contains("Authorization")) args.push_back("download");
throw Error("no Authorization in git-lfs-authenticate response");
return queryResp["header"]["Authorization"].get<std::string>(); auto [status, output] = runProgram({.program = "ssh", .args = args});
if (output.empty())
throw Error("git-lfs-authenticate: no output (cmd: 'ssh %s')", concatStringsSep(" ", args));
auto queryResp = nlohmann::json::parse(output);
auto headerIt = queryResp.find("header");
if (headerIt == queryResp.end())
throw Error("no header in git-lfs-authenticate response");
auto authIt = headerIt->find("Authorization");
if (authIt == headerIt->end())
throw Error("no Authorization in git-lfs-authenticate response");
return {queryResp.at("href").get<std::string>(), authIt->get<std::string>()};
}
return {url.to_string() + "/info/lfs", std::nullopt};
} }
typedef std::unique_ptr<git_config, Deleter<git_config_free>> GitConfig; typedef std::unique_ptr<git_config, Deleter<git_config_free>> GitConfig;
@ -181,13 +204,14 @@ static nlohmann::json pointerToPayload(const std::vector<Pointer> & items)
std::vector<nlohmann::json> Fetch::fetchUrls(const std::vector<Pointer> & pointers) const std::vector<nlohmann::json> Fetch::fetchUrls(const std::vector<Pointer> & pointers) const
{ {
ParsedURL httpUrl(url); auto api = lfs::getLfsApi(this->url);
httpUrl.scheme = url.scheme == "ssh" ? "https" : url.scheme; auto url = api.endpoint + "/objects/batch";
FileTransferRequest request(httpUrl.to_string() + "/info/lfs/objects/batch"); const auto & authHeader = api.authHeader;
FileTransferRequest request(url);
request.post = true; request.post = true;
Headers headers; Headers headers;
if (this->url.scheme == "ssh") if (authHeader.has_value())
headers.push_back({"Authorization", lfs::getLfsApiToken(this->url)}); headers.push_back({"Authorization", *authHeader});
headers.push_back({"Content-Type", "application/vnd.git-lfs+json"}); headers.push_back({"Content-Type", "application/vnd.git-lfs+json"});
headers.push_back({"Accept", "application/vnd.git-lfs+json"}); headers.push_back({"Accept", "application/vnd.git-lfs+json"});
request.headers = headers; request.headers = headers;
@ -260,11 +284,16 @@ void Fetch::fetch(
try { try {
std::string sha256 = obj.at("oid"); // oid is also the sha256 std::string sha256 = obj.at("oid"); // oid is also the sha256
std::string ourl = obj.at("actions").at("download").at("href"); std::string ourl = obj.at("actions").at("download").at("href");
std::string authHeader = ""; auto authHeader = [&]() -> std::optional<std::string> {
if (obj.at("actions").at("download").contains("header") const auto & download = obj.at("actions").at("download");
&& obj.at("actions").at("download").at("header").contains("Authorization")) { auto headerIt = download.find("header");
authHeader = obj["actions"]["download"]["header"]["Authorization"]; if (headerIt == download.end())
} return std::nullopt;
auto authIt = headerIt->find("Authorization");
if (authIt == headerIt->end())
return std::nullopt;
return *authIt;
}();
const uint64_t size = obj.at("size"); const uint64_t size = obj.at("size");
sizeCallback(size); sizeCallback(size);
downloadToSink(ourl, authHeader, sink, sha256, size); downloadToSink(ourl, authHeader, sink, sha256, size);

View file

@ -8,6 +8,8 @@
namespace nix { namespace nix {
Strings getNixSshOpts();
class SSHMaster class SSHMaster
{ {
private: private:

View file

@ -51,6 +51,18 @@ static void checkValidAuthority(const ParsedURL::Authority & authority)
} }
} }
Strings getNixSshOpts()
{
std::string sshOpts = getEnv("NIX_SSHOPTS").value_or("");
try {
return shellSplitString(sshOpts);
} catch (Error & e) {
e.addTrace({}, "while splitting NIX_SSHOPTS '%s'", sshOpts);
throw;
}
}
SSHMaster::SSHMaster( SSHMaster::SSHMaster(
const ParsedURL::Authority & authority, const ParsedURL::Authority & authority,
std::string_view keyFile, std::string_view keyFile,
@ -82,16 +94,8 @@ void SSHMaster::addCommonSSHOpts(Strings & args)
{ {
auto state(state_.lock()); auto state(state_.lock());
std::string sshOpts = getEnv("NIX_SSHOPTS").value_or(""); auto sshArgs = getNixSshOpts();
args.insert(args.end(), sshArgs.begin(), sshArgs.end());
try {
std::list<std::string> opts = shellSplitString(sshOpts);
for (auto & i : opts)
args.push_back(i);
} catch (Error & e) {
e.addTrace({}, "while splitting NIX_SSHOPTS '%s'", sshOpts);
throw;
}
if (!keyFile.empty()) if (!keyFile.empty())
args.insert(args.end(), {"-i", keyFile}); args.insert(args.end(), {"-i", keyFile});

View file

@ -224,5 +224,25 @@
""") """)
client.succeed(f"cmp {repo.path}/beeg {fetched_self_lfs}/beeg >&2") client.succeed(f"cmp {repo.path}/beeg {fetched_self_lfs}/beeg >&2")
with subtest("Ensure fetching with SSH generates the same output"):
client.succeed(f"{repo.git} push origin-ssh main >&2")
client.succeed("rm -rf ~/.cache/nix") # Avoid using the cached output of the http fetch
fetchGit_ssh_expr = f"""
builtins.fetchGit {{
url = "{repo.remote_ssh}";
rev = "{lfs_file_rev}";
ref = "main";
lfs = true;
}}
"""
fetched_ssh = client.succeed(f"""
nix eval --debug --impure --raw --expr '({fetchGit_ssh_expr}).outPath'
""")
assert fetched_ssh == fetched_lfs, \
f"fetching with ssh (store path {fetched_ssh}) yielded a different result than using http (store path {fetched_lfs})"
''; '';
} }

View file

@ -49,19 +49,15 @@ in
self.name = name self.name = name
self.path = "/tmp/repos/" + name self.path = "/tmp/repos/" + name
self.remote = "http://gitea:3000/test/" + name self.remote = "http://gitea:3000/test/" + name
self.remote_ssh = "ssh://gitea/root/" + name self.remote_ssh = "ssh://gitea:3001/test/" + name
self.git = f"git -C {self.path}" self.git = f"git -C {self.path}"
self.private = private self.private = private
self.create() self.create()
def create(self): def create(self):
# create ssh remote repo # create remote repo
gitea.succeed(f""" gitea.succeed(f"""
git init --bare -b main /root/{self.name} curl --fail -X POST http://{gitea_user}:{gitea_password}@gitea:3000/api/v1/user/repos \
""")
# create http remote repo
gitea.succeed(f"""
curl --fail -X POST http://{gitea_admin}:{gitea_admin_password}@gitea:3000/api/v1/user/repos \
-H 'Accept: application/json' -H 'Content-Type: application/json' \ -H 'Accept: application/json' -H 'Content-Type: application/json' \
-d {shlex.quote( f'{{"name":"{self.name}", "default_branch": "main", "private": {boolToJSON(self.private)}}}' )} -d {shlex.quote( f'{{"name":"{self.name}", "default_branch": "main", "private": {boolToJSON(self.private)}}}' )}
""") """)
@ -70,7 +66,7 @@ in
mkdir -p {self.path} \ mkdir -p {self.path} \
&& git init -b main {self.path} \ && git init -b main {self.path} \
&& {self.git} remote add origin {self.remote} \ && {self.git} remote add origin {self.remote} \
&& {self.git} remote add origin-ssh root@gitea:{self.name} && {self.git} remote add origin-ssh {self.remote_ssh}
""") """)
''; '';
}; };

View file

@ -35,28 +35,20 @@ in
server = { server = {
DOMAIN = "gitea"; DOMAIN = "gitea";
HTTP_PORT = 3000; HTTP_PORT = 3000;
SSH_PORT = 3001;
START_SSH_SERVER = true;
}; };
log.LEVEL = "Info"; log.LEVEL = "Info";
database.LOG_SQL = false; database.LOG_SQL = false;
}; };
services.openssh.enable = true; networking.firewall.allowedTCPPorts = [
networking.firewall.allowedTCPPorts = [ 3000 ]; 3000
3001
];
environment.systemPackages = [ environment.systemPackages = [
pkgs.git pkgs.git
pkgs.gitea pkgs.gitea
]; ];
users.users.root.openssh.authorizedKeys.keys = [ clientPublicKey ];
# TODO: remove this after updating to nixos-23.11
nixpkgs.pkgs = lib.mkForce (
import nixpkgs {
inherit system;
config.permittedInsecurePackages = [
"gitea-1.19.4"
];
}
);
}; };
client = client =
{ pkgs, ... }: { pkgs, ... }:
@ -67,38 +59,33 @@ in
]; ];
}; };
}; };
defaults =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.jq ];
};
setupScript = '' setupScript = ''
import shlex import shlex
gitea.wait_for_unit("gitea.service") gitea.wait_for_unit("gitea.service")
gitea_admin = "test" gitea_user = "test"
gitea_admin_password = "test123test" gitea_password = "test123test"
gitea.succeed(f""" gitea.succeed(f"""
gitea --version >&2 gitea --version >&2
su -l gitea -c 'GITEA_WORK_DIR=/var/lib/gitea gitea admin user create \ su -l gitea -c 'GITEA_WORK_DIR=/var/lib/gitea gitea admin user create \
--username {gitea_admin} --password {gitea_admin_password} --email test@client' --username {gitea_user} --password {gitea_password} --email test@client'
""") """)
client.wait_for_unit("multi-user.target") client.wait_for_unit("multi-user.target")
gitea.wait_for_open_port(3000) gitea.wait_for_open_port(3000)
gitea.wait_for_open_port(3001)
gitea_admin_token = gitea.succeed(f""" gitea.succeed(f"""
curl --fail -X POST http://{gitea_admin}:{gitea_admin_password}@gitea:3000/api/v1/users/test/tokens \ curl --fail -X POST http://{gitea_user}:{gitea_password}@gitea:3000/api/v1/user/keys \
-H 'Accept: application/json' -H 'Content-Type: application/json' \ -H 'Accept: application/json' -H 'Content-Type: application/json' \
-d {shlex.quote( '{"name":"token", "scopes":["all"]}' )} \ -d {shlex.quote( '{"title":"key", "key":"${clientPublicKey}", "read_only": false}' )} >&2
| jq -r '.sha1' """)
""").strip()
client.succeed(f""" client.succeed(f"""
echo "http://{gitea_admin}:{gitea_admin_password}@gitea:3000" >~/.git-credentials-admin echo "http://{gitea_user}:{gitea_password}@gitea:3000" >~/.git-credentials-admin
git config --global credential.helper 'store --file ~/.git-credentials-admin' git config --global credential.helper 'store --file ~/.git-credentials-admin'
git config --global user.email "test@client" git config --global user.email "test@client"
git config --global user.name "Test User" git config --global user.name "Test User"
@ -118,13 +105,7 @@ in
echo "Host gitea" >>~/.ssh/config echo "Host gitea" >>~/.ssh/config
echo " StrictHostKeyChecking no" >>~/.ssh/config echo " StrictHostKeyChecking no" >>~/.ssh/config
echo " UserKnownHostsFile /dev/null" >>~/.ssh/config echo " UserKnownHostsFile /dev/null" >>~/.ssh/config
echo " User root" >>~/.ssh/config echo " User gitea" >>~/.ssh/config
""") """)
# ensure ssh from client to gitea works
client.succeed("""
ssh root@gitea true
""")
''; '';
} }