arg@{ config, pkgs, lib, ... }: with builtins; with lib; let cfg = config.tv.git; out = { imports = [ ../../3modules/tv/nginx.nix ]; options.tv.git = api; config = mkIf cfg.enable (mkMerge [ imp ]); }; api = { enable = mkOption { type = types.bool; default = false; description = "Enable Git repository hosting."; }; cgit = mkOption { type = types.bool; default = true; description = "Enable cgit."; # TODO better desc; talk about nginx }; dataDir = mkOption { type = types.str; default = "/var/lib/git"; description = "Directory used to store repositories."; }; etcDir = mkOption { type = types.str; default = "/etc/git"; }; rules = mkOption { type = types.unspecified; }; repos = mkOption { type = types.attrsOf (types.submodule ({ options = { desc = mkOption { type = types.nullOr types.str; default = null; description = '' Repository description. ''; }; section = mkOption { type = types.nullOr types.str; default = null; description = '' Repository section. ''; }; name = mkOption { type = types.str; description = '' Repository name. ''; }; hooks = mkOption { type = types.attrsOf types.str; description = '' Repository-specific hooks. ''; }; public = mkOption { type = types.bool; default = false; description = '' Allow everybody to read the repository via HTTP if cgit enabled. ''; # TODO allow every configured user to fetch the repository via SSH. }; }; })); default = {}; example = literalExample '' { testing = { name = "testing"; hooks.post-update = ''' #! /bin/sh set -euf echo post-update hook: $* >&2 '''; }; testing2 = { name = "testing2"; }; } ''; description = '' Repositories. ''; }; users = mkOption { type = types.unspecified; }; }; imp = { 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 }; }; 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 = pkgs.writeScript "${farm-name}_${script-name}" script-string; }; in pkgs.linkFarm farm-name (mapAttrsToList makeScript scripts); git-ssh-command = pkgs.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 = pkgs.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 out #let # inherit (lib) mkIf mkMerge; # # cfg = config.tv.git; # arg' = arg // { inherit cfg; }; #in # ## TODO unify logging of shell scripts to user and journal ## TODO move all scripts to ${etcDir}, so ControlMaster connections ## immediately pick up new authenticators ## TODO when authorized_keys changes, then restart ssh ## (or kill already connected users somehow) # #{ # imports = [ # ../../3modules/tv/nginx.nix # ]; # # options.tv.git = import ./options.nix arg'; # # config = mkIf cfg.enable (mkMerge [ # (import ./config.nix arg') # (mkIf cfg.cgit (import ./cgit.nix arg')) # ]); #}