From d90bc8106058e926e2987bb52438563dd951b097 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 17 Jun 2026 13:22:35 -0400 Subject: [PATCH 1/2] feat(services): open-core community/professional tier split Pro-tier honeypots load from an optional decnet/services/pro/ subpackage that the registry auto-discovers when present; the Community build omits it, so the directory's absence IS the entitlement gate (no runtime licence check). Recurse subclasses so a pro service may extend a community one. Exclude pro from the community wheel and git-ignore the path (it lives in the private decnet-professional repo). Add LICENSING.md documenting the dual-license: AGPL-3.0-or-later core plus a commercial EULA for the Professional tier. --- .gitignore | 4 ++ LICENSING.md | 40 ++++++++++++++++++ decnet/services/registry.py | 20 ++++++++- pyproject.toml | 5 +++ tests/services/test_pro_tier.py | 75 +++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 LICENSING.md create mode 100644 tests/services/test_pro_tier.py diff --git a/.gitignore b/.gitignore index bc49506f..c5201a3b 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,7 @@ enterprise-attack-*.json # pytest failure dump files testfail .phaseloop/ + +# Professional tier: proprietary, lives in a separate private repo +# (github.com/DECNET-Foundation/decnet-professional). Must NEVER be tracked by the open-core repo. +/decnet/services/pro/ diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 00000000..c78e0c51 --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,40 @@ +# Licensing + +DECNET is **dual-licensed open core**. + +## Community (this repository) + +DECNET core — everything in this repository — is licensed under the **GNU Affero +General Public License v3.0 or later (AGPL-3.0-or-later)**. See [LICENSE](./LICENSE). + +AGPL (not GPL) is deliberate: DECNET is a network-deployed honeypot platform, so +the AGPL §13 network-use clause matters — anyone who offers DECNET to others over +a network must make their source available. GPLv3 would leave that loophole open. + +## Commercial / Professional + +Because the DECNET Foundation holds copyright in the core, the core is **also +available under a commercial license**. A commercial core license is what lets +the proprietary **DECNET Professional** add-on (advanced honeypots, distributed +separately) be combined and shipped with the core without triggering the AGPL's +copyleft obligations. + +DECNET Professional itself is closed source, licensed under the +[DECNET Commercial EULA](https://github.com/DECNET-Foundation/decnet-professional), +and is **not** part of this repository. The open-core build neither contains nor +depends on it. + +| Tier | Code | License | +|--------------|----------------------------------------|----------------------------| +| Community | this repo | AGPL-3.0-or-later | +| Professional | `decnet/services/pro/` (private repo) | DECNET Commercial EULA | + +To use DECNET core under terms other than the AGPL, or to obtain DECNET +Professional, contact **licensing@decnet.cl**. + +## Contributing + +Contributions to the core are accepted under the AGPL. Because the project is +dual-licensed, contributors must agree that their contributions may also be +distributed under the commercial license (a CLA / DCO sign-off). Relicensing +requires that the Foundation hold or be granted rights to all contributed code. diff --git a/decnet/services/registry.py b/decnet/services/registry.py index 1f50ed24..b440cd57 100644 --- a/decnet/services/registry.py +++ b/decnet/services/registry.py @@ -5,6 +5,12 @@ Service plugin registry. Auto-discovers all BaseService subclasses by importing every module in the services package. Adding a new service requires nothing beyond dropping a new .py file here that subclasses BaseService. + +Professional-tier honeypots live in the optional ``decnet.services.pro`` +subpackage, which ships only in the Professional build (a private tree merged +in at packaging time) and is absent from the open-core Community build. The +registry scans it when present, so absence of the directory IS the entitlement +gate — no licence check, no feature flag. """ import importlib @@ -17,6 +23,13 @@ _registry: dict[str, BaseService] = {} _loaded = False +def _all_subclasses(cls: type) -> set[type]: + # Recurse: a pro honeypot may extend a community service, not BaseService + # directly, and __subclasses__() only returns direct children. + subs = set(cls.__subclasses__()) + return subs.union(*(_all_subclasses(s) for s in subs)) + + def _load_plugins() -> None: global _loaded if _loaded: @@ -26,7 +39,12 @@ def _load_plugins() -> None: if module_info.name in ("base", "registry"): continue importlib.import_module(f"decnet.services.{module_info.name}") - for cls in BaseService.__subclasses__(): + # Professional build only: present == entitled. Community build has no pro/. + pro_dir = package_dir / "pro" + if pro_dir.is_dir(): + for mi in pkgutil.iter_modules([str(pro_dir)]): + importlib.import_module(f"decnet.services.pro.{mi.name}") + for cls in _all_subclasses(BaseService): if not cls.__module__.startswith("decnet.services."): continue instance = cls() # type: ignore[abstract] diff --git a/pyproject.toml b/pyproject.toml index 3d32a816..69f12879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,11 @@ where = ["."] # "decnet*" also globs decnet_web/ and pulls in stray node_modules .py files; # pin to the actual package so the wheel/sdist stay clean. include = ["decnet", "decnet.*"] +# Community build is open-core: never ship the Professional-tier honeypots even +# if a dev tree has the private decnet/services/pro/ submodule mounted. The +# Professional build overrides this. templates/pro/ is absent from the public +# tree, so package-data's templates/**/* glob picks up nothing extra here. +exclude = ["decnet.services.pro", "decnet.services.pro.*"] [tool.setuptools.package-data] # Ship docker build contexts + syslog_bridge.py as package data so they land diff --git a/tests/services/test_pro_tier.py b/tests/services/test_pro_tier.py new file mode 100644 index 00000000..9dc3d5a3 --- /dev/null +++ b/tests/services/test_pro_tier.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Open-core tier split: the Professional build supplies advanced honeypots via the +optional decnet/services/pro/ subpackage (a separate private repo cloned into +this path; git-ignored here so it never enters the open-core tree). The +Community build simply omits it. + +The registry must auto-discover pro honeypots when present — including ones that +EXTEND a community service rather than subclassing BaseService directly (the +recursive-subclass walk). Absence of a pro module is the entitlement gate; there +is no licence check. + +One test on purpose: it mutates decnet/services/pro/ and the process-global +registry, so it cannot race a sibling test under xdist. It tolerates a pro/ dir +that already exists (developer tree) and leaves the registry pristine. +""" + +import gc +import shutil +import sys +from pathlib import Path + +import decnet.services.registry as reg + +_DEMO_MOD = "_demo_pro_tier_test" +_DEMO_NAME = "demo-pro-honeypot" + + +def _reload_clean(): + reg._loaded = False + reg._registry.clear() + reg._load_plugins() + + +def test_pro_tier_packaging_gate(): + pkg_dir = Path(reg.__file__).parent + pro_dir = pkg_dir / "pro" + init = pro_dir / "__init__.py" + demo = pro_dir / f"{_DEMO_MOD}.py" + + created_dir = not pro_dir.exists() + if created_dir: + pro_dir.mkdir() + created_init = not init.exists() + if created_init: + init.write_text("") + + try: + # Gate closed: our pro honeypot absent, community services present. + _reload_clean() + assert _DEMO_NAME not in reg.all_services() + assert "ssh" in reg.all_services() + + # Professional build: drop in a pro honeypot that EXTENDS a community + # service (only reachable via the recursive subclass walk). + demo.write_text( + "from decnet.services.ssh import SSHService\n" + "class DemoProHoneypot(SSHService):\n" + f" name = {_DEMO_NAME!r}\n" + ) + _reload_clean() + svcs = reg.all_services() + assert _DEMO_NAME in svcs # pro discovered + assert "ssh" in svcs # community untouched + finally: + demo.unlink(missing_ok=True) + if created_init: + init.unlink(missing_ok=True) + if created_dir: + shutil.rmtree(pro_dir) + # Drop the dynamically-imported class so it can't pollute the registry + # for sibling tests sharing this worker. + sys.modules.pop(f"decnet.services.pro.{_DEMO_MOD}", None) + gc.collect() + _reload_clean() From 777606681ec995cdc306f81fec87cf7c8b7c0c15 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 17 Jun 2026 13:22:35 -0400 Subject: [PATCH 2/2] fix(tests): drop illegal @pytest.mark.anyio on anyio_backend fixture Newer pytest raises 'Marks cannot be applied to fixtures' instead of ignoring it. The async test methods already carry @pytest.mark.anyio, which is what selects the backend; the fixture must not. --- tests/fleet/test_reconciler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/fleet/test_reconciler.py b/tests/fleet/test_reconciler.py index 54543976..b455d427 100644 --- a/tests/fleet/test_reconciler.py +++ b/tests/fleet/test_reconciler.py @@ -142,7 +142,6 @@ class TestAggregate: # ── reconcile_once ──────────────────────────────────────────────────────────── -@pytest.mark.anyio @pytest.fixture def anyio_backend(): return "asyncio"