diff options
| -rw-r--r-- | lib/default.nix | 14 | ||||
| -rw-r--r-- | lib/git.nix | 41 | ||||
| -rw-r--r-- | modules/cd/default.nix | 1 | ||||
| -rw-r--r-- | modules/tv/git.nix | 292 | 
4 files changed, 346 insertions, 2 deletions
| diff --git a/lib/default.nix b/lib/default.nix index 26653d96d..27cf0e250 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -3,10 +3,20 @@  with builtins;  let -  inherit (lib) stringAsChars; +  inherit (lib) mapAttrs stringAsChars;  in -{ +rec { +  git = import ./git.nix { +    lib = lib // { +      inherit addNames; +    }; +  }; + +  addName = name: set: +    set // { inherit name; }; + +  addNames = mapAttrs addName;    # "7.4.335" -> "74" diff --git a/lib/git.nix b/lib/git.nix new file mode 100644 index 000000000..5916cf83c --- /dev/null +++ b/lib/git.nix @@ -0,0 +1,41 @@ +{ lib, ... }: + +let +  inherit (lib) addNames; + +  commands = addNames { +    git-receive-pack = {}; +    git-upload-pack = {}; +  }; + +  receive-modes = addNames { +    fast-forward = {}; +    non-fast-forward = {}; +    create = {}; +    delete = {}; +    merge = {}; # TODO implement in git.nix +  }; + +  permissions = { +    fetch = { +      allow-commands = [ +        commands.git-upload-pack +      ]; +    }; + +    push = ref: extra-modes: { +      allow-commands = [ +        commands.git-receive-pack +        commands.git-upload-pack +      ]; +      allow-receive-ref = ref; +      allow-receive-modes = [ receive-modes.fast-forward ] ++ extra-modes; +    }; +  }; + +  refs = { +    master = "refs/heads/master"; +    all-heads = "refs/heads/*"; +  }; +in +commands // receive-modes // permissions // refs diff --git a/modules/cd/default.nix b/modules/cd/default.nix index 7ceaf71f3..cc63318cd 100644 --- a/modules/cd/default.nix +++ b/modules/cd/default.nix @@ -11,6 +11,7 @@        ../tv/base-cac-CentOS-7-64bit.nix        ../tv/ejabberd.nix # XXX echtes modul        ../tv/exim-smarthost.nix +      ../tv/git.nix        ../tv/retiolum.nix        ../tv/sanitize.nix      ]; diff --git a/modules/tv/git.nix b/modules/tv/git.nix new file mode 100644 index 000000000..4d9e200ad --- /dev/null +++ b/modules/tv/git.nix @@ -0,0 +1,292 @@ +{ config, lib, pkgs, ... }: + +let +  inherit (builtins) +    attrNames concatLists filter hasAttr head lessThan removeAttrs tail toJSON +    typeOf; +  inherit (lib) +    concatStrings concatStringsSep escapeShellArg hasPrefix listToAttrs +    makeSearchPath mapAttrsToList mkIf mkOption 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)); + +  toShellArgs = xs: toString (map escapeShellArg xs); + +  # 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.unspecified; +    }; +    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 +        ])} + +        dataDir=${escapeShellArg cfg.dataDir} +        mkdir -p "$dataDir" + +        for reponame in ${toShellArgs (reponames cfg.rules)}; do +          repodir=$dataDir/$reponame +          if ! test -d "$repodir"; then +            mkdir -m 0700 "$repodir" +            git init --bare --template=/var/empty "$repodir" +            chown -R git: "$repodir" +            # branches/ +            # description +            # hooks/ +            # info/ +          fi +          ln -snf ${hooks} "$repodir/hooks" +        done +      ''; + +      # TODO repo-specific hooks +      hooks = scriptFarm "git-ssh-hooks" { +        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" +        ''; +        update = '' +          #! /bin/sh +          set -euf +          echo update hook: $* >&2 +        ''; +        post-update = '' +          #! /bin/sh +          set -euf +          echo post-update hook: $* >&2 +        ''; +      }; + +      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 +      }; +    }; +} | 
