Merge feat/pro-extension-surfaces: multi-surface pro extension points
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
10
decnet_web/src/pro/pro.test.ts
Normal file
10
decnet_web/src/pro/pro.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
decnet_web/src/pro/stub.ts
Normal file
8
decnet_web/src/pro/stub.ts
Normal 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[] = [];
|
||||||
15
decnet_web/src/pro/types.ts
Normal file
15
decnet_web/src/pro/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user