diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index 5d7eeca7cfaa..fae671dc12b7 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -10842,6 +10842,15 @@ githubId = 77865363; name = "Leonid Belyaev"; }; + leonm1 = { + github = "leonm1"; + githubId = 32306579; + keys = [{ + fingerprint = "C12D F14B DC9D 64E1 44C3 4D8A 755C DA4E 5923 416A"; + }]; + matrix = "@mattleon:matrix.org"; + name = "Matt Leon"; + }; leshainc = { email = "leshainc@fomalhaut.me"; github = "LeshaInc"; diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index fd286a4e6e69..a6a09755c14d 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -78,6 +78,10 @@ In addition to numerous new and upgraded packages, this release has the followin - [hebbot](https://github.com/haecker-felix/hebbot), a Matrix bot to generate "This Week in X" like blog posts. Available as [services.hebbot](#opt-services.hebbot.enable). +- [Python Matter Server](https://github.com/home-assistant-libs/python-matter-server), a + Matter Controller Server exposing websocket connections for use with other services, notably Home Assistant. + Available as [services.matter-server](#opt-services.matter-server.enable) + - [Anki Sync Server](https://docs.ankiweb.net/sync-server.html), the official sync server built into recent versions of Anki. Available as [services.anki-sync-server](#opt-services.anki-sync-server.enable). The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been marked deprecated and will be dropped after 24.05 due to lack of maintenance of the anki-sync-server softwares. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 00ad2ddd51b8..627427262da6 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -585,6 +585,7 @@ ./services/home-automation/govee2mqtt.nix ./services/home-automation/home-assistant.nix ./services/home-automation/homeassistant-satellite.nix + ./services/home-automation/matter-server.nix ./services/home-automation/zigbee2mqtt.nix ./services/home-automation/zwave-js.nix ./services/logging/SystemdJournal2Gelf.nix diff --git a/nixos/modules/services/home-automation/matter-server.nix b/nixos/modules/services/home-automation/matter-server.nix new file mode 100644 index 000000000000..864ef9e20083 --- /dev/null +++ b/nixos/modules/services/home-automation/matter-server.nix @@ -0,0 +1,125 @@ +{ lib +, pkgs +, config +, ... +}: + +with lib; + +let + cfg = config.services.matter-server; + storageDir = "matter-server"; + storagePath = "/var/lib/${storageDir}"; + vendorId = "4939"; # home-assistant vendor ID +in + +{ + meta.maintainers = with lib.maintainers; [ leonm1 ]; + + options.services.matter-server = with types; { + enable = mkEnableOption (lib.mdDoc "Matter-server"); + + package = mkPackageOptionMD pkgs "python-matter-server" { }; + + port = mkOption { + type = types.port; + default = 5580; + description = "Port to expose the matter-server service on."; + }; + + logLevel = mkOption { + type = types.enum [ "critical" "error" "warning" "info" "debug" ]; + default = "info"; + description = "Verbosity of logs from the matter-server"; + }; + + extraArgs = mkOption { + type = listOf str; + default = []; + description = '' + Extra arguments to pass to the matter-server executable. + See https://github.com/home-assistant-libs/python-matter-server?tab=readme-ov-file#running-the-development-server for options. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.matter-server = { + after = [ "network-online.target" ]; + before = [ "home-assistant.service" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + description = "Matter Server"; + environment.HOME = storagePath; + serviceConfig = { + ExecStart = (concatStringsSep " " [ + "${cfg.package}/bin/matter-server" + "--port" (toString cfg.port) + "--vendorid" vendorId + "--storage-path" storagePath + "--log-level" "${cfg.logLevel}" + "${escapeShellArgs cfg.extraArgs}" + ]); + # Start with a clean root filesystem, and allowlist what the container + # is permitted to access. + TemporaryFileSystem = "/"; + # Allowlist /nix/store (to allow the binary to find its dependencies) + # and dbus. + ReadOnlyPaths = "/nix/store /run/dbus"; + # Let systemd manage `/var/lib/matter-server` for us inside the + # ephemeral TemporaryFileSystem. + StateDirectory = storageDir; + # `python-matter-server` writes to /data even when a storage-path is + # specified. This bind-mount points /data at the systemd-managed + # /var/lib/matter-server, so all files get dropped into the state + # directory. + BindPaths = "${storagePath}:/data"; + + # Hardening bits + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + DevicePolicy = "closed"; + DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallFilter = concatStringsSep " " [ + "~" # Blocklist + "@clock" + "@cpu-emulation" + "@debug" + "@module" + "@mount" + "@obsolete" + "@privileged" + "@raw-io" + "@reboot" + "@resources" + "@swap" + ]; + UMask = "0077"; + }; + }; + }; +} + diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index e36d49678a89..9795023bcea9 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -512,6 +512,7 @@ in { mastodon = discoverTests (import ./web-apps/mastodon { inherit handleTestOn; }); pixelfed = discoverTests (import ./web-apps/pixelfed { inherit handleTestOn; }); mate = handleTest ./mate.nix {}; + matter-server = handleTest ./matter-server.nix {}; matomo = handleTest ./matomo.nix {}; matrix-appservice-irc = handleTest ./matrix/appservice-irc.nix {}; matrix-conduit = handleTest ./matrix/conduit.nix {}; diff --git a/nixos/tests/matter-server.nix b/nixos/tests/matter-server.nix new file mode 100644 index 000000000000..c646e9840d19 --- /dev/null +++ b/nixos/tests/matter-server.nix @@ -0,0 +1,45 @@ +import ./make-test-python.nix ({ pkgs, lib, ...} : + +let + chipVersion = pkgs.python311Packages.home-assistant-chip-core.version; +in + +{ + name = "matter-server"; + meta.maintainers = with lib.maintainers; [ leonm1 ]; + + nodes = { + machine = { config, ... }: { + services.matter-server = { + enable = true; + port = 1234; + }; + }; + }; + + testScript = /* python */ '' + start_all() + + machine.wait_for_unit("matter-server.service") + machine.wait_for_open_port(1234) + + with subtest("Check websocket server initialized"): + output = machine.succeed("echo \"\" | ${pkgs.websocat}/bin/websocat ws://localhost:1234/ws") + machine.log(output) + + assert '"sdk_version": "${chipVersion}"' in output, ( + 'CHIP version \"${chipVersion}\" not present in websocket message' + ) + + assert '"fabric_id": 1' in output, ( + "fabric_id not propagated to server" + ) + + with subtest("Check storage directory is created"): + machine.succeed("ls /var/lib/matter-server/chip.json") + + with subtest("Check systemd hardening"): + _, output = machine.execute("systemd-analyze security matter-server.service | grep -v '✓'") + machine.log(output) + ''; +}) diff --git a/pkgs/development/python-modules/python-matter-server/default.nix b/pkgs/development/python-modules/python-matter-server/default.nix index 588f2042bd9b..31064f369379 100644 --- a/pkgs/development/python-modules/python-matter-server/default.nix +++ b/pkgs/development/python-modules/python-matter-server/default.nix @@ -2,6 +2,8 @@ , buildPythonPackage , fetchFromGitHub , pythonOlder +, stdenvNoCC +, substituteAll # build , setuptools @@ -28,6 +30,29 @@ , pytestCheckHook }: +let + paaCerts = stdenvNoCC.mkDerivation rec { + pname = "matter-server-paa-certificates"; + version = "1.2.0.1"; + + src = fetchFromGitHub { + owner = "project-chip"; + repo = "connectedhomeip"; + rev = "refs/tags/v${version}"; + hash = "sha256-p3P0n5oKRasYz386K2bhN3QVfN6oFndFIUWLEUWB0ss="; + }; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp $src/credentials/development/paa-root-certs/* $out/ + + runHook postInstall + ''; + }; +in + buildPythonPackage rec { pname = "python-matter-server"; version = "5.7.0b2"; @@ -42,6 +67,13 @@ buildPythonPackage rec { hash = "sha256-fMtvVizHeAzLdou0U1tqbmQATIBLK4w9I7EwMlzB8QA="; }; + patches = [ + (substituteAll { + src = ./link-paa-root-certs.patch; + paacerts = paaCerts; + }) + ]; + postPatch = '' substituteInPlace pyproject.toml \ --replace 'version = "0.0.0"' 'version = "${version}"' \ diff --git a/pkgs/development/python-modules/python-matter-server/link-paa-root-certs.patch b/pkgs/development/python-modules/python-matter-server/link-paa-root-certs.patch new file mode 100644 index 000000000000..a788f69144b8 --- /dev/null +++ b/pkgs/development/python-modules/python-matter-server/link-paa-root-certs.patch @@ -0,0 +1,126 @@ +diff --git a/matter_server/server/const.py b/matter_server/server/const.py +index b6cd839..f9f798f 100644 +--- a/matter_server/server/const.py ++++ b/matter_server/server/const.py +@@ -5,14 +5,4 @@ from typing import Final + # The minimum schema version (of a client) the server can support + MIN_SCHEMA_VERSION = 5 + +-# the paa-root-certs path is hardcoded in the sdk at this time +-# and always uses the development subfolder +-# regardless of anything you pass into instantiating the controller +-# revisit this once matter 1.1 is released +-PAA_ROOT_CERTS_DIR: Final[pathlib.Path] = ( +- pathlib.Path(__file__) +- .parent.resolve() +- .parent.resolve() +- .parent.resolve() +- .joinpath("credentials/development/paa-root-certs") +-) ++PAA_ROOT_CERTS_DIR: Final[pathlib.Path] = pathlib.Path("@paacerts@") +diff --git a/matter_server/server/helpers/paa_certificates.py b/matter_server/server/helpers/paa_certificates.py +index 9ac5a10..25230c1 100644 +--- a/matter_server/server/helpers/paa_certificates.py ++++ b/matter_server/server/helpers/paa_certificates.py +@@ -58,84 +58,14 @@ async def fetch_dcl_certificates( + fetch_production_certificates: bool = True, + ) -> int: + """Fetch DCL PAA Certificates.""" +- LOGGER.info("Fetching the latest PAA root certificates from DCL.") +- if not PAA_ROOT_CERTS_DIR.is_dir(): +- loop = asyncio.get_running_loop() +- await loop.run_in_executor(None, makedirs, PAA_ROOT_CERTS_DIR) +- fetch_count: int = 0 +- base_urls = set() +- # determine which url's need to be queried. +- # if we're going to fetch both prod and test, do test first +- # so any duplicates will be overwritten/preferred by the production version +- # NOTE: While Matter is in BETA we fetch the test certificates by default +- if fetch_test_certificates: +- base_urls.add(TEST_URL) +- if fetch_production_certificates: +- base_urls.add(PRODUCTION_URL) + +- try: +- async with ClientSession(raise_for_status=True) as http_session: +- for url_base in base_urls: +- # fetch the paa certificates list +- async with http_session.get( +- f"{url_base}/dcl/pki/root-certificates" +- ) as response: +- result = await response.json() +- paa_list = result["approvedRootCertificates"]["certs"] +- # grab each certificate +- for paa in paa_list: +- # do not fetch a certificate if we already fetched it +- if paa["subjectKeyId"] in LAST_CERT_IDS: +- continue +- async with http_session.get( +- f"{url_base}/dcl/pki/certificates/{paa['subject']}/{paa['subjectKeyId']}" +- ) as response: +- result = await response.json() +- +- certificate_data: dict = result["approvedCertificates"]["certs"][0] +- certificate: str = certificate_data["pemCert"] +- subject = certificate_data["subjectAsText"] +- certificate = certificate.rstrip("\n") +- +- await write_paa_root_cert( +- certificate, +- subject, +- ) +- LAST_CERT_IDS.add(paa["subjectKeyId"]) +- fetch_count += 1 +- except ClientError as err: +- LOGGER.warning( +- "Fetching latest certificates failed: error %s", err, exc_info=err +- ) +- else: +- LOGGER.info("Fetched %s PAA root certificates from DCL.", fetch_count) +- +- return fetch_count ++ return 0 + + + async def fetch_git_certificates() -> int: + """Fetch Git PAA Certificates.""" +- fetch_count = 0 +- LOGGER.info("Fetching the latest PAA root certificates from Git.") +- try: +- async with ClientSession(raise_for_status=True) as http_session: +- for cert in GIT_CERTS: +- if cert in LAST_CERT_IDS: +- continue + +- async with http_session.get(f"{GIT_URL}/{cert}.pem") as response: +- certificate = await response.text() +- await write_paa_root_cert(certificate, cert) +- LAST_CERT_IDS.add(cert) +- fetch_count += 1 +- except ClientError as err: +- LOGGER.warning( +- "Fetching latest certificates failed: error %s", err, exc_info=err +- ) +- +- LOGGER.info("Fetched %s PAA root certificates from Git.", fetch_count) +- +- return fetch_count ++ return 0 + + + async def fetch_certificates( +@@ -144,12 +74,4 @@ async def fetch_certificates( + ) -> int: + """Fetch PAA Certificates.""" + +- fetch_count = await fetch_dcl_certificates( +- fetch_test_certificates=fetch_test_certificates, +- fetch_production_certificates=fetch_production_certificates, +- ) +- +- if fetch_test_certificates: +- fetch_count += await fetch_git_certificates() +- +- return fetch_count ++ return 0 +