diff options
-rwxr-xr-x | bin/nixos-build | 2 | ||||
-rw-r--r-- | lib/default.nix | 18 | ||||
-rw-r--r-- | lib/git.nix | 41 | ||||
-rw-r--r-- | modules/cd/default.nix | 39 | ||||
-rw-r--r-- | modules/tv/git.nix | 292 | ||||
-rw-r--r-- | pubkeys/tv.ssh.pub | 1 |
6 files changed, 387 insertions, 6 deletions
diff --git a/bin/nixos-build b/bin/nixos-build index a0c9551fa..79b052654 100755 --- a/bin/nixos-build +++ b/bin/nixos-build @@ -8,6 +8,7 @@ host=$1 #target=root@$host +pubkeys=$config_root/pubkeys nixpkgs=$nixpkgs_root/$host nixos_config=$config_root/modules/$host secrets_nix=$secrets_root/$host/nix @@ -17,6 +18,7 @@ nixos-fetch-git "$host" nix-build \ -I "$nixpkgs" \ + -I pubkeys="$pubkeys" \ -I nixos-config="$nixos_config" \ -I retiolum-hosts="$retiolum_hosts" \ -I secrets="$secrets_nix" \ diff --git a/lib/default.nix b/lib/default.nix index 995e3dbcd..27cf0e250 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,16 +1,26 @@ -{ pkgs, ... }: +{ lib, ... }: with builtins; let - inherit (pkgs.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" - majmin = with pkgs.lib; x : concatStrings (take 2 (splitString "." x)); + majmin = with lib; x : concatStrings (take 2 (splitString "." x)); concat = xs : 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..9bb4d0f2a 100644 --- a/modules/cd/default.nix +++ b/modules/cd/default.nix @@ -1,4 +1,4 @@ -{ config, pkgs, ... }: +{ config, lib, pkgs, ... }: { imports = @@ -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 ]; @@ -43,6 +44,40 @@ enable = true; }; + services.git = + let + inherit (builtins) readFile; + # TODO lib should already include our stuff + inherit (import ../../lib { inherit lib; }) addNames git; + in + rec { + enable = true; + + users = addNames { + tv = { pubkey = readFile <pubkeys/tv.ssh.pub>; }; + lass = { pubkey = "xxx"; }; + makefu = { pubkey = "xxx"; }; + }; + + # TODO warn about stale repodirs + repos = addNames { + testing = { + # TODO hooks = { post-receive = ... + }; + }; + + rules = with git; with users; with repos; [ + { user = tv; + repo = testing; + perm = push master [ non-fast-forward create delete merge ]; + } + { user = [ lass makefu ]; + repo = testing; + perm = fetch; + } + ]; + }; + services.journald.extraConfig = '' SystemMaxUse=1G RuntimeMaxUse=128M @@ -61,7 +96,7 @@ services.retiolum = { enable = true; - hosts = /etc/nixos/hosts; + hosts = <retiolum-hosts>; privateKeyFile = "/etc/nixos/secrets/cd.retiolum.rsa_key.priv"; connectTo = [ "fastpoke" 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 + }; + }; +} diff --git a/pubkeys/tv.ssh.pub b/pubkeys/tv.ssh.pub new file mode 100644 index 000000000..b6e2634e8 --- /dev/null +++ b/pubkeys/tv.ssh.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAEAQDFR//RnCvEZAt0F6ExDsatKZ/DDdifanuSL360mqOhaFieKI34RoOwfQT9T+Ga52Vh5V2La6esvlph686EdgzeKLvDoxEwFM9ZYFBcMrNzu4bMTlgE7YUYw5JiORyXNfznBGnme6qpuvx9ibYhUyiZo99kM8ys5YrUHrP2JXQJMezDFZHxT4GFMOuSdh/1daGoKKD6hYL/jEHX8CI4E3BSmKK6ygYr1fVX0K0Tv77lIi5mLXucjR7CytWYWYnhM6DC3Hxpv2zRkPgf3k0x/Y1hrw3V/r0Me5h90pd2C8pFaWA2ZoUT/fmyVqvx1tZPYToU/O2dMItY0zgx2kR0yD+6g7Aahz3R+KlXkV8k5c8bbTbfGnZWDR1ZlbLRM9Yt5vosfwapUD90MmVkpmR3wUkO2sUKi80QfC7b4KvSDXQ+MImbGxMaU5Bnsq1PqLN95q+uat3nlAVBAELkcx51FlE9CaIS65y4J7FEDg8BE5JeuCNshh62VSYRXVSFt8bk3f/TFGgzC8OIo14BhVmiRQQ503Z1sROyf5xLX2a/EJavMm1i2Bs2TH6ROKY9z5Pz8hT5US0r381V8oG7TZyLF9HTtoy3wCYsgWA5EmLanjAsVU2YEeAA0rxzdtYP8Y2okFiJ6u+M4HQZ3Wg3peSodyp3vxdYce2vk4EKeqEFuuS82850DYb7Et7fmp+wQQUT8Q/bMO0DreWjHoMM5lE4LJ4ME6AxksmMiFtfo/4Fe2q9D+LAqZ+ANOcv9M+8Rn6ngiYmuRNd0l/a02q1PEvO6vTfXgcl4f7Z1IULHPEaDNZHCJS1K5RXYFqYQ6OHsTmOm7hnwaRAS97+VFMo1i5uvTx9nYaAcY7yzq3Ckfb67dMBKApGOpJpkvPgfrP7bgBO5rOZXM1opXqVPb09nljAhhAhyCTh1e/8+mJrBo0cLQ/LupQzVxGDgm3awSMPxsZAN45PSWz76zzxdDa1MMo51do+VJHfs7Wl0NcXAQrniOBYL9Wqt0qNkn1gY5smkkISGeQ/vxNap4MmzeZE7b5fpOy+2fpcRVQLpc4nooQzJvSVTFz+25lgZ6iHf45K87gQFMIAri1Pf/EDDpL87az+bRWvWi+BA2kMe1kf+Ay1LyMz8r+g51H0ma0bNFh6+fbWMfUiD9JCepIObclnUJ4NlWfcgHxTf17d/4tl6z4DTcLpCCk8Da77JouSHgvtcRbRlFV1OfhWZLXUsrlfpaQTiItv6TGIr3k7+7b66o3Qw/GQVs5GmYifaIZIz8n8my4XjkaMBd0SZfBzzvFjHMq6YUP9+SbjvReqofuoO+5tW1wTYZXitFFBfwuHlXm6w77K5QDBW6olT7pat41/F5eGxLcz tv@wu |