فهرست منبع

feat(gitzones): adds SSH-based git server for replication on port 222

Nils Wisiol 4 سال پیش
والد
کامیت
80a7179747
7فایلهای تغییر یافته به همراه280 افزوده شده و 0 حذف شده
  1. 18 0
      docker-compose.yml
  2. 13 0
      gitzones/Dockerfile
  3. 49 0
      gitzones/README.md
  4. 167 0
      gitzones/auth
  5. 6 0
      gitzones/entrypoint.sh
  6. 3 0
      gitzones/git-shell-commands/no-interactive-login
  7. 24 0
      gitzones/sshd_config

+ 18 - 0
docker-compose.yml

@@ -407,6 +407,23 @@ services:
         tag: "desec/prometheus"
     restart: unless-stopped
 
+  gitzones:
+    build: gitzones
+    image: desec/gitzones:latest
+    init: true
+    environment:
+      - DESECSTACK_DOMAIN
+    volumes:
+      - zones:/zones:ro
+      - gitzones_keys:/etc/ssh/keys/:rw
+    ports:
+      - "222:22"
+    logging:
+      driver: "syslog"
+      options:
+        tag: "desec/gitzones"
+    restart: unless-stopped
+
 volumes:
   dbapi_postgres:
   dblord_mysql:
@@ -417,6 +434,7 @@ volumes:
   webapp_dist:
   zones:
   celerybeat:
+  gitzones_keys:
 
 networks:
   # Note that it is required that the front network ranks lower (in lexical order)

+ 13 - 0
gitzones/Dockerfile

@@ -0,0 +1,13 @@
+FROM alpine:latest
+
+RUN apk add --no-cache openssh git python3
+
+RUN adduser -D -s /usr/bin/git-shell git \
+  && mkdir /home/git/.ssh \
+  && ln -s /etc/ssh/keys/git_authorized_keys /home/git/.ssh/authorized_keys
+
+COPY git-shell-commands /home/git/
+COPY sshd_config /etc/ssh/
+COPY entrypoint.sh auth /usr/local/bin/
+
+ENTRYPOINT entrypoint.sh

+ 49 - 0
gitzones/README.md

@@ -0,0 +1,49 @@
+# git Zones Repository SSH Server
+
+Provides *read only* git access to the zones git repository which is stored in volume `zones`.
+
+
+## Server Authentication
+
+The server identity is based on an ED25519 key pair generated on first startup and stored in the `gitzones_keys` volume.
+To make sure clients are connecting to the correct zone server, use the `auth` command of the gitzones container:
+
+    $ docker-compose exec gitzones auth id
+    desec.example.dedyn.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKxVBPSvHDFGzorms9x76+nAo7Zs+0PhaKnMblcdVPos root@c269a29f451d
+
+The output can be appended to any client's `~/.ssh/known_hosts` file.
+
+
+## Client Authentication
+
+To allow clients to read from the zones repository, add their keys to the `gitzones_authorized_keys` file.
+This container ships a tool for key management.
+
+To add a key, use
+
+    docker-compose exec gitzones auth add ssh-rsa AAAAB<omitted>= ns23.desec.io
+
+The command line arguments after `auth add` can usually be copied from the client's SSH public key file.
+The last argument is the label under which the key is stored.
+Unlike SSH, we insist on unique labels for each key.
+
+To remove a key, use
+
+    docker-compose exec gitzones auth rm ns23.desec.io
+
+To list all labels of currently authorized keys,
+
+    docker-compose exec gitzones auth ls
+
+A `-v` flag can be added to also display the keys.
+To see the bare contents of the authorized_keys file,
+
+    docker-compose exec gitzones auth cat
+
+
+## Security Considerations
+
+Read-only access to the repository is enforced by docker volume options.
+SSH configuration is pretty restrictive, extra features like X11 forwarding are disabled.
+SSH access is only granted via a non-interactive git shell, but all clients share the same UNIX user (`git`).
+For mutual authentication, see above.

+ 167 - 0
gitzones/auth

@@ -0,0 +1,167 @@
+#!/usr/bin/python3
+# this file is formatted using black
+import argparse
+import os
+import sys
+from typing import List, Dict
+
+authorized_keys_file = "/home/git/.ssh/authorized_keys"
+host_public_key_file = "/etc/ssh/keys/ssh_host_ed25519_key.pub"
+usage = """auth <command> [<args>]
+
+Available commands are:
+  id                          Shows server key formatted for client's ~/.ssh/known_hosts
+  add <proto> <key> <label>   Adds a client key to the list of authorized keys.
+                              <proto> <key> <label> is typically the content of the public key file of the client.
+                              The label must be unique; usage of existing labels will overwrite the existing key
+                              without warning.
+  rm <label>                  Removes client key with given label from the list of authorized keys
+  ls [-v]                     Lists all authorized keys.
+  cat                         Print contents of the authorized_keys file.
+"""
+
+
+class AuthorizedKeysException(Exception):
+    pass
+
+
+class AuthorizedKeys(list):
+    """ Provides management of an SSH "authorized_keys" file, restricted to a subset of functionality. """
+
+    def __init__(self, location: str = authorized_keys_file) -> None:
+        super().__init__()
+        self.location = location
+        self.db = {}
+        try:
+            with open(location) as f:
+                for line in f:
+                    if not line.strip() or line.startswith("#"):
+                        continue
+                    proto, key, label = line.strip().split(" ", maxsplit=2)
+                    self.db[label] = proto + " " + key
+        except FileNotFoundError:
+            pass
+
+    def add(self, proto: str, key: str, label: str) -> None:
+        """ Adds an authorized key. """
+        if label in self.db:
+            raise AuthorizedKeysException(f'Key with label "{label}" already exists.')
+        self.db[label] = proto + " " + key
+        self._save()
+
+    def rm(self, label: str) -> None:
+        """ Removes an authorized key, identified by its label. Raises if key with given label cannot be found. """
+        try:
+            del self.db[label]
+        except KeyError:
+            raise AuthorizedKeysException(
+                f'Could not find authorized key with label "{label}".'
+            )
+        self._save()
+
+    def ls(self) -> Dict[str, str]:
+        """ Returns dictionary of authorized keys, identified by their labels. """
+        return self.db.copy()
+
+    def _save(self) -> None:
+        real_location = os.path.realpath(self.location)
+        with open(real_location + '~', "w") as f:
+            for label, proto_key in self.db.items():
+                f.write(proto_key + " " + label + "\n")
+        os.rename(real_location + '~', real_location)
+
+
+def main(args: List[str]) -> None:
+    """ Command line application main entry point. """
+    parser = argparse.ArgumentParser(
+        usage=usage,
+    )
+    parser.add_argument("command", help="Subcommand to run")
+    parsed = parser.parse_args(args[0:1])
+
+    cmd = {
+        "id": cmd_id,
+        "add": cmd_add,
+        "rm": cmd_rm,
+        "ls": cmd_ls,
+        "cat": cmd_cat,
+    }.get(parsed.command, None)
+    if not callable(cmd):
+        print(f'Unrecognized command "{parsed.command}"')
+        parser.print_usage()
+        exit(1)
+
+    try:
+        cmd(args[1:])
+    except AuthorizedKeysException as e:
+        sys.stderr.write(str(e) + "\n")
+        exit(1)
+
+
+def cmd_id(args: List[str]) -> None:
+    """ Entrypoint for CLI command that shows this hosts SSH public key. """
+    parser = argparse.ArgumentParser()
+    parser.parse_args(args)
+    with open(host_public_key_file, "r") as f:
+        print(f'desec.{os.environ["DESECSTACK_DOMAIN"]} {f.readline().strip()}')
+
+
+def cmd_add(args: List[str]) -> None:
+    """ Entrypoint for CLI command that adds an authorized SSH key. """
+    parser = argparse.ArgumentParser()
+    parser.add_argument("proto", help="Protocol used with given key.")
+    parser.add_argument("key", help="Public key of authorized key pair.")
+    parser.add_argument("label", help="Label under which the key is stored.")
+    parsed = parser.parse_args(args)
+    AuthorizedKeys().add(parsed.proto, parsed.key, parsed.label)
+
+
+def cmd_rm(args: List[str]) -> None:
+    """ Entrypoint for CLI command that removes an authorized SSH key. """
+    parser = argparse.ArgumentParser()
+    parser.add_argument("label", help="The key with this label will be removed.")
+    parsed = parser.parse_args(args)
+    AuthorizedKeys().rm(parsed.label)
+
+
+def cmd_ls(args: List[str]) -> None:
+    """ Entrypoint for CLI command that shows all authorized SSH keys. """
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="store_true",
+        help="Lists all authorized keys.",
+    )
+    parsed = parser.parse_args(args)
+    keys = AuthorizedKeys().ls()
+
+    if not keys:
+        return
+
+    if parsed.verbose:
+        label_length = max(len(label) for label in keys) + 2
+        print(
+            "\n".join(
+                f"{label:{label_length}s} {proto_key}"
+                for label, proto_key in keys.items()
+            )
+        )
+    else:
+        print("\n".join(keys))
+
+
+def cmd_cat(args: List[str]) -> None:
+    """ Entrypoint for CLI command that outputs an exact copy of the SSH authorized keys file. """
+    parser = argparse.ArgumentParser()
+    parser.parse_args(args)
+    try:
+        with open(authorized_keys_file, "r") as f:
+            print(f.read())
+    except FileNotFoundError:
+        sys.stderr.write(f'Authorized keys file not found at "{authorized_keys_file}".')
+        exit(1)
+
+
+if __name__ == "__main__":
+    main(sys.argv[1:])

+ 6 - 0
gitzones/entrypoint.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+if ! test -f /etc/ssh/keys/ssh_host_ed25519_key; then
+  ssh-keygen -t ed25519 -f /etc/ssh/keys/ssh_host_ed25519_key
+fi
+touch /etc/ssh/keys/git_authorized_keys
+exec /usr/sbin/sshd -D -e  # -D to not daemonize, -e to log to stdout/stderr

+ 3 - 0
gitzones/git-shell-commands/no-interactive-login

@@ -0,0 +1,3 @@
+#!/bin/sh
+printf '%s\n' "No interactive login."
+exit 128

+ 24 - 0
gitzones/sshd_config

@@ -0,0 +1,24 @@
+# Auth & security settings
+PasswordAuthentication no
+ChallengeResponseAuthentication no
+AuthenticationMethods publickey
+PermitRootLogin no
+AllowUsers git
+
+# Features
+AllowAgentForwarding no
+AllowTcpForwarding no
+X11Forwarding no
+PermitTTY no
+PrintMotd no
+
+# Logging
+LogLevel INFO
+
+# Keys
+HostKey /etc/ssh/keys/ssh_host_ed25519_key
+
+# Mozilla's "Modern" Cipher Settings (https://infosec.mozilla.org/guidelines/openssh)
+KexAlgorithms curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256
+Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
+MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,umac-128@openssh.com