#!/usr/bin/env python3
"""
Centrally run the Gitweb repository frontend (https://git-scm.com/book/en/v2/Git-on-the-Server-GitWeb) in a python HTTP
server, as a more stable and featured self-hosted alternative to `git instaweb` generated python code.
See: https://github.com/git/git/blob/master/git-instaweb.sh
In general, this self-hosted git web interface still *should not* be considered safe enough to be exposed to the
internet - even when using authentication and HTTPS.
No local files will be created, a temporary directory containing the configuration file is used as environment instead.
The `GITWEB_CONFIG` environment variable can be used to override this.
Note that `GITWEB_CONFIG_SYSTEM` and `GITWEB_CONFIG_COMMON` can be set, too - see the Gitweb documentation at
https://git-scm.com/docs/gitweb and https://git-scm.com/docs/gitweb.conf for details.
Basic HTTP authentication is supported - via commandline argument or environment variable.
Given a certificate and key, the web frontend can also be served via HTTPS.
There are no additional python3 dependencies needed and `gitweb` is usually already installed per default with `git`.
For actually running the Perl CGI script, `libcgi-pm-perl` or similar might be needed, though.
"""
import argparse
import os
import signal
import sys
import tempfile
import threading
from base64 import b64encode
from hmac import compare_digest
from pathlib import Path
from urllib.parse import quote_plus
from http.server import HTTPServer, ThreadingHTTPServer, CGIHTTPRequestHandler
from ssl import wrap_socket
from typing import Optional, Tuple, List, Type
class CGIHTTPServer(ThreadingHTTPServer):
def __init__(self,
server_address: Tuple[str, int],
handler_class: Type[CGIHTTPRequestHandler],
auth: Optional[str],
cgi_path: str, cgi_file: str,
ssl_cert: Optional[str], ssl_key: Optional[str],
directory: str) -> None:
super().__init__(server_address, handler_class, bind_and_activate=True)
self.auth: Optional[str] = b64encode(auth.encode("utf-8")).decode("ascii") if auth is not None else None
self.cgi_path: str = cgi_path
self.cgi_file: str = cgi_file
if ssl_cert or ssl_key:
self.socket = wrap_socket(self.socket, keyfile=ssl_key, certfile=ssl_cert, server_side=True)
handler_class.have_fork = False
os.chdir(directory)
class AuthCGIHTTPRequestHandler(CGIHTTPRequestHandler):
server: CGIHTTPServer
def do_HEAD(self) -> None:
if self._check_auth():
super().do_HEAD()
def do_GET(self) -> None:
if self._check_auth():
super().do_GET()
def do_POST(self) -> None:
if self._check_auth():
super().do_POST()
def _check_auth(self) -> bool:
auth: Optional[str] = self.headers.get("Authorization", None)
if self.server.auth is None:
return True
elif auth is not None and compare_digest(auth, "Basic " + self.server.auth):
return True
else:
self.send_response(401, "Unauthorized")
self.send_header("WWW-Authenticate", "Basic realm=\"gitweb\"")
self.end_headers()
return False
def list_directory(self, *args, **kwargs) -> None:
self.send_error(403, "Forbidden")
class CGIFileHTTPRequestHandler(AuthCGIHTTPRequestHandler):
server: CGIHTTPServer
def send_head(self):
if self.path == "/":
self.send_response(303, "See Other")
self.send_header("Location", self.server.cgi_file)
self.end_headers()
return None
return super().send_head()
def is_cgi(self) -> bool:
if self.path == self.server.cgi_file or self.path.startswith(self.server.cgi_file + "?"):
self.cgi_info = self.server.cgi_path, self.path[1:]
return True
return False
def end_headers(self) -> None:
if not hasattr(self, "cgi_info"):
self.send_header("Cache-Control", "public, max-age=3600")
self.send_header("Vary", "Authorization")
super().end_headers()
def _setup_config(private_root: Path, project_root: Path, project_list: List[Path]) -> None:
if "GITWEB_CONFIG" in os.environ:
return
private_root = private_root.resolve(strict=True)
project_root = project_root.resolve(strict=True)
project_list = [_.resolve(strict=True).relative_to(project_root) for _ in project_list]
projects_list: Path = private_root / "projects_list"
with projects_list.open(mode="w") as fp:
fp.writelines(["{}\n".format(quote_plus(project.as_posix(), safe='/')) for project in project_list])
temp_dir: Path = private_root / "tmp"
temp_dir.mkdir()
config_file: Path = private_root / "config"
os.environ.setdefault("GITWEB_CONFIG", config_file.as_posix())
with config_file.open(mode="w") as fp:
fp.write(f"""
our $projectroot = "{project_root}";
our $projects_list = "{projects_list if len(project_list) else project_root}";
our $git_temp = "{temp_dir}";
our $strict_export = 1;
our $prevent_xss = 1;
our $per_request_config = 0;
our $site_html_head_string = "<style>html{{padding:10px;}}body{{max-width:1280px;margin:0 auto;}}</style>";
""")
fp.write("""
$feature{'timed'}{'default'} = [1];
$feature{'blame'}{'default'} = [1];
$feature{'snapshot'}{'default'} = ['tgz', 'zip'];
$feature{'grep'}{'default'} = [1];
$feature{'patches'}{'default'} = [0];
$feature{'remote_heads'}{'default'} = [1];
""")
def _serve_forever(server: HTTPServer) -> bool:
shutdown_requested: threading.Event = threading.Event()
def _handler(signum: int, frame) -> None:
shutdown_requested.set()
signal.signal(signal.SIGINT, _handler)
signal.signal(signal.SIGTERM, _handler)
thread: threading.Thread = threading.Thread(target=server.serve_forever)
thread.start()
shutdown_requested.wait()
server.shutdown()
server.server_close()
thread.join()
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description=__doc__.partition("\n\n\n")[0],
epilog=__doc__.partition("\n\n\n")[2])
parser.add_argument("--local", action="store_const", const=True, default=False,
help="only bind to 127.0.0.1")
parser.add_argument("--port", type=int, default=8000,
help="port number to bind to")
parser.add_argument("--auth", type=str, metavar="USER:PASS", default=None,
help="require HTTP basic authentication")
parser.add_argument("--auth-env", type=str, metavar="ENVAR", default=None,
help="require HTTP basic authentication, credentials from environment variable")
parser.add_argument("--ssl-cert", type=str, metavar="CERT_PEM", default=None,
help="certificate PEM file for HTTPS")
parser.add_argument("--ssl-key", type=str, metavar="KEY_PEM", default=None,
help="certificate key PEM file for HTTPS")
parser.add_argument("--gitweb", type=str, metavar="DIR", default="/usr/share/gitweb",
help="gitweb installation directory")
parser.add_argument("--project-root", type=Path, metavar="DIR", required=True,
help="common root directory for repositories or where to scan for")
parser.add_argument("projects", type=Path, nargs='*', metavar="REPOSITORY",
help="repository git directory to expose, scan root if none given")
args = parser.parse_args()
os.umask(0o027)
with tempfile.TemporaryDirectory() as tempdir:
_setup_config(Path(tempdir), args.project_root, args.projects)
httpd: CGIHTTPServer = CGIHTTPServer(
server_address=("127.0.0.1" if args.local else "0.0.0.0", args.port),
handler_class=CGIFileHTTPRequestHandler,
auth=os.environ[args.auth_env] if args.auth_env is not None else args.auth,
cgi_path="", cgi_file="/gitweb.cgi",
ssl_cert=args.ssl_cert, ssl_key=args.ssl_key,
directory=args.gitweb,
)
sys.exit(0 if _serve_forever(httpd) else 1)