about summary refs log tree commit diff
path: root/nixos/cobalt
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/cobalt')
-rw-r--r--nixos/cobalt/configuration.nix145
-rw-r--r--nixos/cobalt/hardware-configuration.nix65
-rw-r--r--nixos/cobalt/modules/git-daemon.nix137
-rw-r--r--nixos/cobalt/modules/soju.nix132
-rw-r--r--nixos/cobalt/services/README.md5
-rw-r--r--nixos/cobalt/services/acme.nix26
-rw-r--r--nixos/cobalt/services/cgit.nix105
-rw-r--r--nixos/cobalt/services/fail2ban.nix5
-rw-r--r--nixos/cobalt/services/git-daemon.nix15
-rw-r--r--nixos/cobalt/services/gitolite-noncore/fix-refs9
-rw-r--r--nixos/cobalt/services/gitolite-noncore/rename62
-rw-r--r--nixos/cobalt/services/gitolite.nix109
-rw-r--r--nixos/cobalt/services/nginx.nix15
-rw-r--r--nixos/cobalt/services/soju.nix26
14 files changed, 856 insertions, 0 deletions
diff --git a/nixos/cobalt/configuration.nix b/nixos/cobalt/configuration.nix
new file mode 100644
index 0000000..c596536
--- /dev/null
+++ b/nixos/cobalt/configuration.nix
@@ -0,0 +1,145 @@
+# Edit this configuration file to define what should be installed on
+# your system.  Help is available in the configuration.nix(5) man page
+# and in the NixOS manual (accessible by running ‘nixos-help’).
+
+{ config, pkgs, lib, ... }:
+let
+  ipv4 = {
+    address = "95.216.74.104";
+    gateway = "95.216.74.65";
+    netmask = "255.255.255.192";
+    prefixLength = 26; # https://www.pawprint.net/designresources/netmask-converter.php
+  };
+  ipv6 = {
+    address = "2a01:4f9:2b:a98::";
+    gateway = "fe80::1";
+    prefixLength = 64;
+  };
+  networkInterface = "eth0";
+  hostName = "cobalt";
+  hostId = "712ae82a";
+in
+{
+  imports =
+    [
+      # Include the results of the hardware scan.
+      ./hardware-configuration.nix
+
+      ./services/acme.nix
+      ./services/nginx.nix
+      ./services/fail2ban.nix
+      ./services/soju.nix
+      ./services/gitolite.nix
+      ./services/git-daemon.nix
+      ./services/cgit.nix
+    ];
+
+  boot.supportedFilesystems = [ "zfs" ];
+  networking.hostId = hostId;
+
+  boot.loader.grub.enable = true;
+  # boot.loader.grub.version = 2;
+  boot.loader.grub.efiSupport = false;
+  # boot.loader.grub.device = "nodev";
+
+  # This should be done automatically, but explicitly declare it just in case.
+  boot.loader.grub.copyKernels = true;
+  # Make sure that you've listed all of the boot partitions here.
+  boot.loader.grub.mirroredBoots = [
+    { path = "/boot"; devices = [ "/dev/disk/by-id/ata-ST4000NM0245-1Z2107_ZC17GW7G" ]; }
+    { path = "/boot-fallback"; devices = [ "/dev/disk/by-id/ata-ST4000NM0245-1Z2107_ZC17GWB2" ]; }
+  ];
+
+  # Boot normally when one of the boot partitions are missing
+  fileSystems."/boot".options = [ "nofail" ];
+  fileSystems."/boot-fallback".options = [ "nofail" ];
+
+  # Erase your darlings
+  boot.initrd.postDeviceCommands = lib.mkAfter ''
+    zfs rollback -r rpool/local/root@blank
+  '';
+
+  # NOTE: replace these to boot.initrd.availableKernelModules?
+  boot.kernelModules = [ "e1000e" ];
+  boot.initrd.kernelModules = [ "e1000e" ];
+
+  boot.kernelParams = [
+    # See <https:#www.kernel.org/doc/Documentation/filesystems/nfs/nfsroot.txt> for documentation.
+    # ip=<client-ip>:<server-ip>:<gw-ip>:<netmask>:<hostname>:<device>:<autoconf>:<dns0-ip>:<dns1-ip>:<ntp0-ip>
+    # The server ip refers to the NFS server -- not needed in this case.
+    "ip=${ipv4.address}::${ipv4.gateway}:${ipv4.netmask}:${hostName}-initrd:${networkInterface}:off:8.8.8.8"
+  ];
+
+  boot.initrd.network.enable = true;
+  boot.initrd.network.ssh = {
+    enable = true;
+
+    # Using the same port as the actual SSH will cause clients to throw errors
+    # related to host key mismatch.
+    port = 2222;
+
+    # This takes 'path's, not 'string's.
+    hostKeys = [
+      /boot/initrd-ssh-key
+      /boot-fallback/initrd-ssh-key
+    ];
+
+    # Public ssh key to log into the initrd ssh
+    authorizedKeys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDi7GGOGVj1Y5Sc1EW6zEdrp78dS6hvmS348pqu9dUsB openpgp:0x6BE7BD6F" ];
+  };
+  boot.initrd.network.postCommands = ''
+    cat <<EOF > /root/.profile
+    if pgrep -x "zfs" > /dev/null
+    then
+      zfs load-key -a
+      killall zfs
+    else
+      echo "ZFS is not running -- this could be a sign of failure."
+    fi
+    EOF
+  '';
+
+
+  networking.hostName = hostName; # Define your hostname.
+
+  networking.useDHCP = false;
+  networking.interfaces.${networkInterface} = {
+    ipv4 = { addresses = [{ address = ipv4.address; prefixLength = ipv4.prefixLength; }]; };
+    ipv6 = { addresses = [{ address = ipv6.address; prefixLength = ipv6.prefixLength; }]; };
+  };
+  networking.defaultGateway = ipv4.gateway;
+  networking.defaultGateway6 = { address = ipv6.gateway; interface = networkInterface; };
+  networking.nameservers = [ "8.8.8.8" ];
+
+  # Set your time zone.
+  time.timeZone = "UTC";
+
+  users.users.root.initialHashedPassword = "";
+  users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDi7GGOGVj1Y5Sc1EW6zEdrp78dS6hvmS348pqu9dUsB openpgp:0x6BE7BD6F" ];
+  services.openssh.enable = true;
+  services.openssh.permitRootLogin = "prohibit-password";
+  # mkdir -p /persist/etc/ssh
+  services.openssh.hostKeys = [
+    {
+      path = "/persist/ssh/ssh_host_ed25519_key";
+      type = "ed25519";
+    }
+    {
+      path = "/persist/ssh/ssh_host_rsa_key";
+      type = "rsa";
+      bits = 4096;
+    }
+  ];
+
+  # impermanence requirement
+  fileSystems."/persist".neededForBoot = true;
+
+  # This value determines the NixOS release from which the default
+  # settings for stateful data, like file locations and database versions
+  # on your system were taken. It‘s perfectly fine and recommended to leave
+  # this value at the release version of the first install of this system.
+  # Before changing this value read the documentation for this option
+  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+  system.stateVersion = "23.05"; # Did you read the comment?
+}
+
diff --git a/nixos/cobalt/hardware-configuration.nix b/nixos/cobalt/hardware-configuration.nix
new file mode 100644
index 0000000..95ecb96
--- /dev/null
+++ b/nixos/cobalt/hardware-configuration.nix
@@ -0,0 +1,65 @@
+# Do not modify this file!  It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations.  Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+  imports =
+    [
+      (modulesPath + "/installer/scan/not-detected.nix")
+    ];
+
+  boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "sd_mod" ];
+  boot.initrd.kernelModules = [ ];
+  boot.kernelModules = [ "kvm-intel" ];
+  boot.extraModulePackages = [ ];
+
+  fileSystems."/" =
+    {
+      device = "rpool/local/root";
+      fsType = "zfs";
+    };
+
+  fileSystems."/boot" =
+    {
+      device = "/dev/disk/by-uuid/445A-0C55";
+      fsType = "vfat";
+    };
+
+  fileSystems."/boot-fallback" =
+    {
+      device = "/dev/disk/by-uuid/445C-198F";
+      fsType = "vfat";
+    };
+
+  fileSystems."/nix" =
+    {
+      device = "rpool/local/nix";
+      fsType = "zfs";
+    };
+
+  fileSystems."/home" =
+    {
+      device = "rpool/safe/home";
+      fsType = "zfs";
+    };
+
+  fileSystems."/persist" =
+    {
+      device = "rpool/safe/persist";
+      fsType = "zfs";
+    };
+
+  swapDevices = [ ];
+
+  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
+  # (the default) this is the recommended approach. When using systemd-networkd it's
+  # still possible to use this option, but it's recommended to use it in conjunction
+  # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
+  networking.useDHCP = lib.mkDefault false;
+  # networking.interfaces.enp0s31f6.useDHCP = lib.mkDefault true;
+
+  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
+  powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
+  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
+}
diff --git a/nixos/cobalt/modules/git-daemon.nix b/nixos/cobalt/modules/git-daemon.nix
new file mode 100644
index 0000000..76b395e
--- /dev/null
+++ b/nixos/cobalt/modules/git-daemon.nix
@@ -0,0 +1,137 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+
+  cfg = config.services.gitDaemon;
+
+in
+{
+
+  ###### interface
+
+  options = {
+    services.gitDaemon = {
+
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Enable Git daemon, which allows public hosting of git repositories
+          without any access controls. This is mostly intended for read-only access.
+
+          You can allow write access by setting daemon.receivepack configuration
+          item of the repository to true. This is solely meant for a closed LAN setting
+          where everybody is friendly.
+
+          If you need any access controls, use something else.
+        '';
+      };
+
+      basePath = mkOption {
+        type = types.str;
+        default = "";
+        example = "/srv/git/";
+        description = lib.mdDoc ''
+          Remap all the path requests as relative to the given path. For example,
+          if you set base-path to /srv/git, then if you later try to pull
+          git://example.com/hello.git, Git daemon will interpret the path as /srv/git/hello.git.
+        '';
+      };
+
+      exportAll = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Publish all directories that look like Git repositories (have the objects
+          and refs subdirectories), even if they do not have the git-daemon-export-ok file.
+
+          If disabled, you need to touch .git/git-daemon-export-ok in each repository
+          you want the daemon to publish.
+
+          Warning: enabling this without a repository whitelist or basePath
+          publishes every git repository you have.
+        '';
+      };
+
+      repositories = mkOption {
+        type = types.listOf types.str;
+        default = [ ];
+        example = [ "/srv/git" "/home/user/git/repo2" ];
+        description = lib.mdDoc ''
+          A whitelist of paths of git repositories, or directories containing repositories
+          all of which would be published. Paths must not end in "/".
+
+          Warning: leaving this empty and enabling exportAll publishes all
+          repositories in your filesystem or basePath if specified.
+        '';
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "";
+        example = "example.com";
+        description = lib.mdDoc "Listen on a specific IP address or hostname.";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 9418;
+        description = lib.mdDoc "Port to listen on.";
+      };
+
+      options = mkOption {
+        type = types.str;
+        default = "";
+        description = lib.mdDoc "Extra configuration options to be passed to Git daemon.";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "User under which Git daemon would be running.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "Group under which Git daemon would be running.";
+      };
+
+      createUserAndGroup = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc ''
+          Create the specified group and user.
+          Disable this option if you want to use the existing user
+        '';
+      };
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+
+    users.users.${cfg.user} = optionalAttrs (cfg.createUserAndGroup == true) {
+      uid = config.ids.uids.git;
+      group = cfg.group;
+      description = "Git daemon user";
+    };
+
+    users.groups.${cfg.group} = optionalAttrs (cfg.createUserAndGroup == true) {
+      gid = config.ids.gids.git;
+    };
+
+    systemd.services.git-daemon = {
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      script = "${pkgs.git}/bin/git daemon --reuseaddr "
+        + (optionalString (cfg.basePath != "") "--base-path=${cfg.basePath} ")
+        + (optionalString (cfg.listenAddress != "") "--listen=${cfg.listenAddress} ")
+        + "--port=${toString cfg.port} --user=${cfg.user} --group=${cfg.group} ${cfg.options} "
+        + "--verbose " + (optionalString cfg.exportAll "--export-all ") + concatStringsSep " " cfg.repositories;
+    };
+
+  };
+
+}
diff --git a/nixos/cobalt/modules/soju.nix b/nixos/cobalt/modules/soju.nix
new file mode 100644
index 0000000..d14082c
--- /dev/null
+++ b/nixos/cobalt/modules/soju.nix
@@ -0,0 +1,132 @@
+# Not an overlay, module replacement
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.soju;
+  stateDir = "/var/lib/soju";
+  listenCfg = concatMapStringsSep "\n" (l: "listen ${l}") cfg.listen;
+  tlsCfg = optionalString (cfg.tlsCertificate != null)
+    "tls ${cfg.tlsCertificate} ${cfg.tlsCertificateKey}";
+  logCfg = optionalString cfg.enableMessageLogging
+    "log fs ${stateDir}/logs";
+
+  configFile = pkgs.writeText "soju.conf" ''
+    ${listenCfg}
+    hostname ${cfg.hostName}
+    ${tlsCfg}
+    db sqlite3 ${stateDir}/soju.db
+    ${logCfg}
+    http-origin ${concatStringsSep " " cfg.httpOrigins}
+    accept-proxy-ip ${concatStringsSep " " cfg.acceptProxyIP}
+
+    ${cfg.extraConfig}
+  '';
+in
+{
+  ###### interface
+
+  options.services.soju = {
+    enable = mkEnableOption (lib.mdDoc "soju");
+
+    listen = mkOption {
+      type = types.listOf types.str;
+      default = [ ":6697" ];
+      description = lib.mdDoc ''
+        Where soju should listen for incoming connections. See the
+        `listen` directive in
+        {manpage}`soju(1)`.
+      '';
+    };
+
+    hostName = mkOption {
+      type = types.str;
+      default = config.networking.hostName;
+      defaultText = literalExpression "config.networking.hostName";
+      description = lib.mdDoc "Server hostname.";
+    };
+
+    tlsCertificate = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.cert";
+      description = lib.mdDoc "Path to server TLS certificate.";
+    };
+
+    tlsCertificateKey = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.key";
+      description = lib.mdDoc "Path to server TLS certificate key.";
+    };
+
+    enableMessageLogging = mkOption {
+      type = types.bool;
+      default = true;
+      description = lib.mdDoc "Whether to enable message logging.";
+    };
+
+    httpOrigins = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        List of allowed HTTP origins for WebSocket listeners. The parameters are
+        interpreted as shell patterns, see
+        {manpage}`glob(7)`.
+      '';
+    };
+
+    acceptProxyIP = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        Allow the specified IPs to act as a proxy. Proxys have the ability to
+        overwrite the remote and local connection addresses (via the X-Forwarded-\*
+        HTTP header fields). The special name "localhost" accepts the loopback
+        addresses 127.0.0.0/8 and ::1/128. By default, all IPs are rejected.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = types.lines;
+      default = "";
+      description = lib.mdDoc "Lines added verbatim to the configuration file.";
+    };
+
+    extraGroups = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc "Extra groups for the dynamic user.";
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = (cfg.tlsCertificate != null) == (cfg.tlsCertificateKey != null);
+        message = ''
+          services.soju.tlsCertificate and services.soju.tlsCertificateKey
+          must both be specified to enable TLS.
+        '';
+      }
+    ];
+
+    systemd.services.soju = {
+      description = "soju IRC bouncer";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network-online.target" ];
+      serviceConfig = {
+        DynamicUser = true;
+        SupplementaryGroups = cfg.extraGroups;
+        Restart = "always";
+        ExecStart = "${pkgs.soju}/bin/soju -config ${configFile}";
+        StateDirectory = "soju";
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ malvo ];
+}
diff --git a/nixos/cobalt/services/README.md b/nixos/cobalt/services/README.md
new file mode 100644
index 0000000..89d9ca5
--- /dev/null
+++ b/nixos/cobalt/services/README.md
@@ -0,0 +1,5 @@
+# colmena/cobalt/services
+
+A list of 'pluggable' services.
+TODO: this should be moved to /modules/ and
+converted to modules.
diff --git a/nixos/cobalt/services/acme.nix b/nixos/cobalt/services/acme.nix
new file mode 100644
index 0000000..b41ae1c
--- /dev/null
+++ b/nixos/cobalt/services/acme.nix
@@ -0,0 +1,26 @@
+let
+  poorObfuscation = y: x: "${x}@${y}";
+in
+{
+  security.acme = {
+    acceptTerms = true;
+    defaults.email = poorObfuscation "sefidel.com" "postmaster";
+    certs = {
+      "sefidel.com" = {
+        domain = "*.sefidel.com";
+        dnsProvider = "hetzner";
+        dnsPropagationCheck = true;
+        credentialsFile = "/persist/secrets/hetzner.key";
+      };
+    };
+  };
+
+  environment.persistence."/persist".directories = [
+    "/var/lib/acme"
+  ];
+
+  deployment.keys."hetzner.key" = {
+    keyCommand = [ "pass" "show" "server/hetzner-dns" ];
+    destDir = "/persist/secrets";
+  };
+}
diff --git a/nixos/cobalt/services/cgit.nix b/nixos/cobalt/services/cgit.nix
new file mode 100644
index 0000000..4e030c8
--- /dev/null
+++ b/nixos/cobalt/services/cgit.nix
@@ -0,0 +1,105 @@
+{ pkgs, ... }:
+
+{
+  services.uwsgi = {
+    enable = true;
+    user = "nginx";
+    group = "nginx";
+    plugins = [ "cgi" ];
+
+    instance = {
+      type = "emperor";
+      vassals = {
+        cgit = {
+          type = "normal";
+          master = true;
+          socket = "/run/uwsgi/cgit.sock";
+          procname-master = "uwsgi cgit";
+          plugins = [ "cgi" ];
+          cgi = "${pkgs.cgit-pink}/cgit/cgit.cgi";
+        };
+      };
+    };
+  };
+
+  users.extraUsers.nginx.extraGroups = [ "git" ];
+
+  services.nginx.virtualHosts."git.sefidel.com" = {
+    addSSL = true;
+    useACMEHost = "sefidel.com";
+    root = "${pkgs.cgit-pink}/cgit";
+    locations = {
+      "/" = {
+        extraConfig = ''
+          try_files $uri @cgit;
+        '';
+      };
+      "@cgit" = {
+        extraConfig = ''
+          uwsgi_pass unix:/run/uwsgi/cgit.sock;
+          include ${pkgs.nginx}/conf/uwsgi_params;
+          uwsgi_modifier1 9;
+        '';
+      };
+    };
+  };
+
+  networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+  systemd.services.create-cgit-cache = {
+    description = "Create cache directory for cgit";
+    enable = true;
+
+    script = ''
+      mkdir -p /run/cgit
+      chown -R nginx:nginx /run/cgit
+    '';
+
+    wantedBy = [ "uwsgi.service" ];
+    serviceConfig = {
+      Type = "oneshot";
+    };
+  };
+
+  environment.etc."cgitrc".text = ''
+    virtual-root=/
+
+    cache-size=1000
+    cache-root=/run/cgit
+
+    root-title=sefidel git
+    root-desc=Exotic place.
+
+    snapshots=tar.gz zip
+
+    enable-git-config=1
+    remove-suffix=1
+
+    enable-git-clone=1
+    enable-index-links=1
+    enable-commit-graph=1
+    enable-log-filecount=1
+    enable-log-linecount=1
+
+    branch-sort=age
+
+    readme=:README
+    readme=:readme
+    readme=:README.md
+    readme=:readme.md
+    readme=:README.org
+    readme=:readme.org
+
+    source-filter=${pkgs.cgit-pink}/lib/cgit/filters/syntax-highlighting.py
+    about-filter=${pkgs.cgit-pink}/lib/cgit/filters/about-formatting.sh
+
+    section-from-path=2
+
+    project-list=/var/lib/gitolite/projects.list
+    scan-path=/var/lib/gitolite/repositories
+  '';
+
+  imports = [
+    ./nginx.nix
+  ];
+}
diff --git a/nixos/cobalt/services/fail2ban.nix b/nixos/cobalt/services/fail2ban.nix
new file mode 100644
index 0000000..9731ef6
--- /dev/null
+++ b/nixos/cobalt/services/fail2ban.nix
@@ -0,0 +1,5 @@
+{
+  services.fail2ban = {
+    enable = true;
+  };
+}
diff --git a/nixos/cobalt/services/git-daemon.nix b/nixos/cobalt/services/git-daemon.nix
new file mode 100644
index 0000000..21e957e
--- /dev/null
+++ b/nixos/cobalt/services/git-daemon.nix
@@ -0,0 +1,15 @@
+{
+  services.gitDaemon = {
+    enable = true;
+    createUserAndGroup = false;
+    basePath = "/var/lib/gitolite/repositories";
+  };
+
+  networking.firewall.allowedTCPPorts = [ 9418 ];
+
+  disabledModules = [ "services/networking/git-daemon.nix" ];
+
+  imports = [
+    ../modules/git-daemon.nix
+  ];
+}
diff --git a/nixos/cobalt/services/gitolite-noncore/fix-refs b/nixos/cobalt/services/gitolite-noncore/fix-refs
new file mode 100644
index 0000000..8ffec9e
--- /dev/null
+++ b/nixos/cobalt/services/gitolite-noncore/fix-refs
@@ -0,0 +1,9 @@
+[[ $4 == W ]] || exit 0
+
+cd $GL_REPO_BASE/$2.git
+
+head=`git symbolic-ref HEAD`
+[[ -f $head ]] || {
+  set -- refs/heads/*
+  git symbolic-ref HEAD $1
+}
diff --git a/nixos/cobalt/services/gitolite-noncore/rename b/nixos/cobalt/services/gitolite-noncore/rename
new file mode 100644
index 0000000..00aa5ca
--- /dev/null
+++ b/nixos/cobalt/services/gitolite-noncore/rename
@@ -0,0 +1,62 @@
+
+# Usage:    ssh git@host rename [-c] <repo1> <repo2>
+#
+# Renames repo1 to repo2. You must be the creator of repo1, and have
+# create ("C") permissions for repo2, which of course must not exist.
+# Alternatively you must be an account admin, that is, you must have
+# write access to the gitolite-admin repository. If you have "C"
+# permissions for repo2 then you can use the -c option to take over
+# as creator of the repository.
+
+die() { echo "$@" >&2; exit 1; }
+usage() { perl -lne 'print substr($_, 2) if /^# Usage/../^$/' < $0; exit 1; }
+[ -z "$1" ] && usage
+[ "$1" = "-h" ] && usage
+[ -z "$GL_USER" ] && die GL_USER not set
+
+# ----------------------------------------------------------------------
+
+if [ "$1" = "-c" ]
+then	shift
+	takeover=true
+else	takeover=false
+fi
+
+from="$1"; shift
+to="$1"; shift
+[ -z "$to" ] && usage
+
+topath=$GL_REPO_BASE/$to.git
+
+checkto() {
+	gitolite access -q "$to" $GL_USER ^C any ||
+		die "'$to' already exists or you are not allowed to create it"
+}
+
+if gitolite access -q gitolite-admin $GL_USER
+then
+	# the user is an admin so we can avoid most permission checks
+	if $takeover
+	then checkto
+	elif [ -e $topath ]
+	then die "'$to' already exists"
+	fi
+else
+	# the user isn't an admin, so do all the checks
+	checkto
+	gitolite creator "$from" $GL_USER ||
+		die "'$from' does not exist or you are not allowed to delete it"
+fi
+
+# ----------------------------------------------------------------------
+
+mv $GL_REPO_BASE/$from.git $topath
+[ $? -ne 0 ] && exit 1
+
+$takeover && echo $GL_USER > $topath/gl-creator
+
+[ -f "$HOME/projects.list" ] && sed "s:$from.git$:$to.git:g" -i "$HOME/projects.list"
+
+echo "$from renamed to $to" >&2
+
+exit
diff --git a/nixos/cobalt/services/gitolite.nix b/nixos/cobalt/services/gitolite.nix
new file mode 100644
index 0000000..94c7ac9
--- /dev/null
+++ b/nixos/cobalt/services/gitolite.nix
@@ -0,0 +1,109 @@
+{ pkgs, ... }:
+
+let
+  # https://groups.google.com/g/gitolite/c/NwZ1-hq9-9E/m/mDbiKyAvDwAJ
+  fixRefsTrigger = pkgs.writeText "fix-refs" ''
+    [[ $4 == W ]] || exit 0
+
+    cd $GL_REPO_BASE/$2.git
+
+    head=`git symbolic-ref HEAD`
+    [[ -f $head ]] || {
+      set -- refs/heads/*
+      git symbolic-ref HEAD $1
+    }
+  '';
+in
+{
+  services.gitolite = {
+    enable = true;
+    user = "git";
+    group = "git";
+    adminPubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDi7GGOGVj1Y5Sc1EW6zEdrp78dS6hvmS348pqu9dUsB openpgp:0x6BE7BD6F";
+    extraGitoliteRc = ''
+      $RC{UMASK} = 0027;
+      $RC{GIT_CONFIG_KEYS} = '.*';
+      $RC{ROLES}{OWNERS} = 1;
+      $RC{OWNER_ROLENAME} = 'OWNERS';
+      # For some unknown reason, $ENV{HOME} doesn't get resolved to the correct
+      # directory.
+      # $RC{LOCAL_CODE} = '$ENV{HOME}/local';
+      $RC{LOCAL_CODE} = '/var/lib/gitolite/local';
+      push(@{$RC{ENABLE}}, 'D');
+      push(@{$RC{ENABLE}}, 'symbolic-ref');
+      push(@{$RC{ENABLE}}, 'rename');
+      push(@{$RC{POST_GIT}}, 'fix-refs');
+      # push(@{$RC{ENABLE}}, 'set-default-roles');
+      # push(@{$RC{ENABLE}}, 'create');
+      # push(@{$RC{ENABLE}}, 'fork');
+
+    '';
+  };
+
+  environment.persistence."/persist".directories = [
+    "/var/lib/gitolite"
+  ];
+
+  system.activationScripts.gitolite-create-local = ''
+    mkdir -p /var/lib/gitolite/local/triggers
+    mkdir -p /var/lib/gitolite/local/commands
+    chown -R git:git /var/lib/gitolite/local
+  '';
+
+  systemd.tmpfiles.rules = [
+    "C /var/lib/gitolite/local/triggers/fix-refs 755 - - - ${./gitolite-noncore/fix-refs}"
+    "C /var/lib/gitolite/local/commands/rename 755 - - - ${./gitolite-noncore/rename}"
+  ];
+
+
+  systemd.timers."gitolite-trash-cleanup" = {
+    wantedBy = [ "timers.target" ];
+    timerConfig = {
+      OnCalendar = "*-*-* 00:00:00";
+      Unit = "gitolite-trash-cleanup.service";
+    };
+  };
+
+  systemd.services."gitolite-trash-cleanup" = {
+    script = ''
+      set -euo pipefail
+      if [ ! -d "Trash" ] ; then
+        echo Trash directory is nonexistent!
+        echo No operations to perform. Exiting.
+        exit 0
+      fi
+
+      match=$(find Trash -type d -regextype posix-extended -regex ".*/[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}:[0-9]{2}:[0-9]{2}$")
+      processed_entry=0
+      removed_entry=0
+
+      for dir in $match
+      do
+        system_timestamp=$(date +%s)
+        trash_timestamp=$(basename $dir | sed -e "s/_/ /g" | date -f - +%s)
+        age=$(( $system_timestamp - $trash_timestamp ))
+        # Wipe trashes older than 2w
+        if [[ age -gt 1209600 ]] ; then
+          echo "Removing '$dir' (age $age)"
+          rm -rf $dir
+          ((removed_entry+=1))
+        fi
+        ((processed_entry+=1))
+      done
+
+      echo "Directories that needs cleanup:"
+      find Trash -type d -empty -print -delete
+      echo "Cleaned empty directories."
+
+      echo "Done! Removed $removed_entry/$processed_entry"
+    '';
+
+    path = with pkgs; [ bash util-linux coreutils ];
+
+    serviceConfig = {
+      Type = "oneshot";
+      User = "git";
+      WorkingDirectory = "/var/lib/gitolite/repositories";
+    };
+  };
+}
diff --git a/nixos/cobalt/services/nginx.nix b/nixos/cobalt/services/nginx.nix
new file mode 100644
index 0000000..cb5ced3
--- /dev/null
+++ b/nixos/cobalt/services/nginx.nix
@@ -0,0 +1,15 @@
+{
+  services.nginx = {
+    enable = true;
+
+    recommendedGzipSettings = true;
+    recommendedOptimisation = true;
+    recommendedTlsSettings = true;
+  };
+
+  users.extraUsers.nginx.extraGroups = [ "acme" ];
+
+  imports = [
+    ./acme.nix
+  ];
+}
diff --git a/nixos/cobalt/services/soju.nix b/nixos/cobalt/services/soju.nix
new file mode 100644
index 0000000..c150879
--- /dev/null
+++ b/nixos/cobalt/services/soju.nix
@@ -0,0 +1,26 @@
+{
+  services.soju = {
+    enable = true;
+    extraGroups = [ "acme" ];
+    hostName = "cobalt.sefidel.com";
+    listen = [
+      ":6697"
+    ];
+    tlsCertificate = "/var/lib/acme/sefidel.com/cert.pem";
+    tlsCertificateKey = "/var/lib/acme/sefidel.com/key.pem";
+  };
+
+  networking.firewall.allowedTCPPorts = [ 6697 ];
+
+  environment.persistence."/persist".directories = [
+    "/var/lib/private/soju"
+  ];
+
+  # TODO: remove this once merged
+  disabledModules = [ "services/networking/soju.nix" ];
+
+  imports = [
+    ./acme.nix
+    ../modules/soju.nix
+  ];
+}