diff --git a/non-critical-infra/flake-module.nix b/non-critical-infra/flake-module.nix index f3ad8d57..45c5cd33 100644 --- a/non-critical-infra/flake-module.nix +++ b/non-critical-infra/flake-module.nix @@ -56,7 +56,7 @@ pkgs = inputs'.nixpkgs-unstable.legacyPackages; in { - packages.encrypt-email-address = pkgs.callPackage ./packages/encrypt-email-address { }; + packages.encrypt-email = pkgs.callPackage ./packages/encrypt-email { }; devShells.non-critical-infra = pkgs.mkShellNoCC { packages = [ diff --git a/non-critical-infra/modules/mailserver/README.md b/non-critical-infra/modules/mailserver/README.md index 22ef0156..ad2fe938 100644 --- a/non-critical-infra/modules/mailserver/README.md +++ b/non-critical-infra/modules/mailserver/README.md @@ -5,10 +5,7 @@ This module will [eventually][issue 485] provide mail services for `nixos.org`. ## Mailing lists To create a new mailing list, or change membership of a mailing list, see the -instructions at the top of [`mailing-lists.nix`](./mailing-lists.nix). +instructions under `### Mailing lists go here ###` in [`default.nix`](./default.nix). -## Sending mail - -This module does not yet provide SMTP login. - -[issue 485]: https://github.com/NixOS/infra/issues/485 +Some mailing lists allow login and sending email via `SMTP`. Search for +`loginAccount` to find examples of this. diff --git a/non-critical-infra/modules/mailserver/default.nix b/non-critical-infra/modules/mailserver/default.nix index 4573440c..a2ab1bc0 100644 --- a/non-critical-infra/modules/mailserver/default.nix +++ b/non-critical-infra/modules/mailserver/default.nix @@ -7,13 +7,31 @@ enable = true; certificateScheme = "acme-nginx"; - # Until we have login accounts, there's no reason to run either of these. - enablePop3 = false; - enableImap = false; - fqdn = config.networking.fqdn; # TODO: change to `nixos.org` when ready domains = [ "mail-test.nixos.org" ]; }; + + ### Mailing lists go here ### + # If you wish to hide your email address, you can encrypt it with SOPS. Just + # run `nix run .#encrypt-email address -- --help` and follow the instructions. + # + # If you wish to set up a login account for sending email, you must generate + # an encrypted password. Run `nix run .#encrypt-email login -- --help` and + # follow the instructions. + mailing-lists = { + # TODO: replace with the real `nixos.org` mailing lists. + "test-list@mail-test.nixos.org" = { + forwardTo = [ + "jfly@playground.jflei.com" + ../../secrets/jfly-email-address.umbriel + "jeremyfleischman+subscriber@gmail.com" + ]; + }; + "test-sender@mail-test.nixos.org" = { + forwardTo = [ "jeremy@playground.jflei.com" ]; + loginAccount.encryptedHashedPassword = ../../secrets/test-sender-email-login.umbriel; + }; + }; } diff --git a/non-critical-infra/modules/mailserver/mailing-lists.nix b/non-critical-infra/modules/mailserver/mailing-lists.nix index a1cac0f3..6ce54fcc 100644 --- a/non-critical-infra/modules/mailserver/mailing-lists.nix +++ b/non-critical-infra/modules/mailserver/mailing-lists.nix @@ -1,69 +1,134 @@ -# This module provides the mailing list definitions for `@nixos.org`. +# This module makes it easy to define mailing lists in `simple-nixos-mailserver` +# with a couple of features: # -# Simply change the `lists` attribute set below to create new mailing lists or -# edit membership of existing lists. -# -# If you wish to hide your email address, you can encrypt it with SOPS. Just -# run `nix run .#encrypt-email-address -- --help` and follow the instructions. +# 1. We can (optionally) encrypt the forward addresses for increase privacy. +# 2. We can set up a login account for mailing addresses to allow sending +# email via `SMTP` from those addresses. { config, lib, ... }: let - # Mailing lists go here. - # TODO: replace with the real `nixos.org` mailing lists. - listsWithSecretFiles = { - "test-list@mail-test.nixos.org" = [ - "jfly@playground.jflei.com" - ../../secrets/jfly-email.umbriel - "jeremyfleischman+subscriber@gmail.com" - ]; - }; + inherit (lib) types; fileToSecretId = file: builtins.baseNameOf file; - listsWithSecretPlaceholders = lib.mapAttrs' (name: members: { + listsWithSecretPlaceholders = lib.mapAttrs' (name: mailingList: { name = name; value = map ( member: if builtins.isString member then member else config.sops.placeholder.${fileToSecretId member} - ) members; - }) listsWithSecretFiles; + ) mailingList.forwardTo; + }) config.mailing-lists; - secretFiles = lib.pipe listsWithSecretFiles [ - (lib.mapAttrsToList (_name: members: members)) + secretAddressFiles = lib.pipe config.mailing-lists [ + (lib.mapAttrsToList (_name: mailingList: mailingList.forwardTo)) lib.flatten (builtins.filter (member: !builtins.isString member)) ]; + + secretPasswordFiles = lib.pipe config.mailing-lists [ + (lib.filterAttrs (_name: mailingList: mailingList.loginAccount != null)) + (lib.mapAttrsToList (_name: mailingList: mailingList.loginAccount.encryptedHashedPassword)) + ]; in { - # Declare secrets for every secret email in the lists above. - sops.secrets = builtins.listToAttrs ( - map (file: { - name = fileToSecretId file; - value = { - format = "binary"; - sopsFile = file; - }; - }) secretFiles - ); + options = { + mailing-lists = lib.mkOption { + type = types.attrsOf ( + types.submodule { + options = { + forwardTo = lib.mkOption { + type = types.listOf (types.either types.str types.path); + description = '' + Either a plaintext email address, or a path to an email address + encrypted with `nix run .#encrypt-email address` + ''; + }; + loginAccount = lib.mkOption { + type = types.nullOr ( + types.submodule { + options = { + encryptedHashedPassword = lib.mkOption { + type = types.path; + description = '' + If specified, this enables sending emails from this address via SMTP. + Must be a path to encrypted file generated with `nix run .#encrypt-email login` + ''; + }; + }; + } + ); + default = null; + }; + }; + } + ); + description = '' + Mailing lists. Supports both forward-only mailing lists, as well as mailing + lists that allow sending via SMTP. + ''; + }; + }; - sops.templates."postfix-virtual-mailing-lists" = { - content = lib.concatStringsSep "\n" ( - lib.mapAttrsToList ( - name: members: "${name} ${lib.concatStringsSep ", " members}" - ) listsWithSecretPlaceholders + config = { + # Disable IMAP. We don't need it, as we don't store email on this server, we + # only forward emails. + mailserver.enableImap = false; + mailserver.enableImapSsl = false; + services.dovecot2.enableImap = false; + + mailserver.loginAccounts = lib.pipe config.mailing-lists [ + (lib.filterAttrs (_name: mailingList: mailingList.loginAccount != null)) + (lib.mapAttrs ( + _name: mailingList: { + hashedPasswordFile = + config.sops.secrets.${fileToSecretId mailingList.loginAccount.encryptedHashedPassword}.path; + } + )) + ]; + + # Declare secrets for every secret file. + sops.secrets = builtins.listToAttrs ( + (map (file: { + name = fileToSecretId file; + value = { + format = "binary"; + sopsFile = file; + }; + }) secretAddressFiles) + ++ (map (file: { + name = fileToSecretId file; + value = { + format = "binary"; + sopsFile = file; + # Need to restart `dovecot2.service` to trigger `genPasswdScript` in + # `nixos-mailserver`: + # https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/af7d3bf5daeba3fc28089b015c0dd43f06b176f2/mail-server/dovecot.nix#L369 + # This could go away if sops-nix gets support for "input addressed secret + # paths": https://github.com/Mic92/sops-nix/issues/648 + restartUnits = [ "dovecot2.service" ]; + }; + }) secretPasswordFiles) ); - # Need to restart postfix-setup to rerun `postmap` and generate updated `.db` - # files whenever mailing list membership changes. - # This could go away if sops-nix gets support for "input addressed secret - # paths": https://github.com/Mic92/sops-nix/issues/648 - restartUnits = [ "postfix-setup.service" ]; - }; + sops.templates."postfix-virtual-mailing-lists" = { + content = lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + name: members: "${name} ${lib.concatStringsSep ", " members}" + ) listsWithSecretPlaceholders + ); + + # Need to restart postfix-setup to rerun `postmap` and generate updated `.db` + # files whenever mailing list membership changes. + # This could go away if sops-nix gets support for "input addressed secret + # paths": https://github.com/Mic92/sops-nix/issues/648 + restartUnits = [ "postfix-setup.service" ]; + }; - services.postfix.mapFiles.virtual-mailing-lists = - config.sops.templates."postfix-virtual-mailing-lists".path; + services.postfix.mapFiles.virtual-mailing-lists = + config.sops.templates."postfix-virtual-mailing-lists".path; - services.postfix.config.virtual_alias_maps = [ "hash:/etc/postfix/virtual-mailing-lists" ]; + services.postfix.config.virtual_alias_maps = [ "hash:/etc/postfix/virtual-mailing-lists" ]; + }; } diff --git a/non-critical-infra/packages/encrypt-email-address/default.nix b/non-critical-infra/packages/encrypt-email-address/default.nix deleted file mode 100644 index 16df8edb..00000000 --- a/non-critical-infra/packages/encrypt-email-address/default.nix +++ /dev/null @@ -1,20 +0,0 @@ -{ - lib, - python3, - sops, -}: - -python3.pkgs.buildPythonApplication { - name = "encrypt-email-address"; - src = ./.; - - format = "other"; - - propagatedBuildInputs = [ python3.pkgs.click ]; - - installPhase = '' - mkdir -p $out/bin - mv ./encrypt-email-address.py $out/bin/encrypt-email-address - wrapProgram $out/bin/encrypt-email-address --prefix PATH : ${lib.makeBinPath [ sops ]} - ''; -} diff --git a/non-critical-infra/packages/encrypt-email-address/encrypt-email-address.py b/non-critical-infra/packages/encrypt-email-address/encrypt-email-address.py deleted file mode 100755 index 82073d31..00000000 --- a/non-critical-infra/packages/encrypt-email-address/encrypt-email-address.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 - -import re -import subprocess -from pathlib import Path - -import click - - -def find_project_root(start: Path) -> Path: - # Can search for `flake.nix` because there are multiple in this project. - root_indicator = start / ".git/config" - if root_indicator.exists(): - return start - - return find_project_root(start.parent) - - -@click.command() -@click.argument("address_id") -@click.argument("email") -@click.option("--force/--no-force", "-f/ ", default=False) -def main(address_id: str, email: str, force: bool) -> None: - """ - Encrypt an email address (or email addresses) for inclusion in a mailing list. - - Example: - - \bencrypt-email-address some-token 'me@example.com,you@example.com' - - Then follow the instructions for what to do next. - """ - # Feel free to make the regex less restrictive if you need to. - id_re = re.compile("[A-Za-z0-9-]+") - if not id_re.fullmatch(address_id): - msg = f"Given ID: {address_id!r} is invalid. Must match regex: {id_re.pattern}" - raise click.ClickException(msg) - - # Make sure we aren't being given a text file that happens to have a newline at the end. - clean_email = email.strip() - if clean_email != email: - click.secho("Removed whitespace surrounding given email address", fg="yellow") - email = clean_email - - project_root = find_project_root(Path.cwd()).relative_to(Path.cwd(), walk_up=True) - non_critical_infra_dir = project_root / "non-critical-infra" - - secret_path = non_critical_infra_dir / f"secrets/{address_id}-email.umbriel" - - if secret_path.exists(): - if not force: - msg = f"Refusing to clobber existing {secret_path}. Use `--force` to override." - raise click.ClickException(msg) - click.secho(f"Clobbering existing {secret_path}", fg="yellow") - - sops_config = non_critical_infra_dir / ".sops.yaml" - cp = subprocess.run( - [ - "sops", - "--encrypt", - "--config", - sops_config, - "--filename-override", - secret_path, - "/dev/stdin", - ], - text=True, - check=True, - stdout=subprocess.PIPE, - input=email, - ) - - secret_path.write_text(cp.stdout) - subprocess.run( - ["git", "add", "--intent-to-add", "--force", "--", secret_path], check=True - ) - - click.secho(f"Successfully generated {secret_path}", fg="green") - - mailing_list_nix = non_critical_infra_dir / "modules/mailserver/mailing-lists.nix" - assert mailing_list_nix.exists() - - click.secho() - click.secho("Now add yourself to ", nl=False) - click.secho(mailing_list_nix, fg="blue", nl=False) - click.secho(". ") - - click.secho() - click.secho("Lastly, add `", nl=False) - click.secho( - secret_path.relative_to(mailing_list_nix.parent, walk_up=True), - fg="blue", - nl=False, - ) - click.secho("` to the relevant mailing list under '", nl=False) - click.secho("# Mailing lists go here.", fg="blue", nl=False) - click.secho("'.") - - -if __name__ == "__main__": - main() diff --git a/non-critical-infra/packages/encrypt-email/default.nix b/non-critical-infra/packages/encrypt-email/default.nix new file mode 100644 index 00000000..0c550140 --- /dev/null +++ b/non-critical-infra/packages/encrypt-email/default.nix @@ -0,0 +1,26 @@ +{ + lib, + mkpasswd, + python3, + sops, +}: + +python3.pkgs.buildPythonApplication { + name = "encrypt-email"; + src = ./.; + + format = "other"; + + propagatedBuildInputs = [ python3.pkgs.click ]; + + installPhase = '' + mkdir -p $out/bin + mv ./encrypt-email.py $out/bin/encrypt-email + wrapProgram $out/bin/encrypt-email --prefix PATH : ${ + lib.makeBinPath [ + sops + mkpasswd + ] + } + ''; +} diff --git a/non-critical-infra/packages/encrypt-email/encrypt-email.py b/non-critical-infra/packages/encrypt-email/encrypt-email.py new file mode 100755 index 00000000..493f0011 --- /dev/null +++ b/non-critical-infra/packages/encrypt-email/encrypt-email.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import re +import subprocess +import sys +from pathlib import Path +from textwrap import dedent, indent + +import click + + +def find_project_root(start: Path) -> Path: + # Can search for `flake.nix` because there are multiple in this project. + root_indicator = start / ".git/config" + if root_indicator.exists(): + return start + + return find_project_root(start.parent) + + +def find_relative_project_root() -> Path: + return find_project_root(Path.cwd()).relative_to(Path.cwd(), walk_up=True) + + +def encrypt_to_file(plaintext: str, secret_path: Path, force: bool) -> None: + if secret_path.exists(): + if not force: + msg = f"Refusing to clobber existing {secret_path}. Use `--force` to override." + raise click.ClickException(msg) + click.secho(f"Clobbering existing {secret_path}", fg="yellow") + + cp = subprocess.run( + [ + "sops", + "--encrypt", + "--filename-override", + secret_path, + "/dev/stdin", + ], + cwd=secret_path.parent, + text=True, + check=True, + stdout=subprocess.PIPE, + input=plaintext, + ) + + secret_path.write_text(cp.stdout) + subprocess.run( + ["git", "add", "--intent-to-add", "--force", "--", secret_path], check=True + ) + + click.secho(f"Successfully generated {secret_path}", fg="green") + + +def hash_password(plaintext: str) -> str: + cp = subprocess.run( + ["mkpasswd", "--stdin", "--method=bcrypt"], + stdout=subprocess.PIPE, + input=plaintext, + text=True, + check=True, + ) + return cp.stdout + + +@click.group() +def main() -> None: + pass + + +@main.command() +@click.argument("address_id") +@click.argument("email") +@click.option("--force/--no-force", "-f/ ", default=False) +def address(address_id: str, email: str, force: bool) -> None: + """ + Encrypt an email address (or email addresses) for inclusion in a mailing list. + + Example: + + \bencrypt-email address some-token 'me@example.com,you@example.com' + + Then follow the instructions for what to do next. + """ + # Feel free to make the regex less restrictive if you need to. + id_re = re.compile("[A-Za-z0-9-]+") + if not id_re.fullmatch(address_id): + msg = f"Given ID: {address_id!r} is invalid. Must match regex: {id_re.pattern}" + raise click.ClickException(msg) + + # Make sure we aren't being given a text file that happens to have a newline at the end. + clean_email = email.strip() + if clean_email != email: + click.secho("Removed whitespace surrounding given email address", fg="yellow") + email = clean_email + + project_root = find_relative_project_root() + non_critical_infra_dir = project_root / "non-critical-infra" + + secret_path = non_critical_infra_dir / f"secrets/{address_id}-email-address.umbriel" + encrypt_to_file(email, secret_path, force) + + default_nix = non_critical_infra_dir / "modules/mailserver/default.nix" + assert default_nix.exists() + + click.secho() + click.secho("Now add `", nl=False) + click.secho( + secret_path.relative_to(default_nix.parent, walk_up=True), + fg="blue", + nl=False, + ) + click.secho("` to the relevant mailing list under '", nl=False) + click.secho("### Mailing lists go here ###", fg="blue", nl=False) + click.secho("' in ", nl=False) + click.secho(default_nix, fg="blue") + + +@main.command() +@click.argument("address_id") +@click.option("--force/--no-force", "-f/ ", default=False) +def login(address_id: str, force: bool) -> None: + """ + Encrypt a password to set up a login account for a mailing list. The password must be given via stdin. + + Example: + + \bencrypt-email login test-sender < file-with-password + + Then follow the instructions for what to do next. + """ + # Make sure we aren't being given a text file that happens to have a newline at the end. + password = sys.stdin.read() + clean_password = password.strip() + if clean_password != password: + click.secho("Removed whitespace surrounding given password", fg="yellow") + password = clean_password + + hashed_password = hash_password(password) + + project_root = find_relative_project_root() + non_critical_infra_dir = project_root / "non-critical-infra" + + secret_path = non_critical_infra_dir / f"secrets/{address_id}-email-login.umbriel" + encrypt_to_file(hashed_password, secret_path, force) + + default_nix = non_critical_infra_dir / "modules/mailserver/default.nix" + assert default_nix.exists() + + nix_code = dedent( + f"""\ + "{address_id}@mail-test.nixos.org" = {{ + forwardTo = [ + # Add emails here + ]; + loginAccount.encryptedHashedPassword = ../../secrets/test-sender-email-login.umbriel; + }}; + """ + ) + click.secho() + click.secho("Now add this login account to ", nl=False) + click.secho(default_nix, fg="blue", nl=False) + click.secho(". Search for '", nl=False) + click.secho("### Mailing lists go here ###", fg="blue", nl=False) + click.secho("'. Add or edit an entry that looks like this:") + click.secho() + click.secho(indent(nix_code, prefix=" " * 4), fg="blue") + + +if __name__ == "__main__": + main() diff --git a/non-critical-infra/secrets/jfly-email.umbriel b/non-critical-infra/secrets/jfly-email-address.umbriel similarity index 100% rename from non-critical-infra/secrets/jfly-email.umbriel rename to non-critical-infra/secrets/jfly-email-address.umbriel diff --git a/non-critical-infra/secrets/test-sender-email-login.umbriel b/non-critical-infra/secrets/test-sender-email-login.umbriel new file mode 100644 index 00000000..78e48474 --- /dev/null +++ b/non-critical-infra/secrets/test-sender-email-login.umbriel @@ -0,0 +1,28 @@ +{ + "data": "ENC[AES256_GCM,data:QrqhPVcJL1Q0VttVaPQ0fNuHdYyDXnSRy2EgWm/P1YRjBGLaSviwOCscYCyQw8Q8CqtPyFt2p4ddhpqueQ==,iv:ydnF/JhFy5mNDHdm/GJeS2PoRpQvAgRfFoumhCLNKsg=,tag:3zSqTVXmf2cl4G97lcF8og==,type:str]", + "sops": { + "kms": null, + "gcp_kms": null, + "azure_kv": null, + "hc_vault": null, + "age": [ + { + "recipient": "age15vcp7875xwtf64j4yshyld0a3hpgzv6n2kxky493s3q0swr9hdaqxugpv6", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTaTlvMm5iR0RlMHFDMEQw\nU0ZhRUVwOVlXWVhWSnJmNUN6d1lsL1lOUjNBCjNoWFI3L2R2dnFvMm96QzU2cENJ\nY2RoQVcwQkRYOVk1UFNqZTdTV2pCS0kKLS0tIC9EZFZISWFJQWdTSnpzQ2xFYkxq\nS1cveHJuOVE4ZmVsUUEzRGRKYWtYZncKccTmgBe1sdnpMYnTOV4gAUEBg93Blg18\n2gfJl1NUszoOGVnUq0HIVi0PHCFb4imMNhbF6INv0eQG5OPB0ElOig==\n-----END AGE ENCRYPTED FILE-----\n" + }, + { + "recipient": "age1j3mkgedmeru63vwww6m44zfw09tg8yw6xdzstaq7ejfkvgcau40qwakm8x", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5U3RDTnlBWk04RVFLV2xZ\ndU5qWmJiT3lPMW1UK3I1TDljWCs2dldDcFdrCmRDRDM5ZnlrTm1NS05YQU1PUTJS\nNjlWYitnWjgxMzI2YituMzJmK2w0VUkKLS0tIHhPc3lYR1c2TWkzS3NFcms5OGQ1\nMHpZVmFmZ0owYmR1OFB5LzJqZGx5UUUKB2j2Pa25K4rJ0PX961R3KBA3UZyOXPJw\nBtuyuKUo7Ro9oOVaIiezU1Z6ii8CY/WVrEpTRHkHbYSTOAZcLKY/qw==\n-----END AGE ENCRYPTED FILE-----\n" + }, + { + "recipient": "age1jrh8yyq3swjru09s75s4mspu0mphh7h6z54z946raa9wx3pcdegq0x8t4h", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvK2REWTQ0eU9OMHZ2aWZM\nUTVQSFJOMHJrT2dwWSsyd2lsWVprMFFWdlY0ClZHc1dFYmJmY1dVcUJ0R2pKU0F0\nRlViQlZDMnpVQWMwZlg5aFEzS055U2sKLS0tIDhYenRPZkpVOWcrREYvUllrQnI5\nQ0U1a3R5R0dhTHhvYWRLbU5GaXh4UmsKJ23a/61odibLmp7UnbmiSkEwTErMlur2\nP1AZgvI1YZGaRo0211s5ffcV2fvmEuY3HxvIHIhby9HRC4B8wFIVUA==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2024-11-10T00:44:29Z", + "mac": "ENC[AES256_GCM,data:VNo6aIkTOxKJFq5xxIo0IJV5bzY+Za9IZP0xuGqSJNj+/TwIV5VTVjMGmQiLB+hPX8ixXcpqAflO9KlWAwxId63dtnPNlGUtOp9ys03zV+QSv0ejmAQrGg4t4FkHxIowT8YLXAw0an0jQU1AdTU4kyoL8jY6Vz2y76FDqw+YRyc=,iv:XEILC5jtuegGkMm3dyMeaZ3RBixB9MWst3ncTENvMDI=,tag:8+FDT7M0MVie+dBGLB0S8A==,type:str]", + "pgp": null, + "unencrypted_suffix": "_unencrypted", + "version": "3.9.1" + } +} \ No newline at end of file