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.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
40
LICENSING.md
Normal file
40
LICENSING.md
Normal file
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
75
tests/services/test_pro_tier.py
Normal file
75
tests/services/test_pro_tier.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user