Merge feat/pro-extension-surfaces: multi-surface pro extension points

This commit is contained in:
2026-06-17 15:02:28 -04:00
13 changed files with 151 additions and 57 deletions

7
.gitignore vendored
View File

@@ -76,5 +76,8 @@ 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/
# (github.com/DECNET-Foundation/decnet-professional), mounted at decnet/pro/.
# Must NEVER be tracked by the open-core repo. src/pro-impl/ is where the pro
# build copies the pro frontend so the JS toolchain resolves it.
/decnet/pro/
/decnet_web/src/pro-impl/

View File

@@ -27,7 +27,7 @@ depends on it.
| Tier | Code | License |
|--------------|----------------------------------------|----------------------------|
| Community | this repo | AGPL-3.0-or-later |
| Professional | `decnet/services/pro/` (private repo) | DECNET Commercial EULA |
| Professional | `decnet/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**.

View File

@@ -6,11 +6,11 @@ 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.
Professional-tier honeypots live in the optional ``decnet.pro.services``
subpackage (a clone of the private decnet-professional repo, mounted at
``decnet/pro/`` and 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
@@ -40,12 +40,13 @@ def _load_plugins() -> None:
continue
importlib.import_module(f"decnet.services.{module_info.name}")
# Professional build only: present == entitled. Community build has no pro/.
pro_dir = package_dir / "pro"
pro_dir = package_dir.parent / "pro" / "services"
if pro_dir.is_dir():
for mi in pkgutil.iter_modules([str(pro_dir)]):
importlib.import_module(f"decnet.services.pro.{mi.name}")
importlib.import_module(f"decnet.pro.services.{mi.name}")
for cls in _all_subclasses(BaseService):
if not cls.__module__.startswith("decnet.services."):
mod = cls.__module__
if not (mod.startswith("decnet.services.") or mod.startswith("decnet.pro.")):
continue
instance = cls() # type: ignore[abstract]
_registry[instance.name] = instance

View File

@@ -218,3 +218,13 @@ api_router.include_router(ttp_rules_router)
api_router.include_router(ttp_tag_details_router)
api_router.include_router(ttp_navigator_router)
api_router.include_router(ttp_groups_for_technique_router)
# Professional tier (optional): mount pro routers under /api/v1 when the
# decnet/pro/ package is present. Absence is the entitlement gate — community
# builds have no decnet.pro, so this is a no-op there.
try:
from decnet.pro.routes import ROUTERS as _pro_routers
except ModuleNotFoundError:
_pro_routers = []
for _pro_router in _pro_routers:
api_router.include_router(_pro_router)

View File

@@ -10,6 +10,7 @@ import { ToastProvider } from './components/Toasts/ToastProvider';
import { useToast } from './components/Toasts/useToast';
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
import { isDeveloperMode } from './lib/devGate';
import { proRoutes } from '@pro';
// Page components are code-split per route. Each lands as its own
// chunk and only downloads when the user navigates to that path —
@@ -148,6 +149,10 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
{isDeveloperMode() && (
<Route path="/theme-lab" element={<ThemeLab />} />
)}
{/* Professional-tier pages. Empty in the community build (@pro -> stub). */}
{proRoutes.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>

View File

@@ -9,6 +9,7 @@ import {
} from '../icons';
import { prefetchRoute } from '../routePrefetch';
import { useThemeToggle } from '../lib/useThemeToggle';
import { proRoutes } from '@pro';
import './Layout.css';
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
@@ -160,6 +161,13 @@ const Layout: React.FC<LayoutProps> = ({
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
</NavGroup>
{proRoutes.length > 0 && (
<NavGroup label="PROFESSIONAL" icon={<Zap size={20} />} open={sidebarOpen}>
{proRoutes.map((r) => (
<NavItem key={r.path} to={r.path} icon={r.icon} label={r.label} open={sidebarOpen} indent />
))}
</NavGroup>
)}
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav>

View File

@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { proRoutes } from '@pro';
// In the community build, `@pro` resolves to the stub: no Professional pages,
// so App's route map and Layout's nav group both tree-shake to nothing.
describe('pro tier — community build', () => {
it('ships no pro routes', () => {
expect(proRoutes).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Community build: no Professional pages. `@pro` resolves here unless the build
// sets VITE_DECNET_PRO=1 with decnet/pro/web/ present, in which case Vite
// aliases `@pro` to the real registry. proRoutes being empty lets the router
// and nav tree-shake the pro surface out of the community bundle.
import type { ProRoute } from './types';
export const proRoutes: ProRoute[] = [];

View File

@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contract for Professional-tier UI pages. The pro build aliases `@pro` to the
// real registry in decnet/pro/web/; the community build resolves it to ./stub.
import type { ReactElement, ReactNode } from 'react';
export interface ProRoute {
/** Router path, e.g. "/pro/intel". Convention: prefix pro routes with /pro. */
path: string;
/** Sidebar label. */
label: string;
/** Sidebar icon (lucide-react element), optional. */
icon?: ReactNode;
/** Page element rendered at `path`. May be a lazy component (App wraps Suspense). */
element: ReactElement;
}

View File

@@ -10,6 +10,12 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
/* `@pro` resolves to the empty community stub for type-checking. The
Professional build overrides this to decnet/pro/web/index.tsx. Must mirror
the Vite alias in vite.config.ts. (bundler resolution => no baseUrl.) */
"paths": { "@pro": ["./src/pro/stub.ts"] },
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,

View File

@@ -1,10 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { existsSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
// `@pro` resolves to the real Professional registry only for an explicit pro
// build (VITE_DECNET_PRO=1) once the pro frontend is mounted at src/pro-impl/
// (git-ignored; the pro build copies decnet/pro/web there so react/lucide and
// tsc resolve normally). Otherwise the empty community stub, which tree-shakes
// the pro surface out of the bundle.
const proRealEntry = resolve(here, 'src/pro-impl/index.tsx')
const proEntry =
process.env.VITE_DECNET_PRO === '1' && existsSync(proRealEntry)
? proRealEntry
: resolve(here, 'src/pro/stub.ts')
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@pro': proEntry } },
test: {
environment: 'jsdom',
globals: true,

View File

@@ -173,11 +173,10 @@ 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.*"]
# Community build is open-core: never ship the Professional tier even if a dev
# tree has the private decnet/pro/ repo mounted. The Professional build overrides
# this.
exclude = ["decnet.pro", "decnet.pro.*"]
[tool.setuptools.package-data]
# Ship docker build contexts + syslog_bridge.py as package data so they land

View File

@@ -1,22 +1,21 @@
# 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.
Open-core tier seams: the Professional tier is a separate private repo mounted at
decnet/pro/ (git-ignored here) that contributes to several core surfaces, each
wired only when the package is present:
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.
* decnet/pro/services/ — advanced honeypots, discovered by the service registry.
* decnet/pro/routes.py — ROUTERS, mounted under /api/v1 by the web router.
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.
Absence of decnet/pro/ is the entitlement gate; there is no licence check.
One test function on purpose: it mutates decnet/pro/services/, the process-global
service registry, and reloads decnet.web.router — doing that sequentially in one
worker avoids the xdist races that separate tests on shared state would hit.
"""
import gc
import shutil
import importlib
import sys
from pathlib import Path
@@ -26,50 +25,64 @@ _DEMO_MOD = "_demo_pro_tier_test"
_DEMO_NAME = "demo-pro-honeypot"
def _reload_clean():
def _reload_registry():
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()
def test_pro_tier_seams():
# --- service-discovery seam -------------------------------------------
services_dir = Path(reg.__file__).parent.parent / "pro" / "services"
demo = services_dir / f"{_DEMO_MOD}.py"
created_dir = not services_dir.exists()
if created_dir:
pro_dir.mkdir()
created_init = not init.exists()
if created_init:
init.write_text("")
services_dir.mkdir(parents=True)
(services_dir / "__init__.py").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()
_reload_registry()
assert _DEMO_NAME not in reg.all_services() # gate closed
assert "ssh" in reg.all_services() # community untouched
# Professional build: drop in a pro honeypot that EXTENDS a community
# service (only reachable via the recursive subclass walk).
# A pro honeypot that EXTENDS a community service — only reachable via
# the registry's 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
_reload_registry()
assert _DEMO_NAME in reg.all_services()
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)
import shutil
shutil.rmtree(services_dir)
sys.modules.pop(f"decnet.pro.services.{_DEMO_MOD}", None)
gc.collect()
_reload_clean()
_reload_registry()
# --- API-router seam --------------------------------------------------
import decnet.pro.routes as pro_routes
import decnet.web.router as web_router
from fastapi import APIRouter, FastAPI
saved = pro_routes.ROUTERS
probe = APIRouter()
@probe.get("/pro/_probe_test")
async def _probe(): # pragma: no cover - never called, just registered
return {}
try:
pro_routes.ROUTERS = [probe]
importlib.reload(web_router)
# This FastAPI version defers route flattening (_IncludedRouter), so go
# through openapi() — it forces resolution and lists effective paths.
app = FastAPI()
app.include_router(web_router.api_router, prefix="/api/v1")
assert "/api/v1/pro/_probe_test" in app.openapi()["paths"]
finally:
pro_routes.ROUTERS = saved
importlib.reload(web_router) # rebuild a pro-free api_router for others