diff --git a/CMakeLists.txt b/CMakeLists.txt index 8242d02..280f4db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ add_executable(sago_web_git src/archive.cpp src/repo_scanner.cpp src/util.cpp + src/cgi.cpp ${VIEWS_CPP}) target_include_directories(sago_web_git PRIVATE diff --git a/Dockerfile b/Dockerfile index c363485..be66f8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 \ libicu74 \ libgit2-1.7 \ + git \ && rm -rf /var/lib/apt/lists/* COPY --from=build /usr/local/lib/libcppcms.so* /usr/local/lib/ diff --git a/config.json b/config.json index 36ad587..2dab077 100644 --- a/config.json +++ b/config.json @@ -11,6 +11,7 @@ "root": "", "static_root": "./static", "cache_root": "", - "github_enterprise_hosts": "" + "github_enterprise_hosts": "", + "allow_clone": false } } diff --git a/src/application.cpp b/src/application.cpp index 4aa0ec1..ec1bf48 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -1,5 +1,6 @@ #include "application.h" +#include "cgi.h" #include "content.h" #include "git_repo.h" #include "repo_scanner.h" @@ -14,8 +15,12 @@ #include #include #include +#include #include +#include +#include + namespace { constexpr int LOG_LIMIT = 200; const std::string SKIN = "git"; @@ -62,6 +67,14 @@ std::string resolve_github_hosts(const std::string &configured) { return configured; } +// Whether to serve `git clone` over HTTP. Opt-in via the ALLOW_GIT_CLONE env +// var ("1"/"true") or the config key "git.allow_clone"; disabled by default. +bool resolve_allow_clone(bool configured) { + if (const char *env = std::getenv("ALLOW_GIT_CLONE"); env && *env) + return std::string(env) == "1" || std::string(env) == "true"; + return configured; +} + // Minimal content-type table for the assets we ship. std::string mime_for(const std::string &path) { const size_t dot = path.find_last_of('.'); @@ -81,9 +94,11 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner) cache_root_ = resolve_cache_root(settings().get("git.cache_root", "")); github_hosts_ = split_hosts(resolve_github_hosts( settings().get("git.github_enterprise_hosts", ""))); + allow_clone_ = resolve_allow_clone(settings().get("git.allow_clone", false)); // Root arrives as "" because script_name "/" is stripped; accept both. dispatcher().assign("^/?$", &GitApp::frontpage, this); + dispatcher().assign("^/about/?$", &GitApp::about, this); dispatcher().assign("^/static/(.+)$", &GitApp::static_file, this, 1); dispatcher().assign("^/repos/?$", &GitApp::repos_list, this); dispatcher().assign("^/repos/([^/]+)/tree/?(.*)$", &GitApp::tree, this, 1, 2); @@ -97,6 +112,11 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner) dispatcher().assign("^/repos/([^/]+)/tags/(.+)\\.tar\\.gz$", &GitApp::tag_archive, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/tags/(.+)$", &GitApp::tag, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/branches/?$", &GitApp::branches, this, 1); + // Only wire up the clone endpoints when enabled, so a disabled server never + // forks git http-backend (the URLs simply 404 and the client falls back). + if (allow_clone_) + dispatcher().assign("^/repos/([^/]+)/(info/refs|git-upload-pack)$", + &GitApp::git_http, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/?$", &GitApp::repo_home, this, 1); } @@ -120,6 +140,102 @@ void GitApp::static_file(std::string rel) { response().out() << file.rdbuf(); } +void GitApp::git_http(std::string name, std::string endpoint) { + namespace fs = std::filesystem; + + if (!allow_clone_) { // defensive: the route is not registered when disabled + show_error(404, "Not found"); + return; + } + + const std::string repo_path = scanner_->path_for(name); + if (repo_path.empty()) { + show_error(404, "No such repository: " + name); + return; + } + + // Read-only: only the fetch/clone service is exposed; reject pushes. + if (endpoint == "info/refs" && + request().get("service") != "git-upload-pack") { + show_error(403, "Only git-upload-pack (clone/fetch) is supported"); + return; + } + + // Resolve the on-disk git directory (bare or non-bare) and express it as + // GIT_PROJECT_ROOT + PATH_INFO for git-http-backend. + fs::path git_dir = repo_path; + if (fs::is_directory(git_dir / ".git")) + git_dir /= ".git"; + const std::string project_root = git_dir.parent_path().string(); + const std::string path_info = "/" + git_dir.filename().string() + "/" + endpoint; + + // A POST body (upload-pack negotiation) is staged in a temp file used as the + // child's stdin; reading from a file rather than a pipe avoids deadlock when + // git streams back a large packfile while we are still writing the request. + int input_fd = -1; + if (request().request_method() == "POST") { + const std::pair data = request().raw_post_data(); + fs::create_directories(cache_root_); + std::string tmpl = cache_root_ + "/upload-pack-XXXXXX"; + std::vector path_buf(tmpl.begin(), tmpl.end()); + path_buf.push_back('\0'); + input_fd = mkstemp(path_buf.data()); + if (input_fd < 0) { + show_error(500, "cannot create temp file for request body"); + return; + } + unlink(path_buf.data()); // unlink now; the open fd keeps the file alive + const char *p = static_cast(data.first); + size_t left = data.second; + while (left > 0) { + const ssize_t w = write(input_fd, p, left); + if (w <= 0) + break; + p += w; + left -= w; + } + lseek(input_fd, 0, SEEK_SET); + } + + // `-c safe.directory=*` is the http-backend analogue of the in-process + // libgit2 owner-validation override: git http-backend is a separate process + // and would otherwise refuse repos owned by a different UID (Docker mounts). + const std::vector argv = {"git", "-c", "safe.directory=*", + "http-backend"}; + const std::vector env = { + "GIT_PROJECT_ROOT=" + project_root, + "GIT_HTTP_EXPORT_ALL=1", + "PATH_INFO=" + path_info, + "REQUEST_METHOD=" + request().request_method(), + "QUERY_STRING=" + request().query_string(), + "CONTENT_TYPE=" + request().getenv("CONTENT_TYPE"), + "HTTP_CONTENT_ENCODING=" + request().getenv("HTTP_CONTENT_ENCODING"), + "GIT_PROTOCOL=" + request().getenv("HTTP_GIT_PROTOCOL"), + "REMOTE_ADDR=" + request().remote_addr(), + }; + + bool started = false; + try { + run_cgi(argv, env, input_fd, + [this, &started](const CgiResult &r) -> std::ostream & { + started = true; + response().status(r.status); + // set_header (not content_type) so git's exact content types + // are sent without a "; charset=..." suffix appended. + for (const auto &h : r.headers) + response().set_header(h.first, h.second); + return response().out(); + }); + } catch (const std::exception &e) { + std::cerr << "git_http: " << e.what() << std::endl; + if (!started) + show_error(500, "git http-backend failed"); + } + + if (input_fd >= 0) + close(input_fd); +} + std::unique_ptr GitApp::open(const std::string &name) { const std::string path = scanner_->path_for(name); if (path.empty()) { @@ -156,6 +272,12 @@ void GitApp::frontpage() { render(SKIN, "frontpage", c); } +void GitApp::about() { + content::master c; + c.title = "About"; + render(SKIN, "about", c); +} + void GitApp::repos_list() { content::repo_list c; c.title = "Repositories"; @@ -178,6 +300,15 @@ void GitApp::repo_home(std::string name) { util::github_clone_urls(repo->remote_url(), github_hosts_); c.clone_ssh = clone.ssh; c.clone_https = clone.https; + if (const std::string host = request().getenv("HTTP_HOST"); + allow_clone_ && !host.empty()) { + // Behind a TLS-terminating proxy the public scheme is https even + // though we serve plain http; honor X-Forwarded-Proto when set. + std::string scheme = request().getenv("HTTP_X_FORWARDED_PROTO"); + if (scheme.empty()) + scheme = "http"; + c.clone_http = scheme + "://" + host + "/repos/" + name; + } c.readme_name = repo->find_readme(c.commit_oid); if (!c.readme_name.empty()) { bool is_binary = false; diff --git a/src/application.h b/src/application.h index 48bd586..0dba75c 100644 --- a/src/application.h +++ b/src/application.h @@ -21,6 +21,7 @@ public: private: void frontpage(); + void about(); void repos_list(); void repo_home(std::string name); void tree(std::string name, std::string path); @@ -36,6 +37,10 @@ private: void commit_patch(std::string name, std::string oid); void static_file(std::string rel); + // Serves Git's Smart HTTP protocol (clone/fetch) by proxying to + // `git http-backend`. `endpoint` is "info/refs" or "git-upload-pack". + void git_http(std::string name, std::string endpoint); + // Opens the named repo, or writes a 404 and returns nullptr. std::unique_ptr open(const std::string &name); @@ -49,4 +54,5 @@ private: std::string static_root_; std::string cache_root_; // where generated tag archives are cached std::vector github_hosts_; // extra GitHub Enterprise hostnames + bool allow_clone_ = false; // serve git clone over HTTP (ALLOW_GIT_CLONE) }; diff --git a/src/cgi.cpp b/src/cgi.cpp new file mode 100644 index 0000000..85b6aa9 --- /dev/null +++ b/src/cgi.cpp @@ -0,0 +1,146 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE // for execvpe +#endif + +#include "cgi.h" + +#include +#include +#include + +#include +#include +#include + +namespace { +constexpr size_t CHUNK = 64 * 1024; + +// Builds a null-terminated char* array from strings for execvp/environ. The +// pointers borrow from `strings`, which must outlive the returned vector. +std::vector to_c_array(const std::vector &strings) { + std::vector out; + out.reserve(strings.size() + 1); + for (const std::string &s : strings) + out.push_back(const_cast(s.c_str())); + out.push_back(nullptr); + return out; +} + +// Splits one CGI header line "Name: value" into a trimmed pair. Returns false +// for malformed lines (no colon). +bool parse_header_line(const std::string &line, std::string &name, + std::string &value) { + const size_t colon = line.find(':'); + if (colon == std::string::npos) + return false; + name = line.substr(0, colon); + size_t start = colon + 1; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) + ++start; + value = line.substr(start); + return true; +} + +// Parses the accumulated CGI header block (already stripped of the trailing +// blank line) into `result`. A "Status: NNN ..." header sets the HTTP status; +// every other header is preserved verbatim. +void parse_headers(const std::string &block, CgiResult &result) { + size_t pos = 0; + while (pos < block.size()) { + size_t end = block.find('\n', pos); + if (end == std::string::npos) + end = block.size(); + std::string line = block.substr(pos, end - pos); + pos = end + 1; + if (!line.empty() && line.back() == '\r') + line.pop_back(); + if (line.empty()) + continue; + std::string name, value; + if (!parse_header_line(line, name, value)) + continue; + if (name == "Status") + result.status = std::atoi(value.c_str()); + else + result.headers.emplace_back(std::move(name), std::move(value)); + } +} +} // namespace + +void run_cgi(const std::vector &argv, + const std::vector &env, int input_fd, + const std::function &on_headers) { + int out_pipe[2]; + if (pipe(out_pipe) != 0) + throw std::runtime_error("cgi: pipe failed"); + + const pid_t pid = fork(); + if (pid < 0) { + close(out_pipe[0]); + close(out_pipe[1]); + throw std::runtime_error("cgi: fork failed"); + } + + if (pid == 0) { + // Child: wire up stdin/stdout, then exec. + const int in = input_fd >= 0 ? input_fd : open("/dev/null", O_RDONLY); + if (in >= 0) + dup2(in, STDIN_FILENO); + dup2(out_pipe[1], STDOUT_FILENO); + close(out_pipe[0]); + close(out_pipe[1]); + + std::vector c_argv = to_c_array(argv); + std::vector c_env = to_c_array(env); + execvpe(c_argv[0], c_argv.data(), c_env.data()); + _exit(127); // exec failed + } + + // Parent: read the child's stdout. + close(out_pipe[1]); + + std::string header_block; + std::ostream *body = nullptr; // set once headers are parsed + char buf[CHUNK]; + ssize_t n; + while ((n = read(out_pipe[0], buf, sizeof(buf))) > 0) { + if (body != nullptr) { + body->write(buf, n); + continue; + } + header_block.append(buf, n); + const size_t sep_crlf = header_block.find("\r\n\r\n"); + const size_t sep_lf = header_block.find("\n\n"); + size_t sep = std::string::npos; + size_t skip = 0; + if (sep_crlf != std::string::npos && + (sep_lf == std::string::npos || sep_crlf <= sep_lf)) { + sep = sep_crlf; + skip = 4; + } else if (sep_lf != std::string::npos) { + sep = sep_lf; + skip = 2; + } + if (sep != std::string::npos) { + const std::string rest = header_block.substr(sep + skip); + header_block.resize(sep); + CgiResult result; + parse_headers(header_block, result); + body = &on_headers(result); + body->write(rest.data(), rest.size()); + } + } + close(out_pipe[0]); + + // No blank line ever arrived: still surface whatever headers we have. + if (body == nullptr) { + CgiResult result; + parse_headers(header_block, result); + on_headers(result); + } + + int wstatus = 0; + waitpid(pid, &wstatus, 0); + if (!WIFEXITED(wstatus) || WEXITSTATUS(wstatus) != 0) + throw std::runtime_error("cgi: child exited abnormally"); +} diff --git a/src/cgi.h b/src/cgi.h new file mode 100644 index 0000000..41ff78f --- /dev/null +++ b/src/cgi.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Parsed CGI header block (the body is streamed separately). +struct CgiResult { + int status = 200; // from a "Status:" header, or 200 if absent + std::vector> headers; +}; + +// Forks and execs `argv` with exactly the environment in `env` (each entry +// "KEY=value"). The child's stdin is connected to `input_fd` (or /dev/null when +// input_fd < 0). Its stdout is parsed as a CGI response: once the header block +// up to the first blank line is parsed, `on_headers` is called with it and must +// return the stream to receive the body; the remaining bytes are then streamed +// there. Splitting it this way lets the caller set HTTP status/headers before +// any body is written. Throws std::runtime_error if the child cannot be spawned +// or exits non-zero. +void run_cgi(const std::vector &argv, + const std::vector &env, int input_fd, + const std::function &on_headers); diff --git a/src/content.h b/src/content.h index 90586c3..258e95c 100644 --- a/src/content.h +++ b/src/content.h @@ -33,6 +33,7 @@ struct repo_home : public master { std::string readme_text; // raw markdown, rendered client-side std::string clone_ssh; // "" when origin is not a github.com remote std::string clone_https; // "" when origin is not a github.com remote + std::string clone_http; // self-served clone URL; "" when clone is disabled }; struct tree_view : public master { diff --git a/templates/skin.tmpl b/templates/skin.tmpl index 23eb34c..2454759 100644 --- a/templates/skin.tmpl +++ b/templates/skin.tmpl @@ -16,6 +16,9 @@
git repos +<% if (content.repo.length() == 0) %> +about +<% end %> <% if (content.repo.length() > 0) %> / <%= repo %> @@ -47,6 +50,13 @@ <% end template %> <% end view %> +<% view about uses content::master extends master %> +<% template content_body() %> +

About

+

A lightweight, read-only web frontend for browsing and cloning local git repositories.

+<% end template %> +<% end view %> + <% view repo_list uses content::repo_list extends master %> <% template content_body() %>

Repositories

@@ -68,11 +78,16 @@ files · log

-<% if (content.clone_https.length() > 0) %> +<% if (content.clone_http.length() > 0 or content.clone_https.length() > 0) %>

Checkout

+<% if (content.clone_http.length() > 0) %> +

Non-GitHub git clone <%= clone_http %>

+<% end %> +<% if (content.clone_https.length() > 0) %>

HTTPS git clone <%= clone_https %>

SSH git clone <%= clone_ssh %>

+<% end %>
<% end %> <% if (content.readme_name.length() > 0) %>