README.md raw

mugit

mugit is a small, read-only web front end for a directory of bare git repositories, with cloning over HTTPS. It is for the person who keeps their own git server, pushes to it over SSH, and wants the public parts of it to have a web page and a clone URL without running a forge.

What it does:

  • lists the public repositories, with descriptions and last-commit dates;
  • shows each repository’s README, rendered from Markdown, and lets visitors browse the files on the default branch;
  • serves git clone and git fetch over HTTP(S) through git’s own smart protocol;
  • obtains and renews its own Let’s Encrypt certificate, or runs plain HTTP behind a reverse proxy.

What it does not do:

  • issues
  • pull requests
  • accounts
  • pushing over HTTP
  • anything else that writes. Repositories are managed with git itself, over SSH or on the machine.

How repositories are published

mugit serves a single directory, /srv/git by default. Each bare repository in it is a subdirectory named <name>.git. A name is the part before .git: letters, digits, ., _ and -, not starting with a dot and not itself ending in .git.

A repository is public when it contains a file named git-daemon-export-ok, the same convention git-daemon and git-http-backend use. Everything else in the directory is invisible: a private repository and a nonexistent one both answer with the same 404, so the site never confirms which private names exist.

cd /srv/git
git init --bare widget.git
echo "A widget for widgeting" > widget.git/description
touch widget.git/git-daemon-export-ok

The description file, which git creates in every repository, is shown on the index and the repository page. The README is looked for at the root of the default branch, under the names README.md, README.markdown, README and README.txt, in that order of preference. Relative links and images in a Markdown README resolve into the site’s own file and raw views.

Bear in mind that publishing a repository publishes all of it: every branch, tag and commit that a clone would fetch, not just the default branch the web pages show.

Building

mugit is a single Rust binary. It needs a Rust toolchain of 1.88 or later, and the git command on the PATH at runtime for clones, fetches and raw file downloads (everything else uses a bundled libgit2).

cargo build --release

The binary is target/release/mugit. The stylesheet and favicon are embedded in it, so it has no files to install alongside it.

Running

Every option is a command line flag or an environment variable of the same name with a MUGIT_ prefix; mugit --help lists them all with their defaults. The ones that matter:

FlagEnvironmentDefaultMeaning
--reposMUGIT_REPOS/srv/gitThe directory of bare repositories
--listenMUGIT_LISTEN0.0.0.0:8000Address for plain HTTP
--tls-domainMUGIT_TLS_DOMAINEnables HTTPS with a Let’s Encrypt certificate for this domain
--tls-listenMUGIT_TLS_LISTEN0.0.0.0:443Address for HTTPS
--acme-cacheMUGIT_ACME_CACHE/var/lib/mugit/acmeWhere the ACME account key and certificates are kept
--acme-contactMUGIT_ACME_CONTACTContact email registered with the certificate authority
--acme-stagingMUGIT_ACME_STAGINGoffUse the Let’s Encrypt staging environment while testing
--site-nameMUGIT_SITE_NAMEmugitName shown in page headers and titles
--public-urlMUGIT_PUBLIC_URLBase URL that clone URLs are built from
--page-sizeMUGIT_PAGE_SIZE50Repositories per page of the index
--git-processesMUGIT_GIT_PROCESSES8Git processes serving clones, fetches and raw files at once
--git-idle-timeoutMUGIT_GIT_IDLE_TIMEOUT60Seconds a stalled clone or download is tolerated before it is dropped
--http-idle-timeoutMUGIT_HTTP_IDLE_TIMEOUT300Seconds a silent connection is held before it is closed; must exceed twice --git-idle-timeout

Logging goes to standard error. MUGIT_LOG takes a tracing filter such as debug or mugit=debug,warn; a release build logs warnings and errors by default.

Standalone with HTTPS

Give mugit a domain and it takes care of the certificate itself, using the TLS-ALPN-01 challenge. That challenge is answered on port 443, so the HTTPS listener must be reachable from the internet on that port, and the plain listener then does nothing but redirect to HTTPS.

mugit --tls-domain git.example.org --acme-contact you@example.org \
      --listen [::]:80 --tls-listen [::]:443

Clone URLs are built from https://<domain> unless --public-url says otherwise. Try --acme-staging first if you are unsure of the setup: Let’s Encrypt rate-limits failed attempts against production.

Behind a reverse proxy

Without a TLS domain, mugit speaks plain HTTP on the listen address and leaves TLS to the proxy in front of it. Set --public-url to the address visitors use, so that the clone URL shown on each page is right; without it, mugit falls back to the scheme and Host header of each request.

mugit --listen 127.0.0.1:8000 --public-url https://git.example.org

The proxy should pass requests through unbuffered: git’s smart protocol streams in both directions, and buffering a whole fetch would defeat the bound mugit keeps on memory.

Deploying as a service

A worked example for a Linux host with systemd, where repositories belong to a git user that people push to over SSH, and mugit runs as its own user with read-only access to them. Adjust paths and names to taste.

  1. Create the service user and give it read access to the repositories through the git group. It owns nothing under the repository directory.

    useradd --system --home-dir /var/lib/mugit --shell /usr/sbin/nologin mugit
    usermod -aG git mugit
    chmod 750 /srv/git
    

    Repositories created by the git user need to be group-readable, which is git’s default; check with ls -l /srv/git/*.git if in doubt.

  2. Create the state directory for the ACME account key and certificates, and install the binary.

    install -d -m 750 -o mugit -g mugit /var/lib/mugit /var/lib/mugit/acme
    install -d -m 755 /opt/mugit
    install -m 755 target/release/mugit /opt/mugit/mugit
    
  3. Install the unit from deploy/mugit.service. It runs the binary from /opt/mugit, serves /srv/git on ports 80 and 443, and hardens the process; everything specific to your site goes in /etc/mugit/env:

    install -m 644 deploy/mugit.service /etc/systemd/system/mugit.service
    install -d -m 755 /etc/mugit
    cat > /etc/mugit/env <<EOT
    MUGIT_TLS_DOMAIN=git.example.org
    MUGIT_ACME_CONTACT=you@example.org
    MUGIT_SITE_NAME=git.example.org
    EOT
    systemctl daemon-reload
    systemctl enable --now mugit
    

    For a reverse proxy setup, put MUGIT_LISTEN=127.0.0.1:8000 and MUGIT_PUBLIC_URL=https://git.example.org in the file instead of the TLS domain.

  4. Watch the journal for the first certificate:

    journalctl -u mugit -f
    

To upgrade, install the new binary over the old one and systemctl restart mugit.

Security notes

The visibility rule is the marker file and nothing else, so review a repository’s history before publishing it. The web side sanitises rendered Markdown and sends every page with a Content Security Policy that forbids script, and serves raw files with a sandbox policy and types that browsers will not execute, so a hosted file cannot run on the site’s origin. Git processes are bounded in number and dropped when their client stalls, which is the protection against clients that open connections and never finish. There is no rate limiting beyond that; put a proxy in front if you need it.

Development

cargo test runs the unit tests and the integration tests under tests/, which build temporary repositories and drive the site through its HTTP API; one of them runs a real git clone against a live listener. A local store for trying the site by hand is just a directory of bare repositories:

mkdir -p local/repos
git clone --bare some/project local/repos/project.git
touch local/repos/project.git/git-daemon-export-ok
cargo run -- --repos local/repos --listen 127.0.0.1:8000

License

This is free and unencumbered software released into the public domain under the Unlicense.