From fe540e183149502ecc37ed1ac0a04ed64104e29c Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Fri, 21 Aug 2026 13:05:34 +0100 Subject: [PATCH] feat: add Python packaging metadata and a Nix flake Package the proxy properly so it can be consumed outside a container, and without downstream users vendoring a package definition into their own configuration. - pyproject.toml: setuptools metadata with a `legacy-email-proxy` console script. Runtime dependencies are read dynamically from requirements.txt, so the Docker build and the package cannot drift apart. - proxy_server.run(): a synchronous entry point for the console script, since `main` is a coroutine and cannot be referenced by one directly. The `__main__` path is unchanged in behaviour. - package.nix / flake.nix: the package, an overlay, and a dev shell. The test suite runs as part of the build, so `nix flake check` covers it. - module.nix: a NixOS module exposing `services.legacy-email-proxy` with freeform `settings` (environment variables) and a separate `environmentFile` for credentials, so secrets stay out of the Nix store. Runs under DynamicUser with CAP_NET_BIND_SERVICE only while a listener needs a privileged port, and opens no firewall ports. The Dockerfile and its CI workflow are deliberately untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 7 +++ README.md | 61 +++++++++++++++++++++++ flake.lock | 27 ++++++++++ flake.nix | 54 ++++++++++++++++++++ module.nix | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ package.nix | 40 +++++++++++++++ proxy_server.py | 11 ++++- pyproject.toml | 25 ++++++++++ 8 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 module.nix create mode 100644 package.nix create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e173b10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +result +result-* +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +*.egg-info/ diff --git a/README.md b/README.md index 4dd0a20..6434e2c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,67 @@ docker run --rm -p 110:110 -p 25:25 \ legacy-email-proxy ``` +The project is also a standard Python package (`pyproject.toml`), so it +installs without Docker. This puts a `legacy-email-proxy` command on `PATH`: + +```bash +pip install . +legacy-email-proxy +``` + +`requirements.txt` remains the single source of runtime dependencies; +`pyproject.toml` reads it, so the Docker build and the package cannot drift. + +## Nix + +The repository is a flake. It exposes the package, an overlay, and a NixOS +module. + +```bash +nix run .#legacy-email-proxy # run it +nix build .#legacy-email-proxy # build it; the test suite runs as part of the build +nix develop # dev shell with pytest +``` + +As a NixOS service, with the proxy's own flake as an input: + +```nix +{ + inputs.legacy-email-proxy.url = "git+https://code.emmathe.dev/lyrathorpe/legacy-email-proxy"; + # optionally: inputs.legacy-email-proxy.inputs.nixpkgs.follows = "nixpkgs"; +} +``` + +```nix +{ + imports = [ inputs.legacy-email-proxy.nixosModules.default ]; + + services.legacy-email-proxy = { + enable = true; + settings = { + POP3_BIND_ADDR = "10.0.0.1"; + SMTP_BIND_ADDR = "10.0.0.1"; + BACKEND_IMAP_HOST = "imap.example.com"; + BACKEND_IMAP_USER = "someone@example.com"; + BACKEND_SMTP_HOST = "smtp.example.com"; + BACKEND_SMTP_USER = "someone@example.com"; + }; + # Credentials belong here, not in `settings` -- anything in `settings` + # lands in the world-readable Nix store. + environmentFile = "/var/lib/legacy-email-proxy/backend.env"; + }; +} +``` + +`settings` accepts any of the environment variables listed above; booleans and +integers are converted for you. The service runs under `DynamicUser`, with +`CAP_NET_BIND_SERVICE` granted only while a listener is on a privileged port. +It opens no firewall ports — see "Security". + +Prefer to manage the package yourself? `overlays.default` provides +`pkgs.legacy-email-proxy`, and `nixosModules.legacy-email-proxy` is the same +module without the package default wired to this flake. + ## Tests Install development dependencies and run the test suite: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..841c839 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..aae74a3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,54 @@ +{ + description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + in + { + overlays.default = final: _prev: { + legacy-email-proxy = final.callPackage ./package.nix { }; + }; + + packages = forAllSystems (pkgs: rec { + legacy-email-proxy = pkgs.callPackage ./package.nix { }; + default = legacy-email-proxy; + }); + + # The NixOS module with its package option pointed at this flake's build, + # so consumers need no overlay. Use nixosModules.legacy-email-proxy + # instead if you would rather apply overlays.default yourself. + nixosModules.default = + { lib, pkgs, ... }: + { + imports = [ ./module.nix ]; + services.legacy-email-proxy.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.legacy-email-proxy; + }; + nixosModules.legacy-email-proxy = ./module.nix; + + # The package builds only if the test suite passes, so this covers both. + checks = forAllSystems (pkgs: { + inherit (self.packages.${pkgs.system}) legacy-email-proxy; + }); + + devShells = forAllSystems (pkgs: { + default = pkgs.mkShellNoCC { + inputsFrom = [ self.packages.${pkgs.system}.legacy-email-proxy ]; + packages = [ pkgs.python3Packages.pytest ]; + }; + }); + + formatter = forAllSystems (pkgs: pkgs.nixfmt-tree); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..5ec1034 --- /dev/null +++ b/module.nix @@ -0,0 +1,129 @@ +# NixOS module for legacy-email-proxy. +# +# Consumed either through this flake's nixosModules.default (which defaults the +# package to the flake's own build) or directly, alongside overlays.default. +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.legacy-email-proxy; + + renderValue = + value: + if lib.isBool value then + lib.boolToString value # the proxy accepts 1/true/yes/on + else + toString value; + + environment = lib.mapAttrs (_name: renderValue) (lib.filterAttrs (_: v: v != null) cfg.settings); + + # The default listeners are 110 and 25, so the service normally needs + # CAP_NET_BIND_SERVICE. Drop it when both are configured above 1024. + portOf = + name: default: + let + value = cfg.settings.${name} or default; + in + if lib.isInt value then value else lib.toInt (toString value); + + needsPrivilegedPorts = portOf "POP3_BIND_PORT" 110 < 1024 || portOf "SMTP_BIND_PORT" 25 < 1024; +in +{ + options.services.legacy-email-proxy = { + enable = lib.mkEnableOption "the legacy POP3/SMTP proxy"; + + package = lib.mkPackageOption pkgs "legacy-email-proxy" { }; + + settings = lib.mkOption { + type = + with lib.types; + attrsOf ( + nullOr (oneOf [ + bool + int + str + ]) + ); + default = { }; + example = lib.literalExpression '' + { + POP3_BIND_ADDR = "10.0.0.1"; + BACKEND_IMAP_HOST = "imap.example.com"; + BACKEND_IMAP_USER = "someone@example.com"; + BACKEND_MUTATE = false; + } + ''; + description = '' + Environment variables for the proxy, passed to the service as-is. + Booleans render as `true`/`false`, which the proxy accepts. The full + list of variables is in the project README. + + Everything set here lands in the Nix store and is world-readable. Put + credentials in {option}`services.legacy-email-proxy.environmentFile` + instead. + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/legacy-email-proxy/backend.env"; + description = '' + Path to a systemd `EnvironmentFile` holding the backend credentials + (`BACKEND_IMAP_PASS`, `BACKEND_SMTP_PASS` and anything else that should + not be in the Nix store). Read by systemd at start, not by the service + user, so it can be root-owned and mode 0600. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.legacy-email-proxy = { + description = "Legacy POP3/SMTP proxy to authenticated IMAPS/SMTPS backends"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = environment // { + PYTHONUNBUFFERED = "1"; + }; + + serviceConfig = { + ExecStart = lib.getExe cfg.package; + Restart = "always"; + RestartSec = 5; + + DynamicUser = true; + AmbientCapabilities = lib.optional needsPrivilegedPorts "CAP_NET_BIND_SERVICE"; + CapabilityBoundingSet = lib.optional needsPrivilegedPorts "CAP_NET_BIND_SERVICE"; + + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + # AF_NETLINK is not gratuitous: glibc's getaddrinfo opens a netlink + # socket to sort resolver results, and backends are reached by name. + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + "AF_NETLINK" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + } + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..87e96ce --- /dev/null +++ b/package.nix @@ -0,0 +1,40 @@ +{ + lib, + python3Packages, +}: +python3Packages.buildPythonApplication { + pname = "legacy-email-proxy"; + inherit ((lib.importTOML ./pyproject.toml).project) version; + pyproject = true; + + # Only the files that affect the build, so editing the README or the CI + # workflow does not invalidate it. + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./proxy_server.py + ./pyproject.toml + ./requirements.txt + ./pytest.ini + ./tests + ]; + }; + + build-system = [ python3Packages.setuptools ]; + dependencies = [ python3Packages.aiosmtpd ]; + + nativeCheckInputs = [ python3Packages.pytestCheckHook ]; + + meta = { + description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends"; + longDescription = '' + Exposes cleartext POP3 and SMTP to clients that cannot speak TLS or + modern authentication, and forwards them to an authenticated IMAPS/SMTPS + backend. The front-end listeners are unauthenticated by design and must + be confined to a trusted network. + ''; + homepage = "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy"; + mainProgram = "legacy-email-proxy"; + platforms = lib.platforms.unix; + }; +} diff --git a/proxy_server.py b/proxy_server.py index 16ced93..a04898c 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -458,8 +458,17 @@ async def main(): await pop3_server.serve_forever() -if __name__ == "__main__": +def run(): + """Run the proxy until interrupted. + + Entry point for the ``legacy-email-proxy`` console script; ``main`` is a + coroutine and cannot be referenced directly by one. + """ try: asyncio.run(main()) except KeyboardInterrupt: logger.info("Shutdown requested") + + +if __name__ == "__main__": + run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db26305 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "legacy-email-proxy" +version = "0.3.0" +description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends" +readme = "README.md" +requires-python = ">=3.12" +dynamic = ["dependencies"] + +[project.scripts] +legacy-email-proxy = "proxy_server:run" + +[project.urls] +Homepage = "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy" + +# requirements.txt stays the single source of runtime dependencies, so the +# Dockerfile and this file cannot drift apart. +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[tool.setuptools] +py-modules = ["proxy_server"] -- 2.54.0