Skip to content

wose/nixos-config

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

43 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NixOS System Configurations

Flakes

Setup

These configs use nix flakes which are not stable yet. To enable them we need to overwrite the nix package by adding the following to the current /etc/nixos/configuration.nix.

nix.package = pkgs.nixUnstable;
nix.extraOptions = ''
  experimental-features = nix-command flakes
'';

After nixos-rebuild switch we should have nix version >= 2.4.

When bootstrapping the installation for a new host we can get nix by installing nixUnstable with nix-env.

nix-env -iA nixpkgs.nixUnstable

To enable flakes we then update /etc/nix/nix.conf.

echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf

Usage

The flake.nix file in this repository contains the flakes for the individual hosts. This file and all other *.nix files are generated by tangling README.org (the file you’re currently reading). The individual *.nix files should not be modified directly since those changes will be overwritten the next time org-babel-tangle is called.

{
  description = "NixOS System Configurations";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs";
    mailserver = {
      url = "gitlab:simple-nixos-mailserver/nixos-mailserver";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, mailserver, nixpkgs }: {
    nixosConfigurations = {
      <<flake/hosts>>
    };
  };

}

New files or folders generated by tangling this file must be added to the git repository or nixos-rebuild won’t pick them up.

Hosts

beholder

Bootstrapping

Hardware

Info

Some hardware info displayed by the Hetzner rescue system after login.

CPU1: AMD Ryzen 7 3700X 8-Core Processor (Cores 16)
Memory:  64251 MB
Disk /dev/sda: 10000 GB (=> 9314 GiB)
Disk /dev/sdb: 10000 GB (=> 9314 GiB)
Total capacity 18 TiB with 2 Disks

Configuration

The basic hardware configuration was created by nixos-generate-config.

{ config, lib, pkgs, modulesPath, ... }:

{
  imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];

  <<beholder/hardware/boot>>

  <<beholder/hardware/filesystems>>

  swapDevices = [ ];

  powerManagement.cpuFreqGovernor = lib.mkDefault "ondemand";
}

Since we are using encrypted partitions and have no way to unlock them without a remote connection, we need to make sure the network interface is available within the initrd. In this case we depend on the r8169 module to be available during boot. Also tell the initrd whick device needs encryption. We also need to set MAILADDR for mdadm or the mdmon service will crash.

boot = {
  kernelModules = [ "kvm-amd" ];
  initrd = {
    availableKernelModules = [ "ahci" "sd_mod" "r8169" ];
    luks.devices."data".device = "/dev/disk/by-uuid/db20b995-5cab-45ae-b1b9-0ba20a73eda6";
  };
  swraid.mdadmConf = "MAILADDR root";
};
fileSystems."/" =
  { device = "/dev/disk/by-uuid/4328df85-8112-495a-ba18-2b23d6fa54e2";
    fsType = "btrfs";
    options = [ "subvol=root" ];
  };

fileSystems."/home" =
  { device = "/dev/disk/by-uuid/4328df85-8112-495a-ba18-2b23d6fa54e2";
    fsType = "btrfs";
    options = [ "subvol=home" ];
  };

fileSystems."/nix" =
  { device = "/dev/disk/by-uuid/4328df85-8112-495a-ba18-2b23d6fa54e2";
    fsType = "btrfs";
    options = [ "subvol=nix" ];
  };

fileSystems."/var" =
  { device = "/dev/disk/by-uuid/4328df85-8112-495a-ba18-2b23d6fa54e2";
    fsType = "btrfs";
    options = [ "subvol=var" ];
  };

fileSystems."/swap" =
  { device = "/dev/disk/by-uuid/4328df85-8112-495a-ba18-2b23d6fa54e2";
    fsType = "btrfs";
    options = [ "subvol=swap" ];
  };

fileSystems."/boot" =
  { device = "/dev/disk/by-uuid/22528ebc-c073-447d-a2cb-74854cc11a19";
    fsType = "ext4";
  };

fileSystems."/boot-fallback" =
  { device = "/dev/disk/by-uuid/1da184b8-3e1c-4492-9bc1-fd04a2d0e343";
    fsType = "ext4";
  };

System

Add this host to the flakes nixosConfigurations.

beholder = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [
    mailserver.nixosModules.mailserver
    ./hosts/beholder/configuration.nix
  ];
};
{ config, lib, pkgs, mailserver, ... }:

{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common.nix
    ../../modules/nginx.nix
    ../../modules/misskey.nix
    ../../users/wose.nix
    ../../users/linda.nix
  ];

  <<beholder/cfg/kernel>>

  <<beholder/cfg/bootloader>>

  <<beholder/cfg/initrd>>

  <<beholder/cfg/network>>

  <<beholder/cfg/i18n>>

  <<beholder/cfg/packages>>

  <<beholder/cfg/services>>

  system.stateVersion = "21.05";
}

Kernel

I had some trouble during the initial installation regarding the network connectivity after Grub finished it’s thing. There was no ssh connection possible to unlock the encrypted partition. I fist suspected the predictable network interface naming to do something … well unpredictable. So I disabled it. The resuce system I used to install and troubleshoot the system was Debian based and had this feature also turned off.

But this changed nothing. I then mistrusted the NixOS configuration to configure the initrd network properly (IP address, netmask, etc) and added the corresponding kernel parameters manually.

This also didn’t fix the problem. As it turned out the network interface driver was missing in the initrd. However, I kept the manually set kernel parameters for now.

boot.kernelParams = [
  "net.ifnames=0"
  "ip=136.243.47.110::136.243.47.65:255.255.255.192:beholder:eth0:none"
];

Bootloader

We install Grub on both disks, so we can still boot if one fails. We also need to mirror the boot partition, which holds the kernel, initrd etc. We do this by using the NixOS option mirroredBoots and save us the trouble with configuring another RAID array.

boot.loader.grub = {
  enable = true;
  efiSupport = false;
  devices = [ "/dev/sda" ];
  mirroredBoots = [
    {
      path = "/boot-fallback";
      devices = [ "/dev/sdb" ];
    }
  ];
};

initrd

To be able to unlock the encrypted partition we need to enable networking and a ssh server within the initrd. And also set some public keys which should be allowed to login. The host key was created during the inital bootstrap and should be distributed to the clients to make sure they are talking to the right server when sending the luks passphrase.

boot.initrd.network = {
  enable = true;
  ssh = {
    enable = true;
    port = 22;
    authorizedKeys = config.users.users.wose.openssh.authorizedKeys.keys;
    hostKeys = [ "/etc/secrets/initrd/ssh_host_ed25519_key" ];
  };
};

Network

The hardest choice during every installation of a new system: the hostname. I’m using the D&D 5e Monster Manual as name source.

networking = {
  hostName = "beholder";
  domain = "zuendmasse.de";
};

We only have one NIC. Hetzner doesn’t use DHCP so we disable it and use the provided static configuration.

networking = {
  usePredictableInterfaceNames = false;

  useDHCP = false;
  interfaces.eth0 = {
    ipv4.addresses = [
      {
        address = "136.243.47.110";
        prefixLength = 26;
      }
    ];
    ipv6.addresses = [
      {
        address = "2a01:4f8:212:f45::1";
        prefixLength = 64;
      }
    ];
  };

  defaultGateway = "136.243.47.65";
  defaultGateway6 = {
    address = "fe80::1";
    interface = "eth0";
  };
};

Those are name servers from Hetzner… except the last one.

networking.nameservers = [
  "213.133.98.98"
  "213.133.99.99"
  "213.133.100.100"
  "2a01:4f8:0:a0a1::add:1010"
  "2a01:4f8:0:a102::add:9999"
  "2a01:4f8:0:a111::add:9898"
  "8.8.8.8"
];

i18n

Set the time zone and default locale.

time.timeZone = "Europe/Berlin";

i18n.defaultLocale = "en_US.UTF-8";

Users

We create the users on this system by importing the corresponding users/username.nix files.

Packages

environment.systemPackages = with pkgs; [
  borgbackup
  emacs-nox
  fd
  pinentry-emacs
  ripgrep
];

Services

PostgreSQL
services.postgresql = {
  enable = true;
  ensureDatabases = [
    <<beholder/db>>
  ];
  ensureUsers = [
    <<beholder/db/users>>
  ];
};
OpenSSH

We disable password authentication and root login. This will also open port 22 in the firewall.

services.openssh = {
  enable = true;
  settings = {
    PermitRootLogin = "no";
    PasswordAuthentication = false;
  };
};
nginx
erlija.de
services.nginx.virtualHosts."erlija.de" = {
  serverAliases = [ "www.erlija.de" "erlija.de" ];
  enableACME = true;
  forceSSL = true;
  root = "/var/www/erlija.de";
};
wittch.de
services.nginx.virtualHosts."wittch.de" = {
  serverAliases = [ "www.wittch.de" "wittch.de" ];
  enableACME = true;
  forceSSL = true;
  root = "/var/www/wittch.de";
};
zuendmasse.de

The rewrite rules are here so old links to the blog posts don’t break. I wasn’t able to construct a regular expression which would match paths with an optional trailing slash. Something like ^/blog/.*/(.*)/?$ didn’t work, the match was greedy and had the slash as part of the match.

services.nginx.virtualHosts."zuendmasse.de" = {
  serverAliases = [ "zuendmasse.de" "www.zuendmasse.de" ];
  enableACME = true;
  forceSSL = true;

  locations."/" = {
    root = "/var/www/zuendmasse.de";
    extraConfig = ''
      rewrite ^/blog/2018/02/23/lets-write-an-embedded-hal-driver.*$ /lets-write-an-embedded-hal-driver.html permanent;
      rewrite ^/blog/2018/01/21/gdb-\+-svd.*$ /gdb-svd.html permanent;
      rewrite ^/blog/2018/01/19/pdf-multi-view.*$ /multi-view-pdf.html permanent;
      rewrite ^/blog/2017/11/03/datenspuren.*$ /datenspuren.html permanent;
      rewrite ^/blog/2017/08/26/embedded-rust.*$ /embedded-rust.html permanent;
      rewrite ^/blog/2017/08/22/reset.*$ /reset.html permanent;
      rewrite ^/blog/$ / permanent;
      rewrite ^/blog$ / permanent;
      rewrite ^/assets/.*/(.+)$ /images/$1 permanent;
      rewrite ^/about.*$ /pages/about.html permanent;
    '';
  };

  listen = [
    { addr = "[::]";    port = 80; ssl = false; }
    { addr = "0.0.0.0"; port = 80; ssl = false; }
    { addr = "[::]";    port = 443; ssl = true; }
    { addr = "0.0.0.0"; port = 443; ssl = true; }
    { addr = "[::]";    port = 8448; ssl = true; }
    { addr = "0.0.0.0"; port = 8448; ssl = true; }
  ];

  locations."/_matrix" = {
    proxyPass = "http://localhost:8008";
  };

  locations."= /.well-known/matrix/server".extraConfig =
    let
      server = { "m.server" = "zuendmasse.de:8448"; };
    in ''
      add_header Content-Type application/json;
      return 200 '${builtins.toJSON server}';
    '';

  locations."= /.well-known/matrix/client".extraConfig =
    let
      client = {
        "m.homeserver" = { "base_url" = "https://zuendmasse.de:8448"; };
        "m.identity_server" = { "base_url" = "https://vector.im"; };
      };
    in ''
      add_header Content-Type application/json;
      add_header Access-Control-Allow-Origin *;
      return 200 '${builtins.toJSON client}';
    '';

};
Mailserver

Local user need to generate the corresponding hashed password files with. This file must be present or the service will not start.

nix shell nixpkgs#apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 > ~/.hashed_passwd_domain
mailserver = {
  enable = true;
  localDnsResolver = false;

  enableImap = true;
  enablePop3 = false;
  enableImapSsl = true;
  enablePop3Ssl = false;

  fqdn = "beholder.zuendmasse.de";
  domains = [ "braunglasmafia.de" "erlija.de" "wittch.de" ];

  loginAccounts = {
    "[email protected]" = {
      hashedPasswordFile = "/home/wose/.hashed_passwd_erlija.de";
      aliases = [
        "[email protected]"
        "[email protected]"
        "[email protected]"
        "[email protected]"
        "[email protected]"
        "[email protected]"
      ];
    };

    "[email protected]" = {
      hashedPasswordFile = "/home/wose/.hashed_passwd_wittch.de";
      aliases = [
        "[email protected]"
        "[email protected]"
        "[email protected]"
      ];
    };

    "[email protected]" = {
      hashedPasswordFile = "/home/linda/.hashed_passwd_erlija.de";
      aliases = [
        "[email protected]"
      ];
    };
  };

  certificateScheme = "acme-nginx";
};
Matrix Synapse

This synapse server was migrated from another machine. Since all users are already in the datebase we don’t need to enable any type of registration. The sqlite3 database and the media files (uploaded data etc.) are stored in /var/lib/matrix-synapse/.

networking.firewall.allowedTCPPorts = [ 8448 1965 6697 ];
networking.firewall.allowedUDPPorts = [ 2456 2457 2458 ];

services.matrix-synapse = {
  enable = true;
  extraConfigFiles = [
    /etc/secrets/matrix-registration-config
  ];
#  registration_shared_secret = builtins.readFile /etc/secrets/matrix-registration;

  settings = {
    listeners = [
      {
        port = 8008;
        bind_addresses = [ "::1" ];
        type = "http";
        tls = false;
        x_forwarded = true;
        resources = [
          {
            names = [ "client" "federation" ];
            compress = false;
          }
        ];
      }
    ];

    database.name = "sqlite3";
    server_name = "zuendmasse.de";
    allow_guest_access = false;
    enable_registration = false;
    max_upload_size = "512M";
  };
};
Forgejo/Gitea
services.gitea = {
    enable = true;
    package = pkgs.forgejo;
    appName = "forgejo";
    settings = {
      service.DISABLE_REGISTRATION = true;
      server = {
        HTTP_PORT = 3200;
        HTTP_ADDR = "127.0.0.1";
        DOMAIN = "git.zuendmasse.de";
        ROOT_URL = "https://git.zuendmasse.de";
        LANDING_PAGE = "/explore/repos";
      };
    };
  };

services.nginx.virtualHosts."git.zuendmasse.de" = {
  enableACME = true;
  forceSSL = true;
  locations."/" = {
    proxyPass = "http://localhost:3200";
  };
};
Fail2ban
services.fail2ban.enable = true;
NextCloud

We use postgresql as database for this nextcloud instance. Don’t forget to create the adminpassFile and the dbpassFile on a fresh bootstrap. Those files need to be readable by the nextcloud user.

services.nextcloud = {
  enable = true;
  package = pkgs.nextcloud27;
  hostName = "cloud.zuendmasse.de";
  https = true;
  enableBrokenCiphersForSSE = false;

  caching = {
    apcu = true;
    memcached = true;
    redis = false;
  };

  config = {
    defaultPhoneRegion = "DE";
    adminuser = "admin";
    adminpassFile = "/etc/secrets/nextcloud-pass";
    dbtype = "pgsql";
    dbname = "nextcloud";
    dbuser = "nextcloud";
    dbpassFile = "/etc/secrets/psql-pass";
    dbhost = "/run/postgresql";
    dbtableprefix = "oc_";
  };

  autoUpdateApps = {
    enable = true;
    startAt = "04:00:00";
  };

  maxUploadSize = "2048M";
};

systemd.services."nextcloud-setup" = {
  requires = ["postgresql.service"];
  after = ["postgresql.service"];
};

services.nginx.virtualHosts."cloud.zuendmasse.de" = {
  enableACME = true;
  forceSSL = true;
  extraConfig = ''
    add_header Strict-Transport-Security "max-age=31536000" always;
    client_body_buffer_size 512k;
  '';
};

Let’s make sure the nextcloud database exists.

"nextcloud"

And add the corresponding db user with the required rights.

{ name = "nextcloud";
  ensurePermissions."DATABASE nextcloud" = "ALL PRIVILEGES";
}

To enable preview generation for larger images we need to increase the maximum preview memory within the nextcloud config file. There is currently no method to add extra options to the config, but we can simply create another file within the nextcloud config directory. The file name must end in .config.php and will then be automatically loaded by nextcloud.

# TODO find out how to write this to /var/lib/nextcloud/config/preview.config.php

<?php
$CONFIG = [
    'preview_max_memory' => 512,
];
Gemini

NixOS has service options to configure the Gemini server molly-brown. This server doesn’t support virtual hosts but since we only have one site anyway we use it for now. Another option, which supports virtual hosts but lags dynamic content (CGI, SCGI), would be agate.

services.molly-brown = {
  hostName = "zuendmasse.de";
  enable = true;
  certPath = "/var/lib/acme/zuendmasse.de/cert.pem";
  keyPath = "/var/lib/acme/zuendmasse.de/key.pem";
  docBase = "/var/gemini/zuendmasse.de";
};

We will use the certificate generated for zuendmasse.de. This means molly-brown must be able to read it.

systemd.services.molly-brown.serviceConfig.SupplementaryGroups = [ config.security.acme.certs."zuendmasse.de".group ];

And make sure we can connect to the server.

#networking.firewall.allowedTCPPorts = [ 1965 ];
Misskey
services.misskey = {
  enable = true;
  settings = {
    url = "https://social.zuendmasse.de/";
    port = 11231;
    id = "aid";
    db = {
      host = "/run/postgresql";
      port = config.services.postgresql.port;
      user = "misskey";
      db = "misskey";
    };
    redis = {
      host = "localhost";
      port = config.services.redis.servers.misskey.port;
    };
  };
};

services.redis.servers.misskey = {
  enable = true;
  bind = "127.0.0.1";
  port = 16434;
};

services.nginx.virtualHosts."social.zuendmasse.de" = {
  enableACME = true;
  forceSSL = true;
  locations = {
    "/" = {
      proxyPass = "http://127.0.0.1:${toString config.services.misskey.settings.port}/";
      proxyWebsockets = true;
    };
  };
};

Make sure the db exists.

"misskey"

And add the corresponding db user with the required rights.

{ name = "misskey";
  ensurePermissions."DATABASE misskey" = "ALL PRIVILEGES";
}

Packages

Some packages which are not in nixpkgs upstream yet.

{ pkgs ? import <nixpkgs> { } }:

{
  gotosocial = pkgs.callPackage ./gotosocial { };
  misskey = pkgs.callPackage ./misskey { };
}

gotosocial

{ buildGoModule, fetchFromGitHub, lib, pkgs }:

buildGoModule rec {
  pname = "gotosocial";
  version = "0.2.1";

  src = fetchFromGitHub {
    owner = "superseriousbusiness";
    repo = "gotosocial";
    rev = "v${version}";
    sha256 = "ldU2wzahCjy2rThcBIuzHCSWtUK1jxDEB+QKBv8Aqrw=";
  };

  vendorSha256 = null;

Currently tests are failing because of some hardcoded path. #412

doCheck = false;
  tags = [ "netgo" "osusergo" "static_build" ];
  ldflags = ["-s" "-w" "-extldflags '-static'" "-X 'main.Version=${version}'" ];

  meta = with lib; {
    description = "Golang fediverse server";
    homepage = "https://docs.gotosocial.org";
    license = licenses.agpl3Only;
    maintainer = with maintainers; [ wose ];
    platforms = platforms.linux;
  };
}

Misskey

This package definition is part of a pending pull reuqest to add Misskey to nixpkgs. #161855

{ lib
, stdenv
, fetchFromGitHub
, fetchYarnDeps
, fixup_yarn_lock
, yarn
, nodejs
, python3
, pkg-config
, glib
, vips
}:

let
  version = "12.108.1";

  src = fetchFromGitHub {
    owner = "misskey-dev";
    repo = "misskey";
    rev = version;
    sha256 = "sha256-NTspyTNy3cqc43+YLeCKRR46D7BvtIWoNCmwgqykHgs=";
  };

  deps = fetchYarnDeps {
    yarnLock = "${src}/yarn.lock";
    sha256 = "sha256-1NEeuBVp5e7RtFzYeT4nTGxGs2oeTxqiz20pEZXmcbo=";
  };
  backendDeps = fetchYarnDeps {
    yarnLock = "${src}/packages/backend/yarn.lock";
    sha256 = "sha256-G01hkYthBCZnsvPNaTIXSgTN9/1inJXhh34umxfxUsc=";
  };
  clientDeps = fetchYarnDeps {
    yarnLock = "${src}/packages/client/yarn.lock";
    sha256 = "sha256-LwGjqHN59KditL3igVP1/TZ7cZSbrZopOl9A0c1nlW8=";
  };

in stdenv.mkDerivation {
  pname = "misskey";
  inherit version src;

  nativeBuildInputs = [ fixup_yarn_lock yarn nodejs python3 pkg-config ];
  buildInputs = [ glib vips ];

  buildPhase = ''
    export HOME=$PWD
    export NODE_ENV=production
    # Build node modules
    fixup_yarn_lock yarn.lock
    fixup_yarn_lock packages/backend/yarn.lock
    fixup_yarn_lock packages/client/yarn.lock
    yarn config --offline set yarn-offline-mirror ${deps}
    yarn install --offline --frozen-lockfile --ignore-engines --ignore-scripts --no-progress
    (
      cd packages/backend
      yarn config --offline set yarn-offline-mirror ${backendDeps}
      yarn install --offline --frozen-lockfile --ignore-engines --ignore-scripts --no-progress
    )
    (
      cd packages/client
      yarn config --offline set yarn-offline-mirror ${clientDeps}
      yarn install --offline --frozen-lockfile --ignore-engines --ignore-scripts --no-progress
    )
    patchShebangs node_modules
    patchShebangs packages/backend/node_modules
    patchShebangs packages/client/node_modules
    (
      cd packages/backend/node_modules/re2
      npm_config_nodedir=${nodejs} npm run rebuild
    )
    (
      cd packages/backend/node_modules/sharp
      npm_config_nodedir=${nodejs} ../.bin/node-gyp rebuild
    )
    yarn build
  '';

  installPhase = ''
    mkdir -p $out/packages/client
    ln -s /var/lib/misskey $out/files
    ln -s /run/misskey $out/.config
    cp -r locales node_modules built $out
    cp -r packages/backend $out/packages/backend
    cp -r packages/client/assets $out/packages/client/assets
  '';

  meta = with lib; {
    description = "Interplanetary microblogging platform. 🚀";
    homepage = "https://misskey-hub.net/";
    license = licenses.agpl3;
    maintainers = with maintainers; [ yuka kloenk ];
    platforms = platforms.unix;
  };
}

Modules

TODOs

Valheim Service

Move zuendmasse mailserver to beholder

Common Settings

We’ll need/want somethings to be available on all hosts. We can export it into modules and import it in each host.

{ pkgs, ... }:

{
  <<common/systempackages>>

  <<common/nix>>

  <<common/nixpkgs>>

  <<common/userconfig>>
}

System Packages

To be able to acutally do something with a freshly installed system we need some tools.

environment.systemPackages = with pkgs; [
  git
  gnupg
  gnutls
  htop
  pass
  pinentry
  vim
  vimPlugins.vim-nix
  wget
  zsh
];

Nix

Since nix flakes are not stable yet, we need to make sure we get nix in version >=2.4.

nix = {
  package = pkgs.nixUnstable;
  extraOptions = ''
    experimental-features = nix-command flakes
  '';
};

nixpkgs

We need to create an overlay to inject our own packages which are currently not part of upstream nixpgs.

nixpkgs = {
  overlays = [
    (self: super:
      (import ../pkgs/default.nix { pkgs = super; })
    )
  ];
};

User configuration

Let’s make sure user can manage their passwords outside of this configuration.

users.mutableUsers = true;

gotosocial service

The goal is to be able to simply enable the gotosocial service and maybe set some config values and be done with it. Something like:

gotosocial = {
  enable = true;
  bind-address = "0.0.0.0";
  port = 8080;
  db-type = "sqlite";
};
{ config, lib, pkgs, ... }:

with lib;

let
  <<gotosocial/let>>
in
{
  <<gotosocial/options>>

  config = mkIf cfg.enable {
    <<gotosocial/config>>
  };
}

Parameter

cfg = config.local.services.gotosocial;
name = "gotosocial";
stateDir = "/var/lib/${name}";
settingsFormat = pkgs.formats.yaml { };
#toYAML = name: data: pkgs.writeText name (generators.toYAML {} data);
#configFile = toYAML "gotosocial.yaml" cfg.settings;
#configFile = settingsFormat.generate "gotosocial.yaml" cfg.settings;
#configFile = "/home/wose/gotosocial/config.yaml";

#configFile = pkgs.writeText "gotosocial.yaml" (
#  generators.toYAML {} { inherit cfg.settings; }
#);

Options

We translate the corresponding config values into nix options. The resulting config is a yaml file.

options.local.services.gotosocial = {
  enable = mkEnableOption "Golang fediverse server";

  configFile = mkOption {
    type = types.path;
    description = "Path to gotosocial config.yaml.";
  };

  settings = mkOption {
#    type = types.submodule {
    type = settingsFormat.type;
#    freeformType = settingsFormat.type;

    options = {
#settings = {
      host = mkOption {
        type = types.str;
        default = "localhost";
        description = ''
          Hostname that this server will be reachable at. Defaults to localhost for local testing,
          but you should *definitely* change this when running for real, or your server won't work at all.
          DO NOT change this after your server has already run once, or you will break things!
        '';
      };

      account-domain = mkOption {
        type = types.str;
        default = "";
        description = ''
          Domain to use when federating profiles. This is useful when you want your server to be at
          eg., "gts.example.org", but you want the domain on accounts to be "example.org" because it looks better
          or is just shorter/easier to remember.
          To make this setting work properly, you need to redirect requests at "example.org/.well-known/webfinger"
          to "gts.example.org/.well-known/webfinger" so that GtS can handle them properly.
          You should also redirect requests at "example.org/.well-known/nodeinfo" in the same way.
          An empty string (ie., not set) means that the same value as 'host' will be used.
          DO NOT change this after your server has already run once, or you will break things!
        '';
      };

      protocol = mkOption {
        type = types.enum [ "http" "https" ];
        default = "https";
        description = ''
          Protocol to use for the server. Only change to http for local testing!
          This should be the protocol part of the URI that your server is actually reachable on. So even if you're
          running GoToSocial behind a reverse proxy that handles SSL certificates for you, instead of using built-in
          letsencrypt, it should still be https.
        '';
      };

      bind-address = mkOption {
        type = types.str;
        default = "0.0.0.0";
        description = ''
          Address to bind the GoToSocial server to.
          This can be an IPv4 address or an IPv6 address (surrounded in square brackets), or a hostname.
          Default value will bind to all interfaces.
          You probably won't need to change this unless you're setting GoToSocial up in some fancy way or
          you have specific networking requirements.
        '';
      };

      port = mkOption {
        type = types.port;
        default = 8080;
        description = ''
          Listen port for the GoToSocial webserver + API. If you're running behind a reverse proxy and/or in a  docker,
          container, just set this to whatever you like (or leave the default), and make sure it's forwarded properly.
          If you are running with built-in letsencrypt enabled, and running GoToSocial directly on a host machine, you will
          probably want to set this to 443 (standard https port), unless you have other services already using that port.
          This *MUST NOT* be the same as the letsencrypt port specified below, unless letsencrypt is turned off.
        '';
      };

      trusted-proxies = mkOption {
        type = types.listOf types.str;
        default = [ "127.0.0.1/32" ];
        description = ''
          CIDRs or IP addresses of proxies that should be trusted when determining real client IP from behind a reverse proxy.
          If you're running inside a Docker container behind Traefik or Nginx, for example, add the subnet of your docker network,
          or the gateway of the docker network, and/or the address of the reverse proxy (if it's not running on the host network).
        '';
      };

      db-type = mkOption {
        type = types.enum [ "postgres" "sqlite" ];
        default = "postgres";
        description = "Database type.";
      };

      db-address = mkOption {
        type = types.str;
        default = "";
        description = ''
          For Postgres, this should be the address or socket at which the database can be reached.

          For Sqlite, this should be the path to your sqlite database file. Eg., /opt/gotosocial/sqlite.db.
          If the file doesn't exist at the specified path, it will be created.
          If just a filename is provided (no directory) then the database will be created in the same directory
          as the GoToSocial binary.
          If address is set to :memory: then an in-memory database will be used (no file).
          WARNING: :memory: should NOT BE USED except for testing purposes.
        '';
      };

      db-port = mkOption {
        type = types.port;
        default = 5432;
        description = "Port for database connection.";
      };

      db-user = mkOption {
        type = types.str;
        default = "";
        description = "Username for the database connection.";
      };

      db-password = mkOption {
        type = types.str;
        default = "";
        description = "Password to use for the database connection.";
      };

      db-database = mkOption {
        type = types.str;
        default = "gotosocial";
        description = "Name of the database to use within the provided database type.";
      };

      db-tls-mode = mkOption {
        type = types.enum [ "disable" "enable" "required" ];
        default = "disable";
        description = ''
          Disable, enable, or require SSL/TLS connection to the database.
          If "disable" then no TLS connection will be attempted.
          If "enable" then TLS will be tried, but the database certificate won't be checked (for self-signed    certs).
          If "require" then TLS will be required to make a connection, and a valid certificate must be presented.
        '';
      };

      db-tls-ca-cert = mkOption {
        type = types.nullOr types.path;
        default = null;
        description = ''
          Path to a CA certificate on the host machine for db certificate validation.
          If this is left empty, just the host certificates will be used.
          If filled in, the certificate will be loaded and added to host certificates.
        '';
      };

      web-template-base-dir = mkOption {
        type = types.path;
        default = "./web/template/";
        description = "Directory from which gotosocial will attempt to load html templates (.tmpl files).";
      };

      web-asset-base-dir = mkOption {
        type = types.path;
        default = "./web/assets/";
        description = "Directory from which gotosocial will attempt to serve static web assets (images, scripts).";
      };

      accounts-registration-open = mkOption {
        type = types.bool;
        default = false;
        description = "Do we want people to be able to just submit sign up requests, or do we want invite only?";
      };

      accounts-approval-required = mkOption {
        type = types.bool;
        default = true;
        description = "Do sign up requests require approval from an admin/moderator before an account can sign in/use the server?";
      };

      accounts-reason-required = mkOption {
        type = types.bool;
        default = false;
        description = "Are sign up requests required to submit a reason for the request (eg., an explanation of why they want to join the instance)?";
      };

      media-image-max-size = mkOption {
        type = types.ints.u32;
        default = 2097152;
        description = "Maximum allowed image upload size in bytes.";
      };

      media-video-max-size = mkOption {
        type = types.ints/u32;
        default = 10485760;
        description = "Maximum allowed video upload size in bytes.";
      };

      media-description-min-chars = mkOption {
        type = types.ints.u32;
        default = 0;
        description = "Minimum amount of characters required as an image or video description.";
      };

      media-description-max-chars = mkOption {
        type = types.ints.u32;
        default = 500;
        description = "Maximum amount of characters permitted in an image or video description.";
      };

      media-remote-cache-days = mkOption {
        type = types.ints.u16;
        default = 30;
        description = ''
          Number of days to cache media from remote instances before they are removed from the cache.
          A job will run every day at midnight to clean up any remote media older than the given amount of days.

          When remote media is removed from the cache, it is deleted from storage but the database entries for the media
          are kept so that it can be fetched again if requested by a user.

          If this is set to 0, then media from remote instances will be cached indefinitely.
        '';
      };

      storage-backend = mkOption {
        type = types.str;
        default = "local";
        description = "Type of storage backend to use.";
      };

      storage-local-base-path = mkOption {
        type = types.path;
        default = "/gotosocial/storage";
        description = ''
          Directory to use as a base path for storing files.
          Make sure whatever user/group gotosocial is running as has permission to access
          this directory, and create new subdirectories and files within it.
        '';
      };

      statuses-max-chars = mkOption {
        type = types.ints.u16;
        default = 5000;
        description = ''
          Maximum amount of characters permitted for a new status.
          Note that going way higher than the default might break federation.
        '';
      };

      statuses-cw-max-chars = mkOption {
        type = types.ints.u16;
        default = 100;
        description = ''
          Maximum amount of characters allowed in the CW/subject header of a status.
          Note that going way higher than the default might break federation.
        '';
      };

      statuses-poll-max-options = mkOption {
        type = types.ints.u8;
        default = 6;
        description = ''
          Maximum amount of options to permit when creating a new poll.
          Note that going way higher than the default might break federation.
        '';
      };

      statuses-poll-option-max-chars = mkOption {
        type = types.ints.u8;
        default = 50;
        description = ''
          Maximum amount of characters to permit per poll option when creating a new poll.
          Note that going way higher than the default might break federation.
        '';
      };

      statuses-media-max-files = mkOption {
        type = types.ints.u8;
        default = 6;
        description = ''
          Maximum amount of media files that can be attached to a new status.
          Note that going way higher than the default might break federation.
        '';
      };

      letsencrypt-enabled = mkOption {
        type = types.bool;
        default = false;
        description = ''
          Whether or not letsencrypt should be enabled for the server.
          If false, the rest of the settings here will be ignored.
          If you serve GoToSocial behind a reverse proxy like nginx or traefik, leave this turned off.
          If you don't, then turn it on so that you can use https.
        '';
      };

      letsencrypt-port = mkOption {
        type = types.port;
        default = 80;
        description = ''
          Port to listen for letsencrypt certificate challenges on.
          If letsencrypt is enabled, this port must be reachable or you won't be able to obtain certs.
          If letsencrypt is disabled, this port will not be used.
          This *must not* be the same as the webserver/API port specified.
        '';
      };

      letsencrypt-cert-dir = mkOption {
        type = types.path;
        default = "/gotosocial/storage/certs";
        description = ''
          Directory in which to store LetsEncrypt certificates.
          It is a good move to make this a sub-path within your storage directory, as it makes
          backup easier, but you might wish to move them elsewhere if they're also accessed by other services.
          In any case, make sure GoToSocial has permissions to write to / read from this directory.
        '';
      };

      letsencrypt-email-address = mkOption {
        type = types.str;
        default = "";
        description = ''
          Email address to use when registering LetsEncrypt certs.
          Most likely, this will be the email address of the instance administrator.
          LetsEncrypt will send notifications about expiring certificates etc to this address.
        '';
      };

      oidc-enabled = mkOption {
        type = types.bool;
        default = false;
        description = ''
          Enable authentication with external OIDC provider. If set to true, then
          the other OIDC options must be set as well. If this is set to false, then the standard
          internal oauth flow will be used, where users sign in to GtS with username/password.
        '';
      };

      oidc-idp-name = mkOption {
        type = types.str;
        default = "";
        description = ''
          Name of the oidc idp (identity provider). This will be shown to users when
          they log in.
        '';
      };

      oidc-skip-verification = mkOption {
        type = types.bool;
        default = false;
        description = ''
          Skip the normal verification flow of tokens returned from the OIDC provider, ie.,
          don't check the expiry or signature. This should only be used in debugging or testing,
          never ever in a production environment as it's extremely unsafe!
        '';
      };

      oidc-issuer = mkOption {
        type = types.str;
        default = "";
        description = ''
          The OIDC issuer URI. This is where GtS will redirect users to for login.
          Typically this will look like a standard web URL.
        '';
      };

      oidc-client-id = mkOption {
        type = types.str;
        default = "";
        description = "The ID for this client as registered with the OIDC provider.";
      };

      oidc-client-secret = mkOption {
        type = types.str;
        default = "";
        description = "The secret for this client as registered with the OIDC provider.";
      };

      oidc-scopes = mkOption {
        type = types.listOf types.str;
        default = [ "openid" "email" "profile" "groups" ];
        description = ''
          Scopes to request from the OIDC provider. The returned values will be used to
          populate users created in GtS as a result of the authentication flow. 'openid' and 'email' are  required.
          'profile' is used to extract a username for the newly created user.
          'groups' is optional and can be used to determine if a user is an admin (if they're in the group 'admin' or 'admins').
        '';
      };

      smtp-host = mkOption {
        type = types.str;
        default = "";
        description = ''
          The hostname of the smtp server you want to use.
          If this is not set, smtp will not be used to send emails, and you can ignore the other settings.
        '';
      };

      smtp-port = mkOption {
        type = types.port;
        default = 0;
        description = "Port to use to connect to the smtp server.";
      };

      smtp-username = mkOption {
        type = types.str;
        default = "";
        description = "Username to use when authenticating with the smtp server.";
      };

      smtp-password = mkOption {
        type = types.str;
        default = "";
        description = "Password to use when authenticating with the smtp server.";
      };

      smtp-from = mkOption {
        type = types.str;
        default = "";
        description = "'From' address for sent emails.";
      };

      syslog-enable = mkOption {
        type = types.bool;
        default = false;
        description = "Enable the syslog logging hook. Logs will be mirrored to the configured destination.";
      };

      syslog-protocol = mkOption {
        type = types.str;
        default = "udp";
        description = "Protocol to use when directing logs to syslog. Leave empty to connect to local syslog.";
      };

      syslog-address = mkOption {
        type = types.str;
        default = "localhost:514";
        description = "Address:port to send syslog logs to. Leave empty to connect to local syslog.";
      };
    };
    };
  };
#};

User

The user the service runs as.

local.services.gotosocial.configFile = mkDefault (settingsFormat.generate "gotosocial.yaml" cfg.settings);

users = {
  users.gotosocial = {
    isSystemUser = true;
    group = name;
    home = stateDir;
  };

  groups.gotosocial = { };
};

Service

systemd.services.gotosocial = {
  description = "GoToSocial fediverse server";
  wantedBy = [ "multi-user.target" ];

  serviceConfig = {
    Restart = "on-failure";
    User = name;
    StateDirectory = name;

    ExecStart = "${pkgs.gotosocial}/bin/gotosocial  --config-path ${cfg.configFile} server start";

    WorkingDirectory = stateDir;
  };
};

Misskey service

This service definition is part of a pending pull request to add Misskey to nixpkgs. #161855

{ config, lib, pkgs, ... }:

let
  cfg = config.services.misskey;

  settingsFormat = pkgs.formats.yaml {};
  configFile = settingsFormat.generate "misskey-config.yml" cfg.settings;
in {
  options = {
    services.misskey = with lib; {
      enable = mkEnableOption "misskey";

      settings = mkOption {
        type = settingsFormat.type;
        default = {};
        description = ''
          Configuration for Misskey, see
          <link xlink:href="https://github.com/misskey-dev/misskey/blob/develop/.config/example.yml"/>
          for supported settings.
        '';
      };
    };
  };

  config = lib.mkIf cfg.enable {
    documentation.enable = false;

    systemd.services.misskey = {
      after = [ "network-online.target" "postgresql.service" ];
      wantedBy = [ "multi-user.target" ];
      preStart = ''
        ${pkgs.envsubst}/bin/envsubst -i "${configFile}" > /run/misskey/default.yml
        cd ${pkgs.misskey}/packages/backend
        ./node_modules/.bin/typeorm migration:run
      '';
      serviceConfig = {
        StateDirectory = "misskey";
        StateDirectoryMode = "700";
        RuntimeDirectory = "misskey";
        RuntimeDirectoryMode = "700";
        ExecStart = "${pkgs.nodejs}/bin/node --experimental-json-modules ${pkgs.misskey}/packages/backend/built/index.js";
        TimeoutSec = 240;

        # implies RemoveIPC=, PrivateTmp=, NoNewPrivileges=, RestrictSUIDSGID=,
        # ProtectSystem=strict, ProtectHome=read-only
        DynamicUser = true;
        LockPersonality = true;
        PrivateDevices = true;
        PrivateUsers = true;
        ProtectClock = true;
        ProtectControlGroups = true;
        ProtectHome = true;
        ProtectHostname = true;
        ProtectKernelLogs = true;
        ProtectProc = "invisible";
        ProtectKernelModules = true;
        ProtectKernelTunables = true;
        RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK";
        RestrictNamespaces = true;
        RestrictRealtime = true;
        SystemCallArchitectures = "native";
        SystemCallFilter = "@system-service";
        UMask = "0077";
      };
      environment.NODE_ENV = "production";
    };
  };
  meta.maintainers = with lib.maintainers; [ yuka ];
  meta.doc = ./misskey.xml;
}

nginx

{ pkgs, lib, ... }:

{
  <<nginx/basic>>
}

Firewall

networking.firewall.allowedTCPPorts = [ 80 443 ];

SSL

Enable the Let’s Encrypt CA to be able to autogenerate certificates for virtual hosts.

security.acme.defaults.email = lib.mkDefault "[email protected]";
security.acme.acceptTerms = true;

With this we can use services.nginx.virtualHosts.<name>.enableACME to enable certificate generation for the corresponding virtual host.

Basic

We enable the nginx service and activate some recommended settings.

services.nginx = {
  enable = true;

  recommendedGzipSettings = true;
  recommendedOptimisation = true;
  recommendedProxySettings = true;
  recommendedTlsSettings = true;

  sslCiphers = "TLSv1.2+HIGH+ECDHE@STRENGTH";
};

Users

wose

This user thinks he knows what he does. We set his initial password to be able to gain root after the first login throgh ssh after intial bootstrap of a machine.

{ config, ... }:

{
  users.users.wose = {
      initialHashedPassword = "$6$KQo7A0p2cYsB3$Kpw2XUByh1gPiUA/JQC63w7WlVOrsWX5HqRudSqxjzBJY.R/hiyYUW24HAZSP54iFxUCrqRYDhghM3PBpB8XN1";
      isNormalUser = true;
      extraGroups = [ "wheel" ];
      openssh.authorizedKeys.keys = [
        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDqhXu3cahNdm5EjMbJXgCIMTOqVXD/SO5sRu9ep/EBOAQGQiUYg7Eqx7Ka7gCj2xi+X797DejfgOxFMm1jk4L4YQ5oKc+aehLZM/JLM4dNIqU7tGt2pKv7oGJMVi/u3HX6VPcPqdPIvayBN6Odw4aqtaQY6Pr5R9SJFCftWaeLFXGVOL0FauzMrwhxVgYId5RXtEyVJv9npe0wYEFemnNO4kktsxHxKHC8e+EzggGNRQyCyWOr8arXvjqM/HI5ZnqpWP8/R0hXOHw1WbziHBYNJNT4uwkwXBf2496mCxJCNerJQh/qF/fUUU/C1ix24EHDyx+FkA0aheylAjNAgSopGNfvkQ3Hqz8lnRiyu9WDS6W7liras/oJCmcsh/BmCWO+BQQ6P6q9Y2oC3KM+Rj8WWykjfCchTQeuz1GGx9sQOS8Js2DROlWDGKg4NoY0Clp8brcTmRR+8TiZQak7p3zg6CgFPzZfLPk7BA3gHLO5te753TK8jsKZegdzH6gA0hM="
        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDgV2nv344CXTk+SX89W2QMPHLJy8VJu8V40uJPQQfaa8m/6Ept51HGfNwTF2E8C6n5jo3Bno1nyJNkuSSop6eYgmnFrkvoay3YQWTH4ZGWUl+vrFIRWlNdlMRASeRuBnMlm2Sp5dm0k7Clqzr2NN0Zq04WdHOi4JO+wTqV73+qdrnB1Sug3kYTyZKM4+Mr+fuhnecJTHw3fQfTYE7Pm9bMMCZjq+orrxyMBIFsDA4ssH6g5c/juKPCm3LC/el5MYjhzG0RhGTzKU1cf4jc3qttJF65FsfhFb3qpwlyrlZP5JJ67N3VyYroQgyoLf9N7UwsfDg73mvn7owGjoOh3PewrjMQwdRtiOaK9oR7+iggFaNNdt/xBHdeeCrjNJPqf3OElQrcXJldlYsAazE9aLCRc7CWHDk39tRAYWGobAkRXuMaLosLt+OtzVvqOYFHlNycvBoGX+TMRERiP9Xqb+hqa9z8qOlIbuwHJ7PptlL556tFfrR1lRE7apufFgS3yvE= wose@elric"
        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDiFoKZl/CnDvod0zeBnZjreEVyluQ8Lwcno7wwh73UwXG9qDRBinZqo3iWRsHJM6lqiDQW1IOndbW3uOkyWJsWjb/FI8EMPQpWt9ghmIwqAkEJ+uvJiB/Zhh/vYG642rCzuYQ4aFLlWBRpQWMPacxpvLrH/CIwhH5GoGxphOhew5UtBqGq5kaHucRWB0U9ytpHSRUyHAx6wQEIC1LSPHjDZcw/02RU1gWGFnOcO6wtw0E7AZ3cN4w4nJsVEHUXnbA9M2ODhJjz+YNxw7VsfTbOtVBd/9qndO0QAeVis3/9+1Y2KBG9wde/PbNlk0qsWPfaVX3h+0YyllKNjaxWlgDb+4xweOgdM+4cALS78EO1THOr7KVjJIct3EXcuywn4PjTDAa84Bcz+kNMONjVFabn7nRHnzoEIV5TtlAYonktB22DRmIpjByS0oQmKB3mKCGNAIfiDnmUATkpq34KdCIv4yNkmThr/CBWm7G0ijlMNaAcV6Ts6fg+6T2hHT6Fxo92L4H9WMk4pd2f0CJ41dmYigL8EOfearrWfjLgkulgSvuTYBd5avGxB/OhnGBJkW+vFaCvCwKe1nzo5r4ei40K1e3yZqVpQd9Qq7FNg9qMSNydh049lnNSb0fOkahinps0198gOtNiBC6cTxqRLv0yM2Q1QiwC5Habu4cKBSXglw=="
        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC+FNrrRnVNqeBjuEZt5F0WhHBI/OtYgnUrP8NHYsZoY/9dLret+BryhhQBARZFj0eoKGjT2X50FyJq6/7g4BCYmYtXbe/HxkDXa6IEoeSc9uWC8HXaNKibuCIPWxLgDOvuu6YuFVELO7O5xr2JwrY4AoLhVeeGIPUcTPnOoukKQ0tWSu4BmfYdzF9bnfY+qoyDP0funIOg3r3fWKYQEiWcXfZ6yn10yoTLyqojVKIVO2w5SQRK5AHyI5PtOOGOJf8CR3lQZEH9kbLDA5RHOzvXa0G8p6/OtdaUymPBnfB4V9KovhEGej0L3S9lShZq2j/zrkqY1JlFrxzqzmpJNoF90faUwOeggM0VN0pRAMkkSebOYej5pCNRZaQSONBHgBGl4JN7PlDaE7NRbnNlVDGOlk2nxn/T9vlwx/7ww0Rz4YHgmvDLuSNyKvtzI2Mq+tvIg0tcV9Euo85UpaZfAF9WhT0tnX09rotnzf4avE6L08+uZDdInGRC+d3kRbpxcsXLhl/8/lXtaquLijtLmhGQ6UurPioQSgR91dy9hDjGAeahmEcb2OlwNCp5sJXsuw3f1AonrmUi7zvdcP/dZ3lHSn/AqTxUw4JDDDUmR5Vhs3QEWwD5oODPB2YVp0T9i0meWZ5ptrgcvr7/R00WY/1ELJ2Vg4LUMZ4IJuZ327GJKw=="
      ];
  };
}

linda

{ config, ... }:

{
  users.users.linda = {
    isNormalUser = true;
    openssh.authorizedKeys.keys = [
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRXYbTbEfpsbRDPUS1qfimY22k1KsUBTgUJyK2GKsKYnv06bAH+WOqFV7jc7Ps564VkE1n6Bo+rltKC3qjVJlzxSp23iBNt3mY/R1rSF2ENgkynKHtql5Bxh4CB5t0ZSeyBCb+awc7HCbOeIVmYNWZQalen4d2BXcwmTx05U7f5rfoNq9lkSMAdfHZYunsej0/EC1FVE0lyC1x7Ojj73u/SAaKUQbZD4fhMIIUVY7/ca6wAigeI8DRtsqTCZWCL07H2XOWv9E5aKPA9HJHd3GO3z8ewhNc+U/V16IYpt7saRLWH2DpNktZ5Y9goFangdiCVMTodcftPqi/tgCVJuPf linda@weatherwax"
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCpfvxkWvyREXMeDrmmVSnX7vliaRjMAFlo1n8zRl47ypR9K8wP5UE+jvOcHjtXfBHtZI3HTUSqjLPOVcJn0w5DILDsb8YwNR6+bFtRCcC4JOhkcZfjQsdqufANOmoyuXgsGNKIErQypz/AMmmzIdoCvaZccPUBpy4VvUJJo/H7DTo5Nq6y2sG3/LkKTFQf/aft3YRkcMr/i8Fe0PtDSj1ASQZpXuCN/V1YYgrQRixKjPFScalJVA/is5nlMeTka1Qx4G/MsYxY2ml2rN1BqH93pk6+k6NFOHwddHhWU2omFzDTY6XoFbXH7iWrx/bcWfge9tv5UEbi1Rq5CnxQw4AP linda@knudsen"
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvFujULPQwKibZsjeNGTDQoTWthjlJOEMLbmqLyoRNR/Mza0aD4jpYLWN/qr5BsqZ9zfTTUouTNpQWY4WKw+jhxcisVKyJk8yW/RKMcP94mUR2e4syAeZ0jzd8xyuWEgli/j3J4FZhbNMDlkszFsWzziqWZKo8u6lHR5BX7+DptmDwxhhCSKsuIncb3RN8xqqouJUajnWIYUhabC+5omLikj/KCSNEZ7OIIVaDSOdKClcIK86y0EEuSwhG1pDjpjopqdUYcW10XcmX5y44U4Ddy3D1PGgt993F98YG3My+dxkHDIPfPEYS3W6cktoSEnDtqZzvBmiQ96tJcMLQjao/ l.jaeckel@mainsim"
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDdgW3VnuRtROksH8Vp5AIeifrEvFcLPxM5ud5loQhjE21L8DG3d6wkALOM8qziZ3AF5wr8bueYPU3fYxMuOzggFRIJsJzznkOvZ4wH8XMkLDn2yloKkIWZxfX1E3KFw/NeOe1hTvlqvBF5xlibD6U2qHWAEAArUO/c3R847iD1BgaeDYlpzseNJLx8HddzEj7ZEcCeA5S3nr3Cd2tbTcEQ65M25PjfdKJ7d73zZT6Zk6gwLCWwl3j1DARFNQ+eU6R1CYfaSrZMC58jA9jowDYOuCmCpnVTlUZ9/5SHh7hKSHKqJVzAcxFY8KlaMEN5FUVMUTB0DKDkqIM/bfdgkml5XLflDEsNzVCrD3QwE6hbW8ZHWPaf+8LxHytcQtBY2CCvj6VOwnYpHvY+Kp13jlD5YfDrHF28mCMUFEG3lM3pWEBBp0K3VNJIOl/DIOSOCoSiX6ewv9kGHx5LTdCskwPea6QyIWh7QLvKXQRWwYQMBbE/2Kio08SYrPHSHVjRUqX/ihgs+vQu403XEtsFHniryzjce+v3wUvpY6T8WVMyxDmBAZU6IdxSQc+of1r9uEhvQB6lJZrgikyg4XWwzNAVPPK1Kud3uoWcmXxBKVYpzbn/KRwTlUe2TaYWfh3FeHRTVEEnT3cAV/PlC0rLP+RaGRhaHFF6vx6w7wb7OdGbrw== u0_a169@localhost"
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbUsyXPt8ydXGVh9a4aJPQrt7Ms0xDFAqCJP0qKhzmlOU6COtD9KKFBwJQW82JEWqxKxNNdeO7nMIj0jhWDgtwaKP9mYPd26s2o4F5324qf3YQHrXbsA5b7cjZadQScn4dc1bsLTOn9QGRniPCJhXzdvGnKrfQo83BK/MY0viR+BnxQzF4L+sgvntki8kP28gT9tkuUs7zR1vZsQhGM3WgmjomdPwRBxiV5UF2FgHR68ydqBlpP45JAuaHGrpvW7bhANk4JpctyR08VYyMkoDzSyX5FBoHhL4Dc7aWyqNRhbDLItVYWTB7fifZWBGy+t51VP0xACec+kD613ql+5yQpAC0Zx5mdlZxOMQZ764DwIGsDncfi8tzlgC4wbH4xKB3igLugZ4v8nxwMnIUT2aoq4FMy5WdIQJwO60AptCC0sLBu3qSZ/6oGRQTSyqb6rstRdpFSpbYlHjrLArIDsCdkGPNY71obIiXOb7LC6I3oGugBJ4mkjwfotJ34hln883CoSdPxZhASw+5WHxI67E74862rBSdQlbYsYJWFXsnquaq5nFV2U3zuk6WZvIO0Pc36AP0kCXwgUczFHAMoGmGz6eZQFNafCy8WRClHbt3K9+0w1wI7VVH7WjK2xvzDZcZvVPhJC+aiumFj3Z8dUV7Bdwb2O7Q05n6baeE9opdRQ== linda@prantl"
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC0Kgo3GnaPefgUyZ5b95yrh4kEQ4PMC76Bq4kwW4zsmgHNKe7/RwKCCVWMdAdSJ3dGRfU784fsuqzKVZKdE6DxUOs2J+SCwYKPwpOgrIHY48cRdsfsr64eKCRNVbmkm+JbLYGGsylHQWcOuAp4go/XRrxoOErCb+rd2jKSWB3XK/a6c1UXs48gTEt5U1Ja3q6yYIP3YOhnj15nAfgFJmRvkEN1ASA3RZ1U5maxs4ocZ8ZSZC2z4Xnp2/vSRdjxs3832Xc3bsKs/a1rk15JvrpXvPvJhe3obJtYFEGwXrdnxTln+vS9NsI6aOzFK1YpFOR+qxZvT3EzqVpsZ93qzsYa1Lg6n+tEOUwLiXq72IVrddZJIy6vqmrWAYqDKF1uX8rdVXEkPVBJO6u06GMcRMcKd7ZEHmzuFWD/c9+eh/jULDuLHvLovu2D1VdEG36/l2oJIjL+WPB215YmE1LpsN0DTHCnyQe6FW4TWSBBG61MCDrcyN4/g277fDTNg375cv0="
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM+PYKZu/+pDhWICRU/J+xek/Gy83cVzt1Ymzg01KJiI linda@worklaptop"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC/7fvOJLqQH6jYGnuufs42kyFxwkiof/ld2qllNRuIh"
    ];
  };
}

Releases

No releases published

Packages

No packages published

Languages