diff options
Diffstat (limited to 'modules/tv/git/default.nix')
| -rw-r--r-- | modules/tv/git/default.nix | 347 | 
1 files changed, 347 insertions, 0 deletions
| diff --git a/modules/tv/git/default.nix b/modules/tv/git/default.nix new file mode 100644 index 000000000..d264125dc --- /dev/null +++ b/modules/tv/git/default.nix @@ -0,0 +1,347 @@ +{ config, lib, pkgs, ... }: + +let +  inherit (builtins) +    attrNames attrValues concatLists filter hasAttr head lessThan removeAttrs +    tail toJSON typeOf; +  inherit (lib) +    concatMapStringsSep concatStringsSep escapeShellArg hasPrefix +    literalExample makeSearchPath mapAttrsToList mkIf mkOption optionalString +    removePrefix singleton sort types unique; +  inherit (pkgs) linkFarm writeScript writeText; + + +  ensureList = x: +    if typeOf x == "list" then x else [x]; + +  getName = x: x.name; + +  makeAuthorizedKey = command-script: user@{ name, pubkey }: +    # TODO assert name +    # TODO assert pubkey +    let +      options = concatStringsSep "," [ +        ''command="exec ${command-script} ${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); + +  writeJSON = name: data: writeText name (toJSON data); + + +  cfg = config.services.git; +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) + +{ +  options.services.git = { +    enable = mkOption { +      type = types.bool; +      default = false; +      description = "Enable Git repository hosting."; +    }; +    dataDir = mkOption { +      type = types.str; +      default = "/var/lib/git"; +      description = "Directory used to store repositories."; +    }; +    etcDir = mkOption { +      type = types.str; +      default = "/etc/git-ssh"; +    }; +    rules = mkOption { +      type = types.unspecified; +    }; +    repos = mkOption { +      type = types.attrsOf (types.submodule ({ +        options = { +          name = mkOption { +            type = types.str; +            description = '' +              Repository name. +            ''; +          }; +          hooks = mkOption { +            type = types.attrsOf types.str; +            description = '' +              Repository-specific hooks. +            ''; +          }; +        }; +      })); + +      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; +    }; +  }; + +  config = +    let +      command-script = 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-ssh echo "error: $1" +          exit -1 +        } + +        GIT_SSH_USER=$1 + +        systemd-cat -p info -t git-ssh 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-ssh \ +          echo "authorized exec $command $repodir" + +        export GIT_SSH_USER +        export GIT_SSH_REPO +        exec "$command" "$repodir" +      ''; + +      init-script = writeScript "git-ssh-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-ssh-hooks" (makeHooks repo); +          in +          '' +            reponame=${escapeShellArg repo.name} +            repodir=$dataDir/$reponame +            if ! test -d "$repodir"; then +              mkdir -m 0700 "$repodir" +              git init --bare --template=/var/empty "$repodir" +              chown -R git: "$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-ssh echo "authorized $1" +            accept_string="''${accept_string+$accept_string +          }authorized $1" +          } +          reject() { +            #systemd-cat -p err -t git-ssh 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-ssh echo "$reject_string" +            exit -1 +          fi + +          systemd-cat -p info -t git-ssh 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 +    mkIf cfg.enable { +      system.activationScripts.git-ssh-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 command-script) cfg.users; +        uid = 112606723; # genid git +      }; +    }; +} | 
