arg@{ cfg, lib, pkgs, ... }: let inherit (builtins) head tail typeOf; inherit (lib) attrValues concatStringsSep concatMapStringsSep escapeShellArg filter getAttr hasAttr hasPrefix lessThan makeSearchPath mapAttrsToList optional optionalString removePrefix singleton sort unique; inherit (pkgs) linkFarm writeScript; ensureList = x: if typeOf x == "list" then x else [x]; getName = x: x.name; isPublicRepo = getAttr "public"; # TODO this is also in ./cgit.nix makeAuthorizedKey = git-ssh-command: user@{ name, pubkey }: # TODO assert name # TODO assert pubkey let options = concatStringsSep "," [ ''command="exec ${git-ssh-command} ${name}"'' "no-agent-forwarding" "no-port-forwarding" "no-pty" "no-X11-forwarding" ]; in "${options} ${pubkey}"; # [case-pattern] -> shell-script # Create a shell script that succeeds (exit 0) when all its arguments # match the case patterns (in the given order). makeAuthorizeScript = let # TODO escape to-pattern = x: concatStringsSep "|" (ensureList x); go = i: ps: if ps == [] then "exit 0" else '' case ''$${toString i} in ${to-pattern (head ps)}) ${go (i + 1) (tail ps)} esac''; in patterns: '' #! /bin/sh set -euf ${concatStringsSep "\n" (map (go 1) patterns)} exit -1 ''; reponames = rules: sort lessThan (unique (map (x: x.repo.name) rules)); # TODO makeGitHooks that uses runCommand instead of scriptFarm? scriptFarm = farm-name: scripts: let makeScript = script-name: script-string: { name = script-name; path = writeScript "${farm-name}_${script-name}" script-string; }; in linkFarm farm-name (mapAttrsToList makeScript scripts); git-ssh-command = writeScript "git-ssh-command" '' #! /bin/sh set -euf PATH=${makeSearchPath "bin" (with pkgs; [ coreutils git gnugrep gnused systemd ])} abort() { echo "error: $1" >&2 systemd-cat -p err -t git echo "error: $1" exit -1 } GIT_SSH_USER=$1 systemd-cat -p info -t git echo \ "authorizing $GIT_SSH_USER $SSH_CONNECTION $SSH_ORIGINAL_COMMAND" # References: The Base Definitions volume of # POSIX.1‐2013, Section 3.278, Portable Filename Character Set portable_filename_bre="^[A-Za-z0-9._-]\\+$" command=$(echo "$SSH_ORIGINAL_COMMAND" \ | sed -n 's/^\([^ ]*\) '"'"'\(.*\)'"'"'/\1/p' \ | grep "$portable_filename_bre" \ || abort 'cannot read command') GIT_SSH_REPO=$(echo "$SSH_ORIGINAL_COMMAND" \ | sed -n 's/^\([^ ]*\) '"'"'\(.*\)'"'"'/\2/p' \ | grep "$portable_filename_bre" \ || abort 'cannot read reponame') ${cfg.etcDir}/authorize-command \ "$GIT_SSH_USER" "$GIT_SSH_REPO" "$command" \ || abort 'access denied' repodir=${escapeShellArg cfg.dataDir}/$GIT_SSH_REPO systemd-cat -p info -t git \ echo "authorized exec $command $repodir" export GIT_SSH_USER export GIT_SSH_REPO exec "$command" "$repodir" ''; init-script = writeScript "git-init" '' #! /bin/sh set -euf PATH=${makeSearchPath "bin" (with pkgs; [ coreutils findutils gawk git gnugrep gnused ])} dataDir=${escapeShellArg cfg.dataDir} mkdir -p "$dataDir" # Notice how the presence of hooks symlinks determine whether # we manage a repositry or not. # Make sure that no existing repository has hooks. We can delete # symlinks because we assume we created them. find "$dataDir" -mindepth 2 -maxdepth 2 -name hooks -type l -delete bad_hooks=$(find "$dataDir" -mindepth 2 -maxdepth 2 -name hooks) if echo "$bad_hooks" | grep -q .; then printf 'error: unknown hooks:\n%s\n' \ "$(echo "$bad_hooks" | sed 's/^/ /')" \ >&2 exit -1 fi # Initialize repositories. ${concatMapStringsSep "\n" (repo: let hooks = scriptFarm "git-hooks" (makeHooks repo); in '' reponame=${escapeShellArg repo.name} repodir=$dataDir/$reponame mode=${toString (if isPublicRepo repo then 0711 else 0700)} if ! test -d "$repodir"; then mkdir -m "$mode" "$repodir" git init --bare --template=/var/empty "$repodir" chown -R git:nogroup "$repodir" fi ln -s ${hooks} "$repodir/hooks" '' ) (attrValues cfg.repos)} # Warn about repositories that exist but aren't mentioned in the # current configuration (and thus didn't receive a hooks symlink). unknown_repos=$(find "$dataDir" -mindepth 1 -maxdepth 1 \ -type d \! -exec test -e '{}/hooks' \; -print) if echo "$unknown_repos" | grep -q .; then printf 'warning: stale repositories:\n%s\n' \ "$(echo "$unknown_repos" | sed 's/^/ /')" \ >&2 fi ''; makeHooks = repo: removeAttrs repo.hooks [ "pre-receive" ] // { pre-receive = '' #! /bin/sh set -euf PATH=${makeSearchPath "bin" (with pkgs; [ coreutils # env git systemd ])} accept() { #systemd-cat -p info -t git echo "authorized $1" accept_string="''${accept_string+$accept_string }authorized $1" } reject() { #systemd-cat -p err -t git echo "denied $1" #echo 'access denied' >&2 #exit_code=-1 reject_string="''${reject_string+$reject_string }access denied: $1" } empty=0000000000000000000000000000000000000000 accept_string= reject_string= while read oldrev newrev ref; do if [ $oldrev = $empty ]; then receive_mode=create elif [ $newrev = $empty ]; then receive_mode=delete elif [ "$(git merge-base $oldrev $newrev)" = $oldrev ]; then receive_mode=fast-forward else receive_mode=non-fast-forward fi if ${cfg.etcDir}/authorize-push \ "$GIT_SSH_USER" "$GIT_SSH_REPO" "$ref" "$receive_mode"; then accept "$receive_mode $ref" else reject "$receive_mode $ref" fi done if [ -n "$reject_string" ]; then systemd-cat -p err -t git echo "$reject_string" exit -1 fi systemd-cat -p info -t git echo "$accept_string" ${optionalString (hasAttr "post-receive" repo.hooks) '' # custom post-receive hook ${repo.hooks.post-receive}''} ''; }; etc-base = assert (hasPrefix "/etc/" cfg.etcDir); removePrefix "/etc/" cfg.etcDir; in { system.activationScripts.git-init = "${init-script}"; # TODO maybe put all scripts here and then use PATH? environment.etc."${etc-base}".source = scriptFarm "git-ssh-authorizers" { authorize-command = makeAuthorizeScript (map ({ repo, user, perm }: [ (map getName (ensureList user)) (map getName (ensureList repo)) (map getName perm.allow-commands) ]) cfg.rules); authorize-push = makeAuthorizeScript (map ({ repo, user, perm }: [ (map getName (ensureList user)) (map getName (ensureList repo)) (ensureList perm.allow-receive-ref) (map getName perm.allow-receive-modes) ]) (filter (x: hasAttr "allow-receive-ref" x.perm) cfg.rules)); }; users.extraUsers = singleton { description = "Git repository hosting user"; name = "git"; shell = "/bin/sh"; openssh.authorizedKeys.keys = mapAttrsToList (_: makeAuthorizedKey git-ssh-command) cfg.users; uid = 112606723; # genid git }; }