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:
commit
8e5ca787f4
7 changed files with 128 additions and 85 deletions
11
doc/manual/rl-next/git-lfs-ssh.md
Normal file
11
doc/manual/rl-next/git-lfs-ssh.md
Normal 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.
|
||||||
|
|
@ -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",
|
std::ostringstream hostnameAndUser;
|
||||||
.args = {url.authority->to_string(), "git-lfs-authenticate", url.path, "download"},
|
if (url.authority->user)
|
||||||
});
|
hostnameAndUser << *url.authority->user << "@";
|
||||||
|
hostnameAndUser << url.authority->host;
|
||||||
|
args.push_back(std::move(hostnameAndUser).str());
|
||||||
|
|
||||||
|
args.push_back("--");
|
||||||
|
args.push_back("git-lfs-authenticate");
|
||||||
|
args.push_back(url.path);
|
||||||
|
args.push_back("download");
|
||||||
|
|
||||||
|
auto [status, output] = runProgram({.program = "ssh", .args = args});
|
||||||
|
|
||||||
if (output.empty())
|
if (output.empty())
|
||||||
throw Error(
|
throw Error("git-lfs-authenticate: no output (cmd: 'ssh %s')", concatStringsSep(" ", args));
|
||||||
"git-lfs-authenticate: no output (cmd: ssh %s git-lfs-authenticate %s download)",
|
|
||||||
url.authority.value_or(ParsedURL::Authority{}).to_string(),
|
|
||||||
url.path);
|
|
||||||
|
|
||||||
auto queryResp = nlohmann::json::parse(output);
|
auto queryResp = nlohmann::json::parse(output);
|
||||||
if (!queryResp.contains("header"))
|
auto headerIt = queryResp.find("header");
|
||||||
|
if (headerIt == queryResp.end())
|
||||||
throw Error("no header in git-lfs-authenticate response");
|
throw Error("no header in git-lfs-authenticate response");
|
||||||
if (!queryResp["header"].contains("Authorization"))
|
auto authIt = headerIt->find("Authorization");
|
||||||
|
if (authIt == headerIt->end())
|
||||||
throw Error("no Authorization in git-lfs-authenticate response");
|
throw Error("no Authorization in git-lfs-authenticate response");
|
||||||
|
|
||||||
return queryResp["header"]["Authorization"].get<std::string>();
|
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);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
namespace nix {
|
namespace nix {
|
||||||
|
|
||||||
|
Strings getNixSshOpts();
|
||||||
|
|
||||||
class SSHMaster
|
class SSHMaster
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
""")
|
""")
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
""")
|
|
||||||
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue