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/ .phaseloop/
# Professional tier: proprietary, lives in a separate private repo # Professional tier: proprietary, lives in a separate private repo
# (github.com/DECNET-Foundation/decnet-professional). Must NEVER be tracked by the open-core repo. # (github.com/DECNET-Foundation/decnet-professional), mounted at decnet/pro/.
/decnet/services/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 | | Tier | Code | License |
|--------------|----------------------------------------|----------------------------| |--------------|----------------------------------------|----------------------------|
| Community | this repo | AGPL-3.0-or-later | | 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 To use DECNET core under terms other than the AGPL, or to obtain DECNET
Professional, contact **licensing@decnet.cl**. 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 services package. Adding a new service requires nothing beyond dropping a
new .py file here that subclasses BaseService. new .py file here that subclasses BaseService.
Professional-tier honeypots live in the optional ``decnet.services.pro`` Professional-tier honeypots live in the optional ``decnet.pro.services``
subpackage, which ships only in the Professional build (a private tree merged subpackage (a clone of the private decnet-professional repo, mounted at
in at packaging time) and is absent from the open-core Community build. The ``decnet/pro/`` and absent from the open-core Community build). The registry
registry scans it when present, so absence of the directory IS the entitlement scans it when present, so absence of the directory IS the entitlement gate —
gate — no licence check, no feature flag. no licence check, no feature flag.
""" """
import importlib import importlib
@@ -40,12 +40,13 @@ def _load_plugins() -> None:
continue continue
importlib.import_module(f"decnet.services.{module_info.name}") importlib.import_module(f"decnet.services.{module_info.name}")
# Professional build only: present == entitled. Community build has no pro/. # 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(): if pro_dir.is_dir():
for mi in pkgutil.iter_modules([str(pro_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): 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 continue
instance = cls() # type: ignore[abstract] instance = cls() # type: ignore[abstract]
_registry[instance.name] = instance _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_tag_details_router)
api_router.include_router(ttp_navigator_router) api_router.include_router(ttp_navigator_router)
api_router.include_router(ttp_groups_for_technique_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 { useToast } from './components/Toasts/useToast';
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys'; import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
import { isDeveloperMode } from './lib/devGate'; import { isDeveloperMode } from './lib/devGate';
import { proRoutes } from '@pro';
// Page components are code-split per route. Each lands as its own // Page components are code-split per route. Each lands as its own
// chunk and only downloads when the user navigates to that path — // chunk and only downloads when the user navigates to that path —
@@ -148,6 +149,10 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
{isDeveloperMode() && ( {isDeveloperMode() && (
<Route path="/theme-lab" element={<ThemeLab />} /> <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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>

View File

@@ -9,6 +9,7 @@ import {
} from '../icons'; } from '../icons';
import { prefetchRoute } from '../routePrefetch'; import { prefetchRoute } from '../routePrefetch';
import { useThemeToggle } from '../lib/useThemeToggle'; import { useThemeToggle } from '../lib/useThemeToggle';
import { proRoutes } from '@pro';
import './Layout.css'; import './Layout.css';
type ThreatLevel = 'nominal' | 'elevated' | 'critical'; 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/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 /> <NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
</NavGroup> </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} /> <NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav> </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 */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "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, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,

View File

@@ -1,10 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react' 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { alias: { '@pro': proEntry } },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,

View File

@@ -173,11 +173,10 @@ where = ["."]
# "decnet*" also globs decnet_web/ and pulls in stray node_modules .py files; # "decnet*" also globs decnet_web/ and pulls in stray node_modules .py files;
# pin to the actual package so the wheel/sdist stay clean. # pin to the actual package so the wheel/sdist stay clean.
include = ["decnet", "decnet.*"] include = ["decnet", "decnet.*"]
# Community build is open-core: never ship the Professional-tier honeypots even # Community build is open-core: never ship the Professional tier even if a dev
# if a dev tree has the private decnet/services/pro/ submodule mounted. The # tree has the private decnet/pro/ repo mounted. The Professional build overrides
# Professional build overrides this. templates/pro/ is absent from the public # this.
# tree, so package-data's templates/**/* glob picks up nothing extra here. exclude = ["decnet.pro", "decnet.pro.*"]
exclude = ["decnet.services.pro", "decnet.services.pro.*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
# Ship docker build contexts + syslog_bridge.py as package data so they land # 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 # SPDX-License-Identifier: AGPL-3.0-or-later
""" """
Open-core tier split: the Professional build supplies advanced honeypots via the Open-core tier seams: the Professional tier is a separate private repo mounted at
optional decnet/services/pro/ subpackage (a separate private repo cloned into decnet/pro/ (git-ignored here) that contributes to several core surfaces, each
this path; git-ignored here so it never enters the open-core tree). The wired only when the package is present:
Community build simply omits it.
The registry must auto-discover pro honeypots when present — including ones that * decnet/pro/services/ — advanced honeypots, discovered by the service registry.
EXTEND a community service rather than subclassing BaseService directly (the * decnet/pro/routes.py — ROUTERS, mounted under /api/v1 by the web router.
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 Absence of decnet/pro/ is the entitlement gate; there is no licence check.
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. 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 gc
import shutil import importlib
import sys import sys
from pathlib import Path from pathlib import Path
@@ -26,50 +25,64 @@ _DEMO_MOD = "_demo_pro_tier_test"
_DEMO_NAME = "demo-pro-honeypot" _DEMO_NAME = "demo-pro-honeypot"
def _reload_clean(): def _reload_registry():
reg._loaded = False reg._loaded = False
reg._registry.clear() reg._registry.clear()
reg._load_plugins() reg._load_plugins()
def test_pro_tier_packaging_gate(): def test_pro_tier_seams():
pkg_dir = Path(reg.__file__).parent # --- service-discovery seam -------------------------------------------
pro_dir = pkg_dir / "pro" services_dir = Path(reg.__file__).parent.parent / "pro" / "services"
init = pro_dir / "__init__.py" demo = services_dir / f"{_DEMO_MOD}.py"
demo = pro_dir / f"{_DEMO_MOD}.py" created_dir = not services_dir.exists()
created_dir = not pro_dir.exists()
if created_dir: if created_dir:
pro_dir.mkdir() services_dir.mkdir(parents=True)
created_init = not init.exists() (services_dir / "__init__.py").write_text("")
if created_init:
init.write_text("")
try: try:
# Gate closed: our pro honeypot absent, community services present. _reload_registry()
_reload_clean() assert _DEMO_NAME not in reg.all_services() # gate closed
assert _DEMO_NAME not in reg.all_services() assert "ssh" in reg.all_services() # community untouched
assert "ssh" in reg.all_services()
# Professional build: drop in a pro honeypot that EXTENDS a community # A pro honeypot that EXTENDS a community service — only reachable via
# service (only reachable via the recursive subclass walk). # the registry's recursive subclass walk.
demo.write_text( demo.write_text(
"from decnet.services.ssh import SSHService\n" "from decnet.services.ssh import SSHService\n"
"class DemoProHoneypot(SSHService):\n" "class DemoProHoneypot(SSHService):\n"
f" name = {_DEMO_NAME!r}\n" f" name = {_DEMO_NAME!r}\n"
) )
_reload_clean() _reload_registry()
svcs = reg.all_services() assert _DEMO_NAME in reg.all_services()
assert _DEMO_NAME in svcs # pro discovered
assert "ssh" in svcs # community untouched
finally: finally:
demo.unlink(missing_ok=True) demo.unlink(missing_ok=True)
if created_init:
init.unlink(missing_ok=True)
if created_dir: if created_dir:
shutil.rmtree(pro_dir) import shutil
# Drop the dynamically-imported class so it can't pollute the registry shutil.rmtree(services_dir)
# for sibling tests sharing this worker. sys.modules.pop(f"decnet.pro.services.{_DEMO_MOD}", None)
sys.modules.pop(f"decnet.services.pro.{_DEMO_MOD}", None)
gc.collect() 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