Compare commits
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript — type-check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check (tsc)
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish — build & push image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [typecheck]
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Gitea's registry, not registry.netork.io.
|
||||||
|
#
|
||||||
|
# registry.netork.io is plain registry:2 with htpasswd auth, which knows
|
||||||
|
# nothing about repositories: every account that can log in reads and
|
||||||
|
# writes everything on it, including the accounts issued to customer
|
||||||
|
# instances. It keeps the images those instances are meant to pull
|
||||||
|
# (netork/engine, netork/ui, netork/satellite); the marketing site is not
|
||||||
|
# one of them. Gitea scopes packages to their owning account, and no
|
||||||
|
# customer has one. netOrk issue #172.
|
||||||
|
#
|
||||||
|
# REGISTRY_TOKEN is a Gitea access token with write:package — the token
|
||||||
|
# Actions injects per run is scoped to the repository API and the package
|
||||||
|
# registry rejects it outright.
|
||||||
|
- name: Login to registry
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "::error::REGISTRY_TOKEN is not set (Gitea token with write:package)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
LOGIN_USER="${{ secrets.REGISTRY_USER }}"
|
||||||
|
[ -n "$LOGIN_USER" ] || LOGIN_USER="${{ github.actor }}"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.netork.io -u "$LOGIN_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build & push
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t git.netork.io/netork/website:latest \
|
||||||
|
-t git.netork.io/netork/website:main-${SHA} \
|
||||||
|
.
|
||||||
|
docker push git.netork.io/netork/website:latest
|
||||||
|
docker push git.netork.io/netork/website:main-${SHA}
|
||||||
|
|
||||||
|
- name: Logout
|
||||||
|
if: always()
|
||||||
|
run: docker logout git.netork.io
|
||||||
|
|
||||||
|
# The deploy job that used to live here has been removed, deliberately.
|
||||||
|
#
|
||||||
|
# It ran `docker run` against whatever runner picked the job up, which worked
|
||||||
|
# while exactly one runner existed. There are now several (netork-runner-12 on
|
||||||
|
# .12, netork-runner-13 on .13, and the original netork-runner) and none of them
|
||||||
|
# is on 10.7.224.11, where this site actually runs and where the proxy-net it
|
||||||
|
# attaches to lives. The job would therefore have started a second website
|
||||||
|
# container on the wrong host and reported success, while netork.io went on
|
||||||
|
# serving the old one.
|
||||||
|
#
|
||||||
|
# Deployment is now an explicit step: scripts/deploy.sh, run from a workstation,
|
||||||
|
# which targets .11 by name and verifies afterwards that the container really is
|
||||||
|
# on the image that was pulled.
|
||||||
|
#
|
||||||
|
# To get push-to-deploy back, either register a runner on .11 with a label of its
|
||||||
|
# own and pin `runs-on:` to it, or give CI an ssh key for .11. Both are choices
|
||||||
|
# about where a credential lives, so neither was made here.
|
||||||
@@ -1 +1,14 @@
|
|||||||
node_modules/\ndist/\n.env\n.env.local\n*.local\n.DS_Store
|
node_modules/
|
||||||
|
dist/
|
||||||
|
memory/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Deploy target + registry token
|
||||||
|
deploy.env
|
||||||
|
|
||||||
|
# Screenshot tooling: bytecode, and database dumps that hold production data
|
||||||
|
__pycache__/
|
||||||
|
*.dump
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ Everything on the site should serve those three questions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Infrastruktur ändert man woanders — und vorher
|
||||||
|
|
||||||
|
Diese Anwendung läuft auf einer Infrastruktur, die sie sich mit anderen Projekten teilt:
|
||||||
|
Netz, Datenbankcluster, öffentlicher Eingang und Backup gehören keinem Projekt allein.
|
||||||
|
Dokumentiert ist sie in **`git.netork.io/christianmanivong/infrastructure`**, und dort
|
||||||
|
steht in `CLAUDE.md` auch die verbindliche Regel.
|
||||||
|
|
||||||
|
**Kurz: erst dort dokumentieren, ausdrücklich genehmigen lassen, dann ändern.** Nicht
|
||||||
|
umgekehrt, und „ja mach mal" zu einer früheren Frage deckt die nächste Änderung nicht mit
|
||||||
|
ab.
|
||||||
|
|
||||||
|
Betroffen ist alles, was über dieses Repo hinausreicht — Hosts, Netze, Firewall-Regeln,
|
||||||
|
der Patroni-Cluster samt `pg_hba` und DCS-Parametern, pgBackRest, BunkerWeb-Hosts, DNS,
|
||||||
|
CI-Runner, alles, was eine Anwendung auf den geteilten Datenbankcluster umzieht.
|
||||||
|
**Nicht** betroffen: Anwendungscode, Abhängigkeiten und Migrationen innerhalb der eigenen
|
||||||
|
Datenbank.
|
||||||
|
|
||||||
|
Der Grund für die Reihenfolge ist nicht Bürokratie. Die meisten Zwischenfälle dort waren
|
||||||
|
nicht falsche Werte, sondern richtige Werte in der falschen Reihenfolge — und das fällt
|
||||||
|
beim Aufschreiben auf, nicht beim Tippen. Im Zweifel dorthin.
|
||||||
|
|
||||||
## Content & Design Source of Truth
|
## Content & Design Source of Truth
|
||||||
|
|
||||||
All product content (features, copy, page structure) is in `docs/`:
|
All product content (features, copy, page structure) is in `docs/`:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
netork-website:
|
||||||
|
image: registry.netork.io/netork/website:latest
|
||||||
|
container_name: netork-website
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
networks:
|
||||||
|
- proxy-net
|
||||||
|
|
||||||
|
# Zoraxy proxies to netork-website:80 via proxy-net.
|
||||||
|
# No host port binding needed — SSL terminated by Zoraxy.
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
external: true
|
||||||
@@ -6,6 +6,43 @@ between the product UI and the marketing site.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Logo Assets
|
||||||
|
|
||||||
|
The logo file is in `public/` — use it directly, do not recreate.
|
||||||
|
|
||||||
|
| File | Format | Size | Use |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `public/logo.png` | PNG | 1024×1024, RGBA | Nav logo, OG image, hero, press kit, favicon fallback |
|
||||||
|
|
||||||
|
### Usage in `<img>` (nav, hero)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<img src="/logo.png" alt="netOrk" className="h-8 w-8" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For the nav, pair it with the wordmark:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<a href="/" className="flex items-center gap-2.5">
|
||||||
|
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||||
|
<span className="font-semibold text-slate-100 tracking-tight">
|
||||||
|
net<span className="text-sky-400">Ork</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `net<span class="text-sky-400">Ork</span>` consistently — the `Ork` part
|
||||||
|
in sky-400 ties the wordmark to the accent color.
|
||||||
|
|
||||||
|
### `<head>` references
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="icon" href="/logo.png" />
|
||||||
|
<meta property="og:image" content="/logo.png" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Core Principle
|
## Core Principle
|
||||||
|
|
||||||
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
|
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
|
||||||
@@ -158,10 +195,16 @@ against the dark background:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||||
<img src="/screenshots/device-list.png" alt="Device inventory" className="w-full" />
|
<img src="/screenshots/devices.webp" alt="Device inventory" className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Only real screenshots of the running application.** No JSX mockups or
|
||||||
|
drawn imitations of the UI. They come from an anonymized demo copy of a real
|
||||||
|
installation and are taken with `scripts/screenshots/capture.py` (see
|
||||||
|
`scripts/demo/README.md`), published as WebP in `public/screenshots/`.
|
||||||
|
`Screenshot` in `src/pages/Home.tsx` is the frame.
|
||||||
|
|
||||||
Optionally add a browser chrome header above the image:
|
Optionally add a browser chrome header above the image:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -169,7 +212,7 @@ Optionally add a browser chrome header above the image:
|
|||||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||||
<span className="ml-4 text-xs text-slate-500 font-mono">netork.local</span>
|
<span className="ml-4 text-xs text-slate-500 font-mono">netork / devices</span>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ structure, and draft copy. Use this as the brief for implementation.
|
|||||||
| `/features` | Full feature list | P1 |
|
| `/features` | Full feature list | P1 |
|
||||||
| `/drivers` | Supported devices | P1 |
|
| `/drivers` | Supported devices | P1 |
|
||||||
| `/docs/getting-started` | Installation guide | P1 |
|
| `/docs/getting-started` | Installation guide | P1 |
|
||||||
|
| `/roadmap` | Roadmap — planned + under consideration | P1 |
|
||||||
|
| `/nis2` | NIS2 landing page — Art. 21 mapping, evidence, roadmap | P1 |
|
||||||
| `/docs/architecture` | Technical overview | P2 |
|
| `/docs/architecture` | Technical overview | P2 |
|
||||||
| `/plugins` | Plugin system | P2 |
|
| `/plugins` | Plugin system | P2 |
|
||||||
|
|
||||||
@@ -122,28 +124,48 @@ Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
|
|||||||
|
|
||||||
### Section 5 — Screenshot Walkthrough (alternating)
|
### Section 5 — Screenshot Walkthrough (alternating)
|
||||||
|
|
||||||
**Purpose:** Show the UI concretely. Three alternating image + text rows.
|
**Purpose:** Show the UI concretely. Six alternating image + text rows, each a
|
||||||
|
real screenshot from `scripts/screenshots/shots.py`. Copy lives in
|
||||||
|
`home.screenshot1`–`screenshot6` in `src/i18n/translations.ts`.
|
||||||
|
|
||||||
**Row 1 — Left text, right screenshot**
|
| Row | Heading | Screenshot |
|
||||||
- Heading: `Device detail at a glance`
|
|---|---|---|
|
||||||
- Copy: `Hostname, IP, vendor, OS version, last poll time, and active
|
| 1 | Device detail at a glance | `device-detail` — an access point, Networking → Interfaces |
|
||||||
warnings on one card. Tabbed detail view for interfaces, LLDP neighbors,
|
| 2 | Intent-based VLAN and SSID management | `vlans` — VLAN list by site |
|
||||||
ARP table, VLAN membership, packages, services, and scheduled jobs.`
|
| 3 | A security assessment for every device | `device-security` — a server, Security → Assessment |
|
||||||
- Screenshot: DeviceDetailPage
|
| 4 | One triage queue, decisions that hold | `vulnerabilities` — the triage queue |
|
||||||
|
| 5 | Dashboards you actually build | `dashboard` — the home dashboard |
|
||||||
|
| 6 | Service checks every minute | `service-checks` — Network → Service Checks |
|
||||||
|
|
||||||
**Row 2 — Right text, left screenshot**
|
Text sits left on odd rows and right on even rows; on mobile the text always
|
||||||
- Heading: `Intent-based VLAN and SSID management`
|
comes first.
|
||||||
- Copy: `Define VLAN names and SSID settings once. netOrk compares them
|
|
||||||
against every polled device and pushes corrections automatically via
|
|
||||||
UCI (OpenWRT) or the device's native API.`
|
|
||||||
- Screenshot: VlansPage or WirelessPage
|
|
||||||
|
|
||||||
**Row 3 — Left text, right screenshot**
|
---
|
||||||
- Heading: `Security visibility per device`
|
|
||||||
- Copy: `Wazuh agent status, CVE counts by severity, and recent alerts
|
### Section 5b — NIS2
|
||||||
— all linked to the device record. One-click agent install if the
|
|
||||||
agent is missing. Graylog syslog forwarding status with auto-fix.`
|
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
|
||||||
- Screenshot: SecurityTab inside DeviceDetailPage
|
|
||||||
|
**Layout:** Left column — label + Art. 21 mapping list. Right column — screenshot of the audit log (`audit-log`), the evidence the list refers to.
|
||||||
|
|
||||||
|
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
|
||||||
|
|
||||||
|
**Heading:** `Evidence, not paperwork.`
|
||||||
|
|
||||||
|
**Copy:**
|
||||||
|
```
|
||||||
|
NIS2 Art. 21 mandates asset inventory, patch management, access control,
|
||||||
|
and audit trails as baseline technical measures. netOrk doesn't bolt on a
|
||||||
|
compliance layer — these are its day-to-day outputs.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Art. 21 mapping (4 rows, icon = monospace article ref in sky-500):**
|
||||||
|
- Art. 21 (2e) → Patch & vulnerability management — Per-device update status, Wazuh CVE counts by severity
|
||||||
|
- Art. 21 (2h) → Asset management & access control — Full device inventory, RBAC with four roles, complete audit log
|
||||||
|
- Art. 21 (2a) → Risk analysis baseline — Config drift detection, SNMP health metrics, security agent coverage
|
||||||
|
- Art. 21 (2b) → Incident detection — Wazuh alert history, CrowdSec decisions, Graylog syslog per device
|
||||||
|
|
||||||
|
**Mock UI (right column):** `MockCompliance` — per-site checklist with ✓/⚠ rows, each showing label + detail stat. Label: `netork.local / compliance / HQ`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,16 +238,22 @@ sticky section nav). One section per capability area.
|
|||||||
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
|
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
|
||||||
1. Device Management
|
1. Device Management
|
||||||
2. Discovery
|
2. Discovery
|
||||||
3. Supported Drivers (full table)
|
3. VM Provisioning
|
||||||
4. Networking & Inventory
|
4. Supported Drivers (full table)
|
||||||
5. Configuration Management & Drift
|
5. Networking & Inventory
|
||||||
6. Scheduled Operations
|
6. Configuration Management & Drift
|
||||||
7. Monitoring & Health
|
7. Configuration Automation (Ansible)
|
||||||
8. Security Integrations
|
8. Scheduled Operations
|
||||||
9. DNS Management
|
9. Satellite Deployments
|
||||||
10. Access Control (RBAC)
|
10. Monitoring & Health
|
||||||
11. NetBox Sync
|
11. Dashboards
|
||||||
12. Developer Experience
|
12. Security Integrations
|
||||||
|
13. DNS Management
|
||||||
|
14. RADIUS Management
|
||||||
|
15. Access Control (RBAC)
|
||||||
|
16. NetBox Sync
|
||||||
|
17. Compliance & Audit (NIS2)
|
||||||
|
18. Developer Experience
|
||||||
|
|
||||||
Each section: `text-xl font-semibold text-slate-200` heading +
|
Each section: `text-xl font-semibold text-slate-200` heading +
|
||||||
feature items as a clean list with `text-slate-400` body.
|
feature items as a clean list with `text-slate-400` body.
|
||||||
@@ -285,6 +313,39 @@ feature items as a clean list with `text-slate-400` body.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `/roadmap` — Roadmap
|
||||||
|
|
||||||
|
**Purpose:** Show what's being built and what's under consideration. Signal NIS2 investment clearly.
|
||||||
|
|
||||||
|
**Layout:** Page header + two vertical groups ("Planned" / "Under consideration"), each a list of items.
|
||||||
|
|
||||||
|
**NIS2 badge:** `NIS2` monospace tag (sky-500/10 bg, sky-400 text, sky-500/20 border) inline next to item title.
|
||||||
|
|
||||||
|
**Intro copy:**
|
||||||
|
```
|
||||||
|
What's being built and what's being evaluated. Items tagged NIS2 directly
|
||||||
|
address NIS2 Art. 21 technical baseline requirements.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Planned items (NIS2-tagged):**
|
||||||
|
- CVE tracking per device — NVD / OSV cross-reference
|
||||||
|
- Compliance dashboard — per-site Art. 21 checklist view
|
||||||
|
|
||||||
|
**Planned items (general):**
|
||||||
|
- Webhook engine — outbound events with HMAC signing
|
||||||
|
- Live job log streaming — WebSocket for all long-running tasks
|
||||||
|
- NetBox sync — manual trigger + status view
|
||||||
|
|
||||||
|
**Under consideration (NIS2-tagged):**
|
||||||
|
- Incident workflow — structured record + NIS2 Art. 23 Fristen-Tracker
|
||||||
|
|
||||||
|
**Under consideration (general):**
|
||||||
|
- mDNS scanner — media device discovery
|
||||||
|
- Prometheus + Grafana — metrics and dashboards
|
||||||
|
- Kubernetes Helm chart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `/docs/architecture` — Technical Overview
|
## `/docs/architecture` — Technical Overview
|
||||||
|
|
||||||
**Purpose:** Give engineers the mental model before they look at code.
|
**Purpose:** Give engineers the mental model before they look at code.
|
||||||
@@ -314,15 +375,52 @@ expanded into a readable overview with the architecture diagram (ASCII or SVG).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `/for/*` — Persona Pages
|
||||||
|
|
||||||
|
**Purpose:** Answer "is this for me?" from the perspective of a specific
|
||||||
|
buyer/user, instead of one generic homepage pitch. Reachable via the "Für
|
||||||
|
wen" / "Who it's for" nav dropdown.
|
||||||
|
|
||||||
|
**Shared layout:** hero (icon + heading + sub) → "Your day today" pain-point
|
||||||
|
cards (persona-specific, concrete workflow friction) → feature-callout cards
|
||||||
|
(only shipped capabilities, cited from `docs/PRODUCT.md`) → CTA block linking
|
||||||
|
to `/docs/getting-started`. Same card/section classes as `/plugins`.
|
||||||
|
|
||||||
|
- **`/for/it-department`** — core admin/engineer audience. Pain points:
|
||||||
|
per-vendor admin UIs, no single inventory view, undocumented config
|
||||||
|
changes, manual SSH just to check state. Features: Device Management,
|
||||||
|
config drift + one-click fix, Git-backed config history, Ansible
|
||||||
|
automation, VM Provisioning, Dashboards. Plus a short supported-drivers
|
||||||
|
strip linking to `/drivers`.
|
||||||
|
- **`/for/it-support`** — day-to-day operators, less config depth. Pain
|
||||||
|
points: "is it up right now?", repeated manual reboots, no change record,
|
||||||
|
full admin access for one ticket. Features: warning system + dashboard
|
||||||
|
widget, one-click Ack, Wake-on-LAN, scheduled reboots/updates, filterable
|
||||||
|
audit log + export, roles scoped below engineer level.
|
||||||
|
- **`/for/msp`** — managed service providers, strongest standalone buying
|
||||||
|
case. Pain points: unreachable client sites, no cross-client view, proving
|
||||||
|
what was done, client data in someone else's cloud. Features: Satellite
|
||||||
|
Deployments, automatic routing around unreachable sites (with the honest
|
||||||
|
caveat that SNMP metrics + WebSSH still need direct reach), audit trail as
|
||||||
|
client-facing evidence, per-technician custom roles (**not** phrased as
|
||||||
|
per-site RBAC — netOrk's roles are global permission sets, not
|
||||||
|
site-scoped), self-hosted/no per-seat SaaS.
|
||||||
|
|
||||||
|
The existing `/nis2` page (security/compliance persona) is linked from the
|
||||||
|
same dropdown rather than duplicated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Global Layout
|
## Global Layout
|
||||||
|
|
||||||
### Navigation (all pages)
|
### Navigation (all pages)
|
||||||
|
|
||||||
```
|
```
|
||||||
[ netOrk ] Features Drivers Docs ▾ Plugins [ Get started ]
|
[ netOrk ] Features Drivers Docs ▾ Für wen ▾ Plugins Roadmap [ Get started ]
|
||||||
```
|
```
|
||||||
|
|
||||||
`Docs` is a dropdown: Getting Started / Architecture
|
`Docs ▾`: Getting Started / Architecture / NIS2 Compliance / Glossary
|
||||||
|
`Für wen ▾`: IT Department / IT Support / MSP / NIS2 Compliance (reuses the Docs dropdown's NIS2 link/label)
|
||||||
|
|
||||||
### Footer
|
### Footer
|
||||||
|
|
||||||
@@ -333,6 +431,11 @@ Links: Resources: Legal:
|
|||||||
Features Getting Started MIT License
|
Features Getting Started MIT License
|
||||||
Drivers Architecture Privacy (none collected)
|
Drivers Architecture Privacy (none collected)
|
||||||
Plugins Changelog
|
Plugins Changelog
|
||||||
|
Roadmap NIS2
|
||||||
|
Glossary
|
||||||
|
For IT Departments
|
||||||
|
For IT Support
|
||||||
|
For MSPs
|
||||||
```
|
```
|
||||||
|
|
||||||
Footer background: `bg-slate-900 border-t border-slate-800`
|
Footer background: `bg-slate-900 border-t border-slate-800`
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ hardware and want operational visibility beyond what consumer dashboards offer.
|
|||||||
8. **Self-hosted, no SaaS** — Runs in Docker Compose. Your data stays on your
|
8. **Self-hosted, no SaaS** — Runs in Docker Compose. Your data stays on your
|
||||||
infrastructure. No telemetry, no cloud dependency.
|
infrastructure. No telemetry, no cloud dependency.
|
||||||
|
|
||||||
|
9. **NIS2 evidence foundation** — NIS2 Art. 21 mandates asset inventory, patch
|
||||||
|
management, access control, and audit trails. netOrk produces all of these as
|
||||||
|
day-to-day operational outputs: full device inventory, per-device update status,
|
||||||
|
Wazuh CVE tracking, EOL firmware/OS flagging, RBAC with MFA, Git-backed config
|
||||||
|
snapshots with diff/restore, config drift detection, and a complete audit log.
|
||||||
|
|
||||||
|
10. **Build your own view** — Configurable, shareable dashboards: pick from 13
|
||||||
|
widgets, arrange them on a WYSIWYG grid, and share the result with colleagues
|
||||||
|
who can subscribe to the live version or clone their own copy.
|
||||||
|
|
||||||
|
11. **From zero to managed in one flow** — Provision a Cloud-Init VM on a
|
||||||
|
Proxmox hypervisor, assign Ansible roles to configure it, and netOrk
|
||||||
|
auto-links it as a Device — no separate tools, no manual SSH-and-copy.
|
||||||
|
|
||||||
|
12. **Reach sites netOrk can't touch directly** — Deploy a lightweight
|
||||||
|
Satellite agent to poll devices locally at a disconnected or firewalled
|
||||||
|
site and sync results back over HTTPS; scheduled fixes route through it
|
||||||
|
the same way they do for directly reachable devices.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature List
|
## Feature List
|
||||||
@@ -76,64 +95,223 @@ hardware and want operational visibility beyond what consumer dashboards offer.
|
|||||||
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
|
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
|
||||||
- Site assignment with FK to structured Site records
|
- Site assignment with FK to structured Site records
|
||||||
- AP Profile assignment for grouped OpenWRT config
|
- AP Profile assignment for grouped OpenWRT config
|
||||||
|
- Web SSH terminal: sessions log in with each user's own SSH key, never the
|
||||||
|
device's shared account; opened and refused sessions are recorded. Sessions
|
||||||
|
are movable, dockable windows that survive navigating away
|
||||||
|
- A device can hold several roles at once (e.g. storage + hypervisor + Linux)
|
||||||
|
- One device per address per site; duplicates are refused (VMs exempt)
|
||||||
|
- Business criticality per device and site, used in vulnerability ranking
|
||||||
|
|
||||||
### Discovery
|
### Discovery
|
||||||
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
|
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
|
||||||
- Device fingerprinting: vendor + platform confidence scoring
|
- Device fingerprinting: vendor + platform confidence scoring
|
||||||
- FQDN resolution (reverse DNS)
|
- FQDN resolution (reverse DNS)
|
||||||
- Manual adoption from scan results (no auto-create to avoid inventory noise)
|
- Manual adoption from scan results (no auto-create to avoid inventory noise)
|
||||||
|
- Discovery jobs in a sortable, filterable table, grouped per site
|
||||||
|
- LAN Scan: ping sweep from netOrk, each site satellite and every firewall;
|
||||||
|
live results with MAC and manufacturer; a finished scan becomes a discovery
|
||||||
|
job in one step
|
||||||
|
|
||||||
|
### VM Provisioning
|
||||||
|
- Cloud-Init based VM creation directly from a hypervisor's VMs tab — no
|
||||||
|
manual template or VMID setup
|
||||||
|
- Multi-distro image catalog: Debian 12, Ubuntu 22.04/24.04/26.04,
|
||||||
|
Fedora 42/43/44, with Ubuntu and Fedora releases synced automatically as
|
||||||
|
new versions ship
|
||||||
|
- Pick a target VLAN and an IP from its subnet — netOrk creates the DHCP
|
||||||
|
reservation automatically
|
||||||
|
- Cloud-init provisions a real Linux user with an SSH key, plus configurable
|
||||||
|
bootstrap toggles (SNMP, QEMU guest agent)
|
||||||
|
- Reusable provisioning templates for repeatable bootstrap settings
|
||||||
|
- The new VM is auto-linked as a netOrk Device and its hostname assigned to
|
||||||
|
a DNS zone once bootstrap finishes
|
||||||
|
- Deploy progress shown as a live step checklist in the UI
|
||||||
|
- Delete a VM and its linked netOrk Device together, gated behind a
|
||||||
|
name-confirmation prompt
|
||||||
|
|
||||||
### Supported Device Drivers
|
### Supported Device Drivers
|
||||||
Custom NAPALM drivers for all of the following:
|
Custom NAPALM drivers for all of the following:
|
||||||
|
|
||||||
| Driver | Device type |
|
| Driver | Device type |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `openwrt` | OpenWRT access points |
|
| `fritzbox` | AVM Fritz!Box routers (read-only) |
|
||||||
| `opnsense` | OPNsense firewalls |
|
| `hpe_officeconnect` | HPE OfficeConnect 1820 / 1920S switches |
|
||||||
| `proxmox` | Proxmox VE hypervisors |
|
|
||||||
| `linux` | Generic Linux servers |
|
| `linux` | Generic Linux servers |
|
||||||
| `procurve` | HP ProCurve / Aruba switches |
|
| `netgear_plus` | Netgear Plus switches (web UI) |
|
||||||
| `tplink_jetstream` | TP-Link Jetstream managed switches |
|
| `netgear_smart` | Netgear Smart Managed Pro switches |
|
||||||
| `netgear` | Netgear switches |
|
|
||||||
| `fritzbox` | AVM Fritz!Box routers |
|
|
||||||
| `zyxel` | Zyxel switches |
|
|
||||||
| `openmediavault` | OpenMediaVault NAS |
|
| `openmediavault` | OpenMediaVault NAS |
|
||||||
|
| `openwrt` | OpenWrt routers and access points |
|
||||||
|
| `opnsense` | OPNsense firewalls |
|
||||||
|
| `procurve` | HPE ProCurve / Aruba switches |
|
||||||
|
| `proxmox` | Proxmox VE hypervisors |
|
||||||
|
| `qnap_qts` | QNAP NAS on QTS |
|
||||||
| `sonos` | Sonos speakers |
|
| `sonos` | Sonos speakers |
|
||||||
|
| `tplink_jetstream` | TP-Link JetStream managed switches |
|
||||||
|
| `yealink` | Yealink IP phones |
|
||||||
|
| `zyxel` | Zyxel VMG routers (not switches) |
|
||||||
|
|
||||||
Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS.
|
The built-in NAPALM drivers (Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS)
|
||||||
|
are installed but not tested with netOrk and get none of its driver-specific
|
||||||
|
features. Capability matrix (audited against v0.28.0): see `src/pages/Drivers.tsx`.
|
||||||
|
Reboot from netOrk actually restarts only OpenWrt and Proxmox.
|
||||||
|
|
||||||
### Networking & Inventory
|
### Networking & Inventory
|
||||||
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
|
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
|
||||||
- LLDP neighbor discovery and topology graph
|
- LLDP neighbor discovery and topology graph, plus links derived from switch
|
||||||
|
MAC tables (drawn dashed)
|
||||||
|
- Radio problems between the access points of a site are reported
|
||||||
- ARP table and DHCP lease browser per device
|
- ARP table and DHCP lease browser per device
|
||||||
- Subnet browser with interface-to-subnet assignments
|
- Subnet browser with interface-to-subnet assignments
|
||||||
- VLAN list grouped by site; per-VLAN device membership view
|
- VLAN list grouped by site; per-VLAN device membership view
|
||||||
- SSID management with push to OpenWRT APs via UCI
|
- SSID management with push to OpenWRT APs via UCI
|
||||||
|
- Per-SSID MAC access control lists (whitelist / blacklist) pushed to every
|
||||||
|
AP broadcasting the SSID, quick-add straight from the Connected Clients list
|
||||||
|
- MAC ACL state is a first-class drift item — covered by the same drift
|
||||||
|
detection, scheduled auto-fix, and warning aggregation as any other config
|
||||||
|
drift
|
||||||
|
- Dedicated Access Control Lists tab on the Wireless page listing every SSID
|
||||||
|
with its ACL editor inline
|
||||||
|
|
||||||
### Configuration Management
|
### Configuration Management
|
||||||
- Config drift detection: desired state (DB) vs device state (poll snapshot)
|
- Config drift detection: desired state (DB) vs device state (poll snapshot)
|
||||||
- One-click drift fix stream with live SSH output in the browser
|
- One-click drift fix stream with live SSH output in the browser
|
||||||
- UCI-based config push for OpenWRT (VLAN names, SSID settings, radio config)
|
- UCI-based config push for OpenWRT (VLAN names, SSID settings, radio config)
|
||||||
- AP profile system: country code, HT/VHT mode, 802.11r, NTP, syslog, SSH port
|
- AP profile system: country code, HT/VHT mode, 802.11r, NTP, syslog, SSH port
|
||||||
|
- Configuration backup & versioning: every poll captures a config snapshot into
|
||||||
|
a local Git repository, with full history and a side-by-side diff viewer
|
||||||
|
between any two points in time
|
||||||
|
- One-click config restore for OPNsense from any prior snapshot
|
||||||
|
- Unauthorised configuration changes are surfaced as a device warning
|
||||||
|
- netOrk's own config pushes (drift fixes, ACL provisioning) are recognized
|
||||||
|
and auto-accepted as the new baseline — never mistaken for an unauthorized
|
||||||
|
change
|
||||||
|
|
||||||
|
### Configuration Automation (Ansible)
|
||||||
|
- Reusable Ansible roles and playbooks stored and edited directly in
|
||||||
|
netOrk — no separate git checkout
|
||||||
|
- 16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
|
||||||
|
portainer, watchtower, uptime-kuma, vaultwarden, stalwart, bulwark, searxng,
|
||||||
|
postiz, listmonk, wireguard, fail2ban
|
||||||
|
- Roles state their resource needs; undersized hosts are refused with a reason
|
||||||
|
- Automatic dependency resolution — assigning `docker` pulls in `base`
|
||||||
|
automatically, no manual role ordering
|
||||||
|
- Built-in roles can't be deleted but are fully editable; customizations
|
||||||
|
survive upgrades, and only untouched files auto-heal on bugfixes
|
||||||
|
- `ansible-doc`-backed autocomplete while writing roles and playbooks
|
||||||
|
- Upload your own role as an archive
|
||||||
|
- Device-level role assignment with a dedicated Ansible tab on the device
|
||||||
|
detail page
|
||||||
|
- Run history per device, snapshotting the exact role/playbook content
|
||||||
|
that was executed
|
||||||
|
- Wired into VM provisioning: assign roles at VM-creation time and they
|
||||||
|
run automatically after boot
|
||||||
|
|
||||||
### Scheduled Operations
|
### Scheduled Operations
|
||||||
- Scheduled reboots for OpenWRT APs with per-site concurrency lock
|
- Scheduled reboots for OpenWRT APs with per-site concurrency lock
|
||||||
- Failback cron script written to device for netOrk-unreachable scenarios
|
- Failback cron script written to device for netOrk-unreachable scenarios
|
||||||
- Scheduled config drift fixes with time-window enforcement
|
- Scheduled config drift fixes with time-window enforcement
|
||||||
- Package update scheduling and one-click apply
|
- Package update scheduling and one-click apply
|
||||||
|
- Wake-on-LAN via a firewall's driver (OPNsense today) — saved WOL targets
|
||||||
|
with on-demand "Wake now" and recurring schedules; save a seen host as a
|
||||||
|
target directly from the DHCP/ARP tabs
|
||||||
|
|
||||||
|
### Satellite Deployments
|
||||||
|
- Lightweight Docker agent deployed at a site netOrk can't reach directly —
|
||||||
|
polls devices locally and syncs results back to Central over HTTPS
|
||||||
|
- Deployed in one flow via VM provisioning: pick a hypervisor and site,
|
||||||
|
netOrk provisions the VM and installs the satellite container automatically
|
||||||
|
- Central automatically skips direct polling for any device at a site with
|
||||||
|
an online, heartbeating satellite — no manual per-site toggling
|
||||||
|
- Scheduled/on-demand reboots and the SNMP auto-fix flow run through the
|
||||||
|
same command channel whether a device is directly reachable or behind a
|
||||||
|
satellite
|
||||||
|
- Discovery jobs at a satellite-covered site scan locally through the same
|
||||||
|
command channel, instead of failing to reach the subnet from Central
|
||||||
|
- Not yet satellite-covered: SNMP health-metric polling still runs from
|
||||||
|
Central, and WebSSH console access isn't available through a satellite
|
||||||
|
|
||||||
### Monitoring & Health
|
### Monitoring & Health
|
||||||
- SNMP health metrics (CPU, memory, interface counters) via `get_health_metrics()`
|
- SNMP health metrics (CPU, memory, interface counters) via `get_health_metrics()`
|
||||||
- Per-device warning system with severity levels (error / warning / info)
|
- Per-device warning system with severity levels (error / warning / info)
|
||||||
- Docker container and image status (Proxmox/Linux)
|
- One-click Ack on any warning — clears it immediately and writes an audit log
|
||||||
|
entry; for config-change warnings the current state is accepted as the new
|
||||||
|
baseline
|
||||||
|
- Docker container and image status (Linux, OpenMediaVault, QNAP)
|
||||||
- Service status and start/stop/restart (systemd)
|
- Service status and start/stop/restart (systemd)
|
||||||
- VM/container list with OS device cross-linking (Proxmox)
|
- VM/container list with OS device cross-linking (Proxmox)
|
||||||
|
- Per-device availability windows — suppress OFFLINE status and poll-failure
|
||||||
|
warnings during expected downtime (e.g. a nightly power-off); opt-in,
|
||||||
|
unconfigured devices are unaffected
|
||||||
|
- OPNsense: BGP neighbor status polling and display, with a peer-down warning
|
||||||
|
- OPNsense: TLS certificate monitoring for the Trust store, with
|
||||||
|
expiring-soon / expired warnings
|
||||||
|
- OPNsense: Dynamic DNS service-down warning (os-ddclient)
|
||||||
|
- Service checks about once a minute (DNS, NTP, VPN tunnels, core daemons,
|
||||||
|
gateways), derived automatically; three failures before an alert; can run
|
||||||
|
from satellites, including a DHCP check
|
||||||
|
- Site reachability: polling pauses behind a dead tunnel, one warning names
|
||||||
|
it, everything is re-polled when it returns
|
||||||
|
|
||||||
|
### Dashboards
|
||||||
|
- Configurable, shareable dashboards — build your own from a widget picker
|
||||||
|
instead of a fixed layout
|
||||||
|
- WYSIWYG grid-layout editor: drag, resize, and arrange widgets on a canvas
|
||||||
|
- 18 widget types: stats, device warnings, recently updated devices, network
|
||||||
|
topology, EOL status, config drift summary, Wazuh security alerts, audit log
|
||||||
|
activity, discovery jobs status, upcoming scheduled actions, DNS zones
|
||||||
|
overview, site overview, config snapshot history, managed services,
|
||||||
|
certificate expiry, outdated Docker images, firewall profile deployment
|
||||||
|
status, service checks
|
||||||
|
- Multi-instance widgets with independent per-widget settings
|
||||||
|
- Share a dashboard with specific users; recipients can subscribe to the
|
||||||
|
owner's live version or clone it into their own editable copy
|
||||||
|
- Favorite dashboards for quick access from the main menu; set any dashboard
|
||||||
|
as your home view
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- Signal messages for everything netOrk watches; each person registers their
|
||||||
|
own number, administrators pair netOrk once via QR code
|
||||||
|
- One message per site outage, daily summary for recurring items, hourly
|
||||||
|
bundling, quiet hours per number, mute per kind, full history with reasons
|
||||||
|
|
||||||
|
### DHCP
|
||||||
|
- DHCP reservations: import from the firewall, validated, diff, then apply
|
||||||
|
(adds and updates only)
|
||||||
|
- DHCP subnets (Kea on OPNsense) with options and search domains; settings
|
||||||
|
that break a network are refused
|
||||||
|
|
||||||
|
### Managed Services
|
||||||
|
- Every container-based service across devices with endpoints, TLS
|
||||||
|
certificates and access rules
|
||||||
|
- Compose editor with masked secrets and automatic backup snapshot; redeploy
|
||||||
|
is a separate confirmed step
|
||||||
|
- Zoraxy vhosts editable and written back; PostgreSQL databases listed
|
||||||
|
|
||||||
|
### Security Assessment
|
||||||
|
- Security tab per device: TLS/SSH grades A–F, installed software and
|
||||||
|
container images matched against known vulnerabilities, hardening benchmarks
|
||||||
|
- Ratings adjusted to the device (local access, trusted network, not running,
|
||||||
|
not booted kernel; raised when exploited in the wild)
|
||||||
|
- Kernel reboot recommendation with the vulnerabilities it would clear
|
||||||
|
- Exposure from firewall rules; internet-visible ports and abuse reports for
|
||||||
|
own public addresses; on-demand hardening audit and web scan
|
||||||
|
- Vulnerability data from the netOrk Knowledge Base (licence required)
|
||||||
|
|
||||||
|
### Vulnerability Management
|
||||||
|
- Triage queue across all devices, one row per vulnerability, ordered by
|
||||||
|
remediation deadline, exploitation, severity, likelihood, criticality, spread
|
||||||
|
- Decisions (not applicable / accept until / defer until / fixed) with a
|
||||||
|
mandatory reason; accept and not-applicable need an elevated permission
|
||||||
|
- Deferred and accepted items return by themselves; ignored ones go overdue
|
||||||
|
- Daily reassessment verifies fixes and reopens regressions
|
||||||
|
|
||||||
### Security Integrations (plugins)
|
### Security Integrations (plugins)
|
||||||
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
|
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
|
||||||
recent alert history, CIS benchmark scores, one-click agent install fix stream
|
recent alert history, CIS benchmark scores, one-click agent install fix stream
|
||||||
- **Graylog** — rsyslog forwarding status per device, one-click fix to write rule
|
- **Graylog** — rsyslog forwarding status per device, one-click fix to write rule
|
||||||
- **CrowdSec** — org-level decisions, remediation metrics, top attack scenarios
|
- **CrowdSec** — org-level decisions, remediation metrics, top attack scenarios
|
||||||
|
- **EOL Tracking** — flags devices running end-of-life or soon-to-be-end-of-life
|
||||||
|
firmware/OS via the endoflife.date API, checked daily
|
||||||
|
|
||||||
### DNS
|
### DNS
|
||||||
- DNS zone management with authoritative device assignment
|
- DNS zone management with authoritative device assignment
|
||||||
@@ -141,11 +319,26 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
|||||||
- PTR record provisioning to reverse zones
|
- PTR record provisioning to reverse zones
|
||||||
- Pending job queue for zone changes when device is unreachable
|
- Pending job queue for zone changes when device is unreachable
|
||||||
|
|
||||||
|
### RADIUS Management
|
||||||
|
- Global FreeRADIUS server, NAS client, and user management — no site
|
||||||
|
scoping, usable from any site's SSIDs/APs
|
||||||
|
- Changes push to the device via the driver and are only stored after the
|
||||||
|
device confirms — avoids drift between netOrk's view and the actual
|
||||||
|
FreeRADIUS config
|
||||||
|
- Dedicated server list and detail page (NAS clients / users tabs) under
|
||||||
|
the Wireless section
|
||||||
|
- SSID 802.1X integration (auto-provisioning a NAS client from an SSID's
|
||||||
|
RADIUS server) is a deliberate follow-up, not included yet
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
- JWT authentication with remember-me (localStorage) or session-only (sessionStorage)
|
- JWT authentication with remember-me (localStorage) or session-only (sessionStorage)
|
||||||
|
- Two-factor authentication (MFA/TOTP) — authenticator app at login, backup
|
||||||
|
codes for emergencies, session invalidation on TOTP changes, enforceable
|
||||||
|
per role
|
||||||
- RBAC with four built-in roles: viewer / operator / engineer / administrator
|
- RBAC with four built-in roles: viewer / operator / engineer / administrator
|
||||||
- Custom roles with any permission combination
|
- Custom roles with any permission combination
|
||||||
- Full audit log of all orchestration actions
|
- Full audit log of all orchestration actions, filterable by date range,
|
||||||
|
user, action, or resource — export to CSV or PDF
|
||||||
|
|
||||||
### NetBox Sync
|
### NetBox Sync
|
||||||
- Pushes vendor, model, OS version, status to NetBox dcim.devices
|
- Pushes vendor, model, OS version, status to NetBox dcim.devices
|
||||||
@@ -163,11 +356,15 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
|||||||
|
|
||||||
## Architecture in One Paragraph
|
## Architecture in One Paragraph
|
||||||
|
|
||||||
netOrk runs as five Docker containers: a FastAPI API server, two Celery worker
|
netOrk runs as a set of Docker containers: a FastAPI API server, three Celery
|
||||||
pools (general + poll), a Celery Beat scheduler, and an nginx UI server. Redis
|
worker pools (general, poll, and Ansible), a Celery Beat scheduler, and an
|
||||||
is the broker. PostgreSQL stores all state. Device communication is always
|
nginx UI server. Redis is the broker. PostgreSQL stores all state. Device
|
||||||
blocking I/O executed in Celery workers — FastAPI request handlers are
|
communication is always blocking I/O executed in Celery workers — FastAPI
|
||||||
async-only for DB and quick operations. Custom NAPALM drivers live in `vendor/`
|
request handlers are async-only for DB and quick operations. Custom NAPALM
|
||||||
as editable packages and self-register via `@register_driver`. The plugin system
|
drivers live in `vendor/` as editable packages and self-register via
|
||||||
(`netork/plugins/`) provides a hook bus, a plugin registry with enable/disable
|
`@register_driver`. The plugin system (`netork/plugins/`) provides a hook bus,
|
||||||
state in the DB, and a documented pattern for adding integrations.
|
a plugin registry with enable/disable state in the DB, and a documented
|
||||||
|
pattern for adding integrations. For sites Central can't reach directly, a
|
||||||
|
separate Satellite container polls devices locally and syncs results back
|
||||||
|
over HTTPS; Central dispatches actions (reboots, SNMP fixes) to it through a
|
||||||
|
generic command channel, transparently to the UI.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="netOrk — self-hosted network orchestration for heterogeneous infrastructure. Discover, monitor, and manage routers, switches, firewalls, and servers from a single UI." />
|
||||||
|
<title>netOrk — Network Orchestration Platform</title>
|
||||||
|
<link rel="icon" href="/logo.png" />
|
||||||
|
<meta property="og:image" content="/logo.png" />
|
||||||
|
<meta property="og:title" content="netOrk — Network Orchestration Platform" />
|
||||||
|
<meta property="og:description" content="Self-hosted network orchestration for heterogeneous infrastructure." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_vary on;
|
||||||
|
|
||||||
|
# SPA fallback — all routes resolve to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Immutable cache for hashed assets (Vite adds content hashes)
|
||||||
|
location ~* \.(js|css|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(png|jpg|jpeg|gif|svg|ico|webp)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "netork-website",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.1.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
# Demo instance for screenshots
|
||||||
|
|
||||||
|
The website shows real netOrk screens, taken from a local copy of a production
|
||||||
|
database with every hostname, domain, address, MAC and name replaced.
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_dump -Fc ... > netork.dump # on the production host, by hand
|
||||||
|
scripts/demo/up.sh restore netork.dump # fresh local DB + anonymize.py
|
||||||
|
scripts/demo/up.sh start # API :8000, UI http://127.0.0.1:5173
|
||||||
|
scripts/screenshots/capture.py --list-devices
|
||||||
|
scripts/screenshots/capture.py --var ap=<id> --var switch=<id> --var server=<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in as `netork` / `netork-demo`.
|
||||||
|
|
||||||
|
- Only the API and the UI run. There is no worker, no beat and no Redis, so
|
||||||
|
nothing polls or reaches a device. Stored credentials are emptied, and the
|
||||||
|
encryption key is random per start.
|
||||||
|
- The mapping from real to demo names lives outside the repo in
|
||||||
|
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
|
||||||
|
Domains become `example.demo`.
|
||||||
|
- `anonymize.py` ends with a leak report. Read it before taking screenshots,
|
||||||
|
and look at every image before committing it.
|
||||||
|
- The dump file itself holds production data: keep it out of the repo and
|
||||||
|
delete it when done.
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Turn a restored copy of a production netOrk database into demo data.
|
||||||
|
|
||||||
|
anonymize.py [--dsn postgresql://...] [--map demo-map.json] [--dry-run]
|
||||||
|
|
||||||
|
Run it against the LOCAL copy only; it refuses anything that is not
|
||||||
|
localhost. It works on every text-like column of every table instead of a
|
||||||
|
hand-kept list, so a table added in a later release is covered too:
|
||||||
|
|
||||||
|
* domains every configured domain (e.g. corp.example.com, acme.io) becomes
|
||||||
|
`example.demo`, subdomains kept: gw.home.corp.example.com ->
|
||||||
|
gw.home.example.demo
|
||||||
|
* IPv4 private addresses move to another /16 per /16, host part kept,
|
||||||
|
so subnets and VLAN plans still line up; public addresses are
|
||||||
|
mapped one by one into the documentation ranges
|
||||||
|
* IPv6 global prefixes go to 2001:db8::/32, interface IDs are hashed
|
||||||
|
* MAC the vendor prefix (OUI) is kept, so manufacturer lookups still
|
||||||
|
work; the device part is hashed
|
||||||
|
* e-mail local part hashed, domain example.demo
|
||||||
|
* names hostnames, site names, VLAN names, user names ... from the map
|
||||||
|
* secrets stored credentials, keys, tokens, TOTP and secret settings are
|
||||||
|
emptied; one admin `netork` with a known password is left
|
||||||
|
|
||||||
|
Every mapping is deterministic, so the same address always turns into the
|
||||||
|
same fake one, across tables, JSON documents and log lines alike. At the end
|
||||||
|
a leak report lists anything that still looks like the original.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
DEFAULT_DSN = "postgresql://netork:demo@127.0.0.1:55432/netork"
|
||||||
|
DEFAULT_MAP = Path.home() / ".config" / "netork-screenshots" / "demo-map.json"
|
||||||
|
DEMO_DOMAIN = "example.demo"
|
||||||
|
|
||||||
|
# Public reference data: large, and nothing in it is about the instance.
|
||||||
|
SKIP_TABLES = {
|
||||||
|
"alembic_version", "cwe_entries", "epss_scores", "nvd_cpe_matches",
|
||||||
|
"nvd_cpe_products", "nvd_cve_requirements", "nvd_cves", "osv_affected",
|
||||||
|
"osv_vulns", "oui_vendors", "service_templates",
|
||||||
|
}
|
||||||
|
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
|
||||||
|
|
||||||
|
# Only these count as internal addresses to move; Python's is_private also
|
||||||
|
# covers 0.0.0.0/8 and friends, which in practice are version numbers.
|
||||||
|
PRIVATE_NETS = [ipaddress.IPv4Network(n) for n in
|
||||||
|
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
|
||||||
|
|
||||||
|
# Well-known public resolvers stay as they are; they say nothing about anyone.
|
||||||
|
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
|
||||||
|
|
||||||
|
IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})(?!\d|\.\d)")
|
||||||
|
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
|
||||||
|
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
|
||||||
|
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
|
||||||
|
# Reverse zones and PTR names: 8.22.172.in-addr.arpa is 172.22.8.0/24.
|
||||||
|
REVERSE = re.compile(r"(?<![\d.])((?:\d{1,3}\.){1,4})in-addr\.arpa", re.I)
|
||||||
|
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
|
||||||
|
|
||||||
|
|
||||||
|
def h(value: str, n: int) -> str:
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()[:n]
|
||||||
|
|
||||||
|
|
||||||
|
class Mapper:
|
||||||
|
def __init__(self, cfg: dict):
|
||||||
|
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
|
||||||
|
self.domains: dict[str, str] = cfg.get("domains", {})
|
||||||
|
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
|
||||||
|
taken = set(self.prefix16.values())
|
||||||
|
pool = cfg.get("ipv4_pool16") or (
|
||||||
|
[f"10.{n}" for n in range(20, 256, 10)] + [f"10.{n}" for n in range(256) if n % 10]
|
||||||
|
+ [f"172.{n}" for n in range(16, 32)])
|
||||||
|
self.pool16 = iter(p for p in pool if p not in taken)
|
||||||
|
self.public: dict[str, str] = {}
|
||||||
|
self.public_used: set[str] = set()
|
||||||
|
# Public-looking dotted quads are only mapped once they were seen as an
|
||||||
|
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
|
||||||
|
self.known_public: set[str] = set(cfg.get("public_ips", []))
|
||||||
|
self.unmapped_public: dict[str, int] = {}
|
||||||
|
self.public_pool = iter(
|
||||||
|
[f"203.0.113.{n}" for n in range(10, 250)] + [f"198.51.100.{n}" for n in range(10, 250)])
|
||||||
|
names = {**cfg.get("hostnames", {}), **cfg.get("terms", {})}
|
||||||
|
self.names = names
|
||||||
|
self.names_re = None
|
||||||
|
if names:
|
||||||
|
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
|
||||||
|
# A name is a whole token: not glued to letters, digits, '-' or '_'.
|
||||||
|
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
|
||||||
|
# Plain substrings, for names glued into identifiers (HOME_OFFICE_MGMT_NET).
|
||||||
|
self.substrings: dict[str, str] = cfg.get("substrings", {})
|
||||||
|
self.domain_re = None
|
||||||
|
if self.domains:
|
||||||
|
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
|
||||||
|
# Lazy prefix, so the longest configured domain wins.
|
||||||
|
self.domain_re = re.compile(rf"(?<![\w-])((?:[\w-]+\.)*?)({alt})(?![\w-])", re.I)
|
||||||
|
|
||||||
|
# -- single values -------------------------------------------------------
|
||||||
|
def ipv4(self, ip: str) -> str:
|
||||||
|
a = ipaddress.IPv4Address(ip)
|
||||||
|
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
|
||||||
|
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
|
||||||
|
return ip
|
||||||
|
if any(a in net for net in PRIVATE_NETS):
|
||||||
|
p = ".".join(ip.split(".")[:2])
|
||||||
|
if p not in self.prefix16:
|
||||||
|
self.prefix16[p] = next(self.pool16)
|
||||||
|
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
|
||||||
|
if not a.is_global:
|
||||||
|
return ip # 0.x, 192.0.0.x, benchmark ... : versions more often than hosts
|
||||||
|
if ip not in self.known_public:
|
||||||
|
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
|
||||||
|
return ip
|
||||||
|
if ip not in self.public:
|
||||||
|
fake = next(self.public_pool, None)
|
||||||
|
probe = 0
|
||||||
|
while fake is None or fake in self.public_used:
|
||||||
|
# Documentation ranges exhausted (CrowdSec alone brings tens of
|
||||||
|
# thousands of attacker addresses): hash into the non-routable
|
||||||
|
# benchmark range 198.18.0.0/15, probing on collision.
|
||||||
|
n = int(h(f"{ip}/{probe}", 8), 16) % (2 ** 17)
|
||||||
|
fake = f"198.{18 + (n >> 16)}.{(n >> 8) & 255}.{n & 255}"
|
||||||
|
probe += 1
|
||||||
|
self.public_used.add(fake)
|
||||||
|
self.public[ip] = fake
|
||||||
|
return self.public[ip]
|
||||||
|
|
||||||
|
def mac(self, m: str) -> str:
|
||||||
|
sep = m[2]
|
||||||
|
hexs = m.replace(sep, "")
|
||||||
|
new = hexs[:6] + h(hexs.lower(), 6)
|
||||||
|
new = new.upper() if hexs.isupper() else new.lower()
|
||||||
|
return sep.join(new[i:i + 2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
|
def mac_dot(self, m: str) -> str:
|
||||||
|
hexs = m.replace(".", "")
|
||||||
|
new = hexs[:6] + h(hexs.lower(), 6)
|
||||||
|
return ".".join(new[i:i + 4] for i in range(0, 12, 4))
|
||||||
|
|
||||||
|
def ipv6(self, s: str) -> str:
|
||||||
|
# "Data::" or "12:30:45" are no addresses; demand three real groups.
|
||||||
|
if sum(1 for g in s.split(":") if g) < 3:
|
||||||
|
return s
|
||||||
|
try:
|
||||||
|
a = ipaddress.IPv6Address(s)
|
||||||
|
except ValueError:
|
||||||
|
return s # a time like 12:30:45 or similar, not an address
|
||||||
|
if a.is_loopback or a.is_unspecified or a.is_multicast:
|
||||||
|
return s
|
||||||
|
iid = h(a.packed[8:].hex(), 16)
|
||||||
|
if a.is_link_local:
|
||||||
|
prefix = "fe80:0000:0000:0000"
|
||||||
|
elif a.is_private: # ULA fd00::/8, keep it ULA
|
||||||
|
prefix = "fd00:" + h(a.packed[:8].hex(), 12)
|
||||||
|
prefix = prefix[:4] + ":" + prefix[5:9] + ":" + prefix[9:13] + ":" + prefix[13:17].ljust(4, "0")
|
||||||
|
else:
|
||||||
|
p = h(a.packed[:8].hex(), 8)
|
||||||
|
prefix = f"2001:0db8:{p[:4]}:{p[4:]}"
|
||||||
|
full = prefix + ":" + ":".join(iid[i:i + 4] for i in range(0, 16, 4))
|
||||||
|
return str(ipaddress.IPv6Address(full))
|
||||||
|
|
||||||
|
def email(self, m: re.Match) -> str:
|
||||||
|
e = m.group(0)
|
||||||
|
if e.endswith("@" + DEMO_DOMAIN):
|
||||||
|
return e
|
||||||
|
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
|
||||||
|
|
||||||
|
def reverse(self, m: re.Match) -> str:
|
||||||
|
octets = m.group(1).rstrip(".").split(".")[::-1] # forward order
|
||||||
|
if len(octets) < 2 or any(int(o) > 255 for o in octets):
|
||||||
|
return m.group(0)
|
||||||
|
padded = octets + ["0"] * (4 - len(octets))
|
||||||
|
mapped = self.ipv4(".".join(padded)).split(".")[:len(octets)]
|
||||||
|
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||||
|
|
||||||
|
# -- whole strings -------------------------------------------------------
|
||||||
|
def text(self, s: str) -> str:
|
||||||
|
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
|
||||||
|
else f'"{m.group(1)}"{m.group(2)}""', s)
|
||||||
|
s = REVERSE.sub(self.reverse, s)
|
||||||
|
s = EMAIL.sub(self.email, s)
|
||||||
|
if self.domain_re:
|
||||||
|
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
|
||||||
|
s = MAC.sub(lambda m: self.mac(m.group(1)), s)
|
||||||
|
s = MAC_DOT.sub(lambda m: self.mac_dot(m.group(1)), s)
|
||||||
|
s = IPV6.sub(lambda m: self.ipv6(m.group(1)), s)
|
||||||
|
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
|
||||||
|
if self.names_re:
|
||||||
|
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
|
||||||
|
for old, new in self.substrings.items():
|
||||||
|
s = s.replace(old, new)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Cheap server-side prefilter: only rows that could contain something to map.
|
||||||
|
def prefilter(cfg: dict) -> str:
|
||||||
|
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
|
||||||
|
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@",
|
||||||
|
r"in-addr\.arpa", r"(key|psk|passphrase|password|secret|token)\"\s*:"]
|
||||||
|
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||||
|
*cfg.get("substrings", {})]:
|
||||||
|
parts.append(re.escape(k))
|
||||||
|
return "|".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Columns emptied wherever they occur, found by name so a new table is covered.
|
||||||
|
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token|passphrase|psk|ft_key|wpa_key)", re.I)
|
||||||
|
# The same inside JSON and text: device snapshots carry Wi-Fi keys and the like.
|
||||||
|
SECRET_JSON = re.compile(
|
||||||
|
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||||
|
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||||
|
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||||
|
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||||
|
|
||||||
|
# Whole tables that only hold secrets or personal delivery data.
|
||||||
|
SECRET_TABLES = ["user_ssh_keys", "user_backup_codes", "notification_deliveries",
|
||||||
|
"notification_mutes", "notification_channels", "trusted_networks"]
|
||||||
|
|
||||||
|
|
||||||
|
async def columns(con) -> list[tuple[str, str, str]]:
|
||||||
|
rows = await con.fetch(
|
||||||
|
"SELECT table_name, column_name, data_type FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position")
|
||||||
|
return [(r[0], r[1], r[2]) for r in rows
|
||||||
|
if r[0] not in SKIP_TABLES and r[2] in TEXT_TYPES]
|
||||||
|
|
||||||
|
|
||||||
|
ADDRESS_COLUMN = re.compile(r"(^|_)(ip|ips|ip_address|address|addr|host|target|source|wan|gateway|peer|value)(_|$)")
|
||||||
|
|
||||||
|
|
||||||
|
QUAD = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||||
|
# A dotted quad reads as an address when it is a whole JSON string value (not
|
||||||
|
# under a version-like key) or follows a word that introduces an address.
|
||||||
|
AS_JSON_VALUE = re.compile(rf'(?:"([^"]*)"\s*:\s*)?"({QUAD})(?:/\d{{1,2}})?"')
|
||||||
|
AS_PROSE = re.compile(
|
||||||
|
rf"(?i)\b(?:from|to|ip|ipv4|addr|address|host|src|dst|source|peer|wan|gateway|gw|via|at|by|nameserver|server)\W{{1,3}}({QUAD})")
|
||||||
|
VERSIONISH = re.compile(r"(?i)version|ver$|release|build|firmware|kernel|rev")
|
||||||
|
|
||||||
|
|
||||||
|
def addresses_in(value: str, whole_column: bool) -> set[str]:
|
||||||
|
found = set()
|
||||||
|
if whole_column:
|
||||||
|
found.update(IPV4.findall(value))
|
||||||
|
for key, ip in AS_JSON_VALUE.findall(value):
|
||||||
|
if not (key and VERSIONISH.search(key)):
|
||||||
|
found.add(ip)
|
||||||
|
found.update(AS_PROSE.findall(value))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_public(con, mapper: Mapper) -> None:
|
||||||
|
"""Learn which public IPv4 addresses really are addresses."""
|
||||||
|
for t, c, dt in await columns(con):
|
||||||
|
whole = dt in ("inet", "cidr") or bool(ADDRESS_COLUMN.search(c))
|
||||||
|
rows = await con.fetch(
|
||||||
|
f'SELECT DISTINCT "{c}"::text AS v FROM "{t}" WHERE "{c}"::text ~ $1', QUAD)
|
||||||
|
for r in rows:
|
||||||
|
for ip in addresses_in(r["v"], whole):
|
||||||
|
try:
|
||||||
|
a = ipaddress.IPv4Address(ip)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if a.is_global and ip not in KEEP_PUBLIC:
|
||||||
|
mapper.known_public.add(ip)
|
||||||
|
|
||||||
|
|
||||||
|
async def scrub_secrets(con, dry: bool) -> None:
|
||||||
|
rows = await con.fetch(
|
||||||
|
"SELECT c.table_name, c.column_name, c.is_nullable, c.data_type "
|
||||||
|
"FROM information_schema.columns c JOIN information_schema.tables t "
|
||||||
|
"ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
|
||||||
|
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE'")
|
||||||
|
for t, c, nullable, dt in rows:
|
||||||
|
if t in SKIP_TABLES or c in SECRET_KEEP or not SECRET_COLUMN.search(c):
|
||||||
|
continue
|
||||||
|
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
|
||||||
|
continue # flags like require_password are booleans
|
||||||
|
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
|
||||||
|
if dt == "bytea" and nullable != "YES":
|
||||||
|
value = "''::bytea"
|
||||||
|
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||||
|
if n:
|
||||||
|
print(f" {t}.{c}: {n} emptied")
|
||||||
|
if not dry:
|
||||||
|
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||||
|
# Settings flagged secret keep their key, lose their value.
|
||||||
|
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
|
||||||
|
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
|
||||||
|
print(f" settings: {n} secret values emptied")
|
||||||
|
if not dry:
|
||||||
|
await con.execute("UPDATE settings SET value = '' WHERE is_secret")
|
||||||
|
for t in SECRET_TABLES:
|
||||||
|
if await con.fetchval("SELECT to_regclass($1) IS NOT NULL", f"public.{t}"):
|
||||||
|
n = await con.fetchval(f'SELECT count(*) FROM "{t}"')
|
||||||
|
print(f" {t}: {n} rows deleted")
|
||||||
|
if not dry:
|
||||||
|
await con.execute(f'DELETE FROM "{t}"')
|
||||||
|
|
||||||
|
|
||||||
|
async def rewrite(con, mapper: Mapper, cfg: dict, dry: bool) -> None:
|
||||||
|
pat = prefilter(cfg)
|
||||||
|
by_table: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for t, c, dt in await columns(con):
|
||||||
|
by_table.setdefault(t, []).append((c, dt))
|
||||||
|
for table, cols in by_table.items():
|
||||||
|
for col, dt in cols:
|
||||||
|
q = f'SELECT ctid, "{col}"::text AS v FROM "{table}" WHERE "{col}"::text ~ $1'
|
||||||
|
rows = await con.fetch(q, pat)
|
||||||
|
updates = []
|
||||||
|
for r in rows:
|
||||||
|
new = mapper.text(r["v"])
|
||||||
|
if new != r["v"]:
|
||||||
|
updates.append((new, r["ctid"]))
|
||||||
|
if not updates:
|
||||||
|
continue
|
||||||
|
print(f" {table}.{col}: {len(updates)} rows")
|
||||||
|
if dry:
|
||||||
|
continue
|
||||||
|
cast = {"jsonb": "::jsonb", "json": "::json", "inet": "::inet", "cidr": "::cidr",
|
||||||
|
"macaddr": "::macaddr"}.get(dt, "")
|
||||||
|
if dt == "ARRAY":
|
||||||
|
udt = await con.fetchval(
|
||||||
|
"SELECT udt_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = $1 AND column_name = $2", table, col)
|
||||||
|
cast = f"::{udt.lstrip('_')}[]"
|
||||||
|
await con.executemany(
|
||||||
|
f'UPDATE "{table}" SET "{col}" = $1{cast} WHERE ctid = $2', updates)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_users(con, cfg: dict, dry: bool) -> None:
|
||||||
|
sys.path.insert(0, str(Path(cfg["netork_src"]).expanduser()))
|
||||||
|
from netork.core.security import hash_password # noqa: E402
|
||||||
|
|
||||||
|
admin = cfg.get("admin_from", "chris")
|
||||||
|
password = cfg.get("admin_password", "netork-demo")
|
||||||
|
users = await con.fetch("SELECT id, username FROM users ORDER BY username")
|
||||||
|
print(f" users: {[u['username'] for u in users]}")
|
||||||
|
if dry:
|
||||||
|
return
|
||||||
|
n = 0
|
||||||
|
for u in users:
|
||||||
|
if u["username"] == admin:
|
||||||
|
await con.execute(
|
||||||
|
"UPDATE users SET username = 'netork', email = $2, hashed_password = $3, "
|
||||||
|
"totp_secret = NULL, totp_enabled = false, token_version = token_version + 1 "
|
||||||
|
"WHERE id = $1", u["id"], f"netork@{DEMO_DOMAIN}", hash_password(password))
|
||||||
|
else:
|
||||||
|
n += 1
|
||||||
|
await con.execute(
|
||||||
|
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
|
||||||
|
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
|
||||||
|
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
|
||||||
|
# TOTP secrets are gone, so a role that demands MFA would lock everyone out.
|
||||||
|
await con.execute("UPDATE roles SET require_mfa = false")
|
||||||
|
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
|
||||||
|
if role:
|
||||||
|
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
|
||||||
|
print(f" admin '{admin}' is now 'netork' / '{password}'")
|
||||||
|
|
||||||
|
|
||||||
|
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
|
||||||
|
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
|
||||||
|
# leak_terms and domains in any case.
|
||||||
|
names = [n for n in [*cfg.get("hostnames", {}), *cfg.get("terms", {}), *cfg.get("substrings", {})]
|
||||||
|
if len(n) >= 4]
|
||||||
|
loose = [n for n in [*cfg.get("domains", {}), *cfg.get("leak_terms", [])] if len(n) >= 4]
|
||||||
|
# Postgres has no inline (?i:...), so spell case-insensitivity out: [mM][aA]...
|
||||||
|
def anycase(t: str) -> str:
|
||||||
|
return "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else re.escape(c) for c in t)
|
||||||
|
parts = [re.escape(n) for n in names] + [anycase(n) for n in loose]
|
||||||
|
if not parts:
|
||||||
|
return 0
|
||||||
|
pat = "|".join(parts)
|
||||||
|
found = 0
|
||||||
|
for t, c, _ in await columns(con):
|
||||||
|
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
|
||||||
|
if n:
|
||||||
|
found += n
|
||||||
|
sample = await con.fetchval(
|
||||||
|
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
|
||||||
|
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
|
||||||
|
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--dsn", default=os.environ.get("DEMO_DSN", DEFAULT_DSN))
|
||||||
|
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--report-only", action="store_true", help="only run the leak report")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
host = re.search(r"@([^:/]+)", args.dsn)
|
||||||
|
if not host or host.group(1) not in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
sys.exit("Refusing: this only runs against a local copy.")
|
||||||
|
cfg = json.loads(args.map.read_text())
|
||||||
|
mapper = Mapper(cfg)
|
||||||
|
originals = [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||||
|
*cfg.get("leak_terms", [])]
|
||||||
|
|
||||||
|
con = await asyncpg.connect(args.dsn)
|
||||||
|
try:
|
||||||
|
if not args.report_only:
|
||||||
|
async with con.transaction():
|
||||||
|
print("secrets:")
|
||||||
|
await scrub_secrets(con, args.dry_run)
|
||||||
|
print("users:")
|
||||||
|
await reset_users(con, cfg, args.dry_run)
|
||||||
|
await collect_public(con, mapper)
|
||||||
|
print(f"public addresses seen as addresses: {len(mapper.known_public)}")
|
||||||
|
print("rewriting:")
|
||||||
|
await rewrite(con, mapper, cfg, args.dry_run)
|
||||||
|
print("ipv4 /16 mapping:", json.dumps(mapper.prefix16))
|
||||||
|
print("public addresses mapped:", len(mapper.public))
|
||||||
|
if mapper.unmapped_public:
|
||||||
|
top = sorted(mapper.unmapped_public.items(), key=lambda x: -x[1])[:40]
|
||||||
|
print("left as is (versions? add real ones to public_ips in the map):")
|
||||||
|
print(" " + ", ".join(f"{ip} ({n}x)" for ip, n in top))
|
||||||
|
print("leak report:")
|
||||||
|
n = await leak_report(con, cfg, originals)
|
||||||
|
if not args.report_only and mapper.unmapped_public:
|
||||||
|
print(f" review: {len(mapper.unmapped_public)} public-looking dotted quads left as is (listed above)")
|
||||||
|
print(" clean" if n == 0 else f" {n} rows still match")
|
||||||
|
finally:
|
||||||
|
await con.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local netOrk demo instance for website screenshots.
|
||||||
|
#
|
||||||
|
# up.sh restore <dump> fresh demo DB from a pg_dump -Fc file, then anonymize
|
||||||
|
# up.sh start API on :8000 and UI on :5173 (foreground, Ctrl-C stops)
|
||||||
|
# up.sh stop stop the demo database container
|
||||||
|
#
|
||||||
|
# Only the API and the UI run: no Celery worker, no beat, no Redis. Nothing
|
||||||
|
# polls, nothing reboots, nothing reaches a device. Stored credentials are
|
||||||
|
# emptied by anonymize.py and the encryption key is a fresh random one, so
|
||||||
|
# even a leftover value could not be decrypted.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
DEMO="${NETORK_DEMO_DIR:-$HOME/.cache/netork-demo}"
|
||||||
|
SRC="$DEMO/src"
|
||||||
|
VENV="${NETORK_VENV:-$HOME/dev/NetOrk/.venv}"
|
||||||
|
VERSION="${NETORK_DEMO_VERSION:-v0.28.0}"
|
||||||
|
NETORK_REPO="${NETORK_REPO:-$HOME/dev/NetOrk}"
|
||||||
|
DB=netork-demo-db
|
||||||
|
PORT=55432
|
||||||
|
|
||||||
|
ensure_src() {
|
||||||
|
if [ ! -d "$SRC/netork" ]; then
|
||||||
|
mkdir -p "$SRC"
|
||||||
|
git -C "$NETORK_REPO" archive "$VERSION" | tar -x -C "$SRC"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_db() {
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -qx "$DB"; then
|
||||||
|
docker start "$DB" 2>/dev/null || docker run -d --name "$DB" \
|
||||||
|
-p 127.0.0.1:$PORT:5432 -e POSTGRES_DB=netork -e POSTGRES_USER=netork \
|
||||||
|
-e POSTGRES_PASSWORD=demo -v netork-demo-pg:/var/lib/postgresql/data postgres:16-alpine
|
||||||
|
until docker exec "$DB" pg_isready -U netork -q; do sleep 1; done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
restore)
|
||||||
|
dump="${2:?usage: up.sh restore <dump file>}"
|
||||||
|
ensure_src; ensure_db
|
||||||
|
docker exec "$DB" psql -U netork -d postgres -q \
|
||||||
|
-c "DROP DATABASE IF EXISTS netork WITH (FORCE)" -c "CREATE DATABASE netork"
|
||||||
|
docker exec -i "$DB" pg_restore -U netork -d netork --no-owner --no-privileges < "$dump" \
|
||||||
|
|| echo "pg_restore reported errors (often only missing roles/extensions); checking ..."
|
||||||
|
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
|
||||||
|
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
|
||||||
|
echo "dump schema: $got $VERSION head: $want"
|
||||||
|
# Anonymize first: it empties every secret, so a downgrade that would
|
||||||
|
# have to decrypt something (with a key we do not have) finds nothing.
|
||||||
|
"$VENV/bin/python" "$HERE/anonymize.py"
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
# The production instance runs a newer build. Walk the copy back to the
|
||||||
|
# release with the newer code's own downgrade migrations.
|
||||||
|
NEWER="${NETORK_NEWER_REF:-origin/main}"
|
||||||
|
echo "migrating the copy from $got back to $want with $NEWER's migrations"
|
||||||
|
rm -rf "$DEMO/src-newer"; mkdir -p "$DEMO/src-newer"
|
||||||
|
git -C "$NETORK_REPO" archive "$NEWER" | tar -x -C "$DEMO/src-newer"
|
||||||
|
# Rows the older schema cannot hold: CrowdSec blocklist alerts whose scope
|
||||||
|
# is a list name, longer than the column they go back into.
|
||||||
|
docker exec "$DB" psql -U netork -q -c \
|
||||||
|
"DELETE FROM crowdsec_alerts WHERE length(source_scope) > 32" 2>/dev/null || true
|
||||||
|
(cd "$DEMO/src-newer" && PATH="$VENV/bin:$PATH" \
|
||||||
|
DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork" alembic downgrade "$want")
|
||||||
|
"$VENV/bin/python" "$HERE/anonymize.py" --report-only
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
ensure_src; ensure_db
|
||||||
|
[ -d "$SRC/ui/node_modules" ] || (cd "$SRC/ui" && npm ci --no-audit --no-fund)
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork"
|
||||||
|
export ENVIRONMENT=development
|
||||||
|
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||||
|
export CREDENTIAL_ENCRYPTION_KEY="$("$VENV/bin/python" -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
|
||||||
|
# Nothing listens on port 1: no task can be queued, so no worker could act.
|
||||||
|
export REDIS_URL=redis://127.0.0.1:1/0 CELERY_BROKER_URL=redis://127.0.0.1:1/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:1/1
|
||||||
|
cd "$SRC"
|
||||||
|
"$VENV/bin/uvicorn" netork.api.main:app --host 127.0.0.1 --port 8000 &
|
||||||
|
api=$!
|
||||||
|
trap 'kill $api 2>/dev/null' EXIT
|
||||||
|
cd ui && npx vite --host 127.0.0.1 --port 5173 --strictPort
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
docker stop "$DB"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sed -n '2,12p' "$0"; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy the marketing site to the host that actually serves it.
|
||||||
|
#
|
||||||
|
# ./scripts/deploy.sh [--version=<tag>] [server]
|
||||||
|
#
|
||||||
|
# This used to be a CI job. It ran `docker run` on whichever runner picked the
|
||||||
|
# job up, which was correct while exactly one runner existed — there are now
|
||||||
|
# several, none of them on the host this site runs on, so the job would have
|
||||||
|
# started a second container in the wrong place and reported success. Naming the
|
||||||
|
# target is the whole point of this script.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER="${DEPLOY_SERVER:-10.7.224.11}"
|
||||||
|
REGISTRY="${REGISTRY:-git.netork.io/netork}"
|
||||||
|
VERSION="${VERSION:-latest}"
|
||||||
|
NAME="${CONTAINER_NAME:-netork-website}"
|
||||||
|
|
||||||
|
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/deploy.env"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--version=*) VERSION="${arg#--version=}" ;;
|
||||||
|
*) SERVER="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
IMAGE="${REGISTRY}/website:${VERSION}"
|
||||||
|
echo "[${SERVER}] Deploying ${IMAGE}"
|
||||||
|
|
||||||
|
if [[ -n "${REGISTRY_TOKEN:-}" ]]; then
|
||||||
|
ssh -n "$SERVER" "echo '${REGISTRY_TOKEN}' | docker login git.netork.io -u '${REGISTRY_USER:-christianmanivong}' --password-stdin" \
|
||||||
|
| sed "s/^/[${SERVER}] /"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull first, and let a failure stop the script here: the container is only
|
||||||
|
# removed once there is something to replace it with.
|
||||||
|
echo "[${SERVER}] Pulling..."
|
||||||
|
ssh -n "$SERVER" "docker pull '${IMAGE}'" | tail -2 | sed "s/^/[${SERVER}] /"
|
||||||
|
|
||||||
|
echo "[${SERVER}] Recreating..."
|
||||||
|
ssh -n "$SERVER" "docker rm -f '${NAME}' >/dev/null 2>&1 || true; \
|
||||||
|
docker run -d --name '${NAME}' --restart unless-stopped --network proxy-net '${IMAGE}' >/dev/null && echo started" \
|
||||||
|
| sed "s/^/[${SERVER}] /"
|
||||||
|
|
||||||
|
# `docker run` cannot silently reuse an old container the way `compose up -d`
|
||||||
|
# can, but the tag it resolved might still not be the one that was just pulled.
|
||||||
|
# Compare, rather than trust.
|
||||||
|
echo "[${SERVER}] Verifying..."
|
||||||
|
WANT=$(ssh -n "$SERVER" "docker image inspect --format '{{.Id}}' '${IMAGE}'")
|
||||||
|
GOT=$(ssh -n "$SERVER" "docker inspect --format '{{.Image}}' '${NAME}'")
|
||||||
|
if [[ "$WANT" != "$GOT" ]]; then
|
||||||
|
echo "[${SERVER}] ERROR: container runs ${GOT}, expected ${WANT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[${SERVER}] Verified: ${NAME} runs ${IMAGE}."
|
||||||
|
|
||||||
|
echo "[${SERVER}] Checking the site answers..."
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://netork.io/ || echo 000)
|
||||||
|
echo "[${SERVER}] https://netork.io -> ${CODE}"
|
||||||
|
[[ "$CODE" == "200" ]] || { echo "[${SERVER}] ERROR: site is not answering 200" >&2; exit 1; }
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Take real screenshots of a running netOrk instance for the website.
|
||||||
|
|
||||||
|
Normally that instance is the local demo copy from scripts/demo (anonymized
|
||||||
|
production data), which this script logs into on its own:
|
||||||
|
|
||||||
|
capture.py --list-devices # prints IDs to pick for --var
|
||||||
|
capture.py --var ap=<id> --var server=<id> [--only name ...]
|
||||||
|
|
||||||
|
Against a real instance, log in by hand and cover what must not be seen:
|
||||||
|
|
||||||
|
NETORK_URL=https://... capture.py --login
|
||||||
|
NETORK_URL=https://... capture.py --mask --var ...
|
||||||
|
|
||||||
|
While capturing, every request to the API that is not a GET is aborted, so
|
||||||
|
taking screenshots cannot change anything on the instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
|
from shots import SHOTS
|
||||||
|
|
||||||
|
BASE = os.environ.get("NETORK_URL", "http://127.0.0.1:5173").rstrip("/")
|
||||||
|
LOCAL = re.match(r"https?://(127\.0\.0\.1|localhost)[:/]", BASE + "/") is not None
|
||||||
|
# The demo instance's admin (see scripts/demo/anonymize.py).
|
||||||
|
USER = os.environ.get("NETORK_USER", "netork")
|
||||||
|
PASSWORD = os.environ.get("NETORK_PASSWORD", "netork-demo")
|
||||||
|
STATE = Path(os.environ.get(
|
||||||
|
"NETORK_STATE", Path.home() / ".cache" / "netork-screenshots" / "state.json"))
|
||||||
|
# One term per line: site names, customer names, domains ... never committed.
|
||||||
|
MASK_FILE = Path(os.environ.get(
|
||||||
|
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
|
||||||
|
OUT = Path(__file__).resolve().parents[2] / "public" / "screenshots"
|
||||||
|
|
||||||
|
VIEWPORT = {"width": 1600, "height": 1000}
|
||||||
|
|
||||||
|
# Any IPv4 address that is not RFC 1918, loopback or link-local.
|
||||||
|
PUBLIC_IPV4 = re.compile(
|
||||||
|
r"\b(?!10\.)(?!127\.)(?!169\.254\.)(?!192\.168\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)"
|
||||||
|
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b")
|
||||||
|
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def mask_terms() -> list[str]:
|
||||||
|
if not MASK_FILE.exists():
|
||||||
|
return []
|
||||||
|
return [t.strip() for t in MASK_FILE.read_text().splitlines()
|
||||||
|
if t.strip() and not t.startswith("#")]
|
||||||
|
|
||||||
|
|
||||||
|
def login() -> None:
|
||||||
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=False)
|
||||||
|
ctx = browser.new_context(ignore_https_errors=True, viewport=VIEWPORT)
|
||||||
|
page = ctx.new_page()
|
||||||
|
page.goto(f"{BASE}/login")
|
||||||
|
print("Log in in the browser window (10 minutes) ...", flush=True)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => localStorage.getItem('token') && !location.pathname.startsWith('/login')",
|
||||||
|
timeout=600_000)
|
||||||
|
ctx.storage_state(path=STATE)
|
||||||
|
STATE.chmod(0o600)
|
||||||
|
browser.close()
|
||||||
|
print(f"Session saved to {STATE}")
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
if LOCAL:
|
||||||
|
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
|
||||||
|
tok = json.load(res).get("access_token")
|
||||||
|
if not tok:
|
||||||
|
sys.exit(f"Login as {USER} needs MFA; the demo copy should have none (anonymize.py)")
|
||||||
|
return tok
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
|
||||||
|
state = json.loads(STATE.read_text())
|
||||||
|
for origin in state.get("origins", []):
|
||||||
|
for item in origin.get("localStorage", []):
|
||||||
|
if item["name"] == "token":
|
||||||
|
return item["value"]
|
||||||
|
sys.exit("No token in the saved session; run --login first.")
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> None:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
req = p.request.new_context(
|
||||||
|
base_url=BASE, ignore_https_errors=True,
|
||||||
|
extra_http_headers={"Authorization": f"Bearer {token()}"})
|
||||||
|
res = req.get("/api/v1/devices/")
|
||||||
|
if not res.ok:
|
||||||
|
sys.exit(f"{res.status}: {res.text()[:200]} (session expired? run --login)")
|
||||||
|
for d in res.json():
|
||||||
|
print(f"{d.get('id')} {d.get('driver') or '-':18} "
|
||||||
|
f"{d.get('device_type') or '-':20} {d.get('hostname')}")
|
||||||
|
|
||||||
|
|
||||||
|
def settle(page: Page) -> None:
|
||||||
|
"""Wait until the page has finished loading its data."""
|
||||||
|
try:
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
except Exception:
|
||||||
|
pass # pages that poll never go fully idle
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => !document.querySelector('.animate-spin, .animate-pulse')", timeout=15_000)
|
||||||
|
except Exception:
|
||||||
|
print(" still loading after 15 s, taking the shot anyway")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(png: bytes, path: Path, width: int) -> None:
|
||||||
|
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||||
|
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||||
|
if img.width > width:
|
||||||
|
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||||
|
img.save(path, "WEBP", quality=85, method=6)
|
||||||
|
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||||
|
|
||||||
|
|
||||||
|
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
terms = mask_terms()
|
||||||
|
tok = token() if LOCAL else None
|
||||||
|
blocked: list[str] = []
|
||||||
|
|
||||||
|
def guard(route):
|
||||||
|
if route.request.method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
route.continue_()
|
||||||
|
else:
|
||||||
|
blocked.append(f"{route.request.method} {route.request.url}")
|
||||||
|
route.abort()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
ctx = browser.new_context(
|
||||||
|
storage_state=None if LOCAL else STATE, ignore_https_errors=True,
|
||||||
|
viewport=VIEWPORT, device_scale_factor=2, color_scheme="dark")
|
||||||
|
if tok:
|
||||||
|
ctx.add_init_script(f"localStorage.setItem('token', {json.dumps(tok)})")
|
||||||
|
ctx.route("**/api/**", guard)
|
||||||
|
page = ctx.new_page()
|
||||||
|
for shot in SHOTS:
|
||||||
|
if only and shot.name not in only:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path = shot.path.format(**variables)
|
||||||
|
except KeyError as e:
|
||||||
|
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
|
||||||
|
continue
|
||||||
|
print(f"{shot.name}: {path}")
|
||||||
|
page.goto(f"{BASE}{path}")
|
||||||
|
if page.url.rstrip("/").endswith("/login"):
|
||||||
|
sys.exit("Session expired; run --login again.")
|
||||||
|
page.wait_for_selector(shot.wait_for, timeout=20_000)
|
||||||
|
settle(page)
|
||||||
|
# The release notes dialog after an upgrade; dismissing it only
|
||||||
|
# writes localStorage in this throwaway browser context.
|
||||||
|
got_it = page.get_by_role("button", name="Got it")
|
||||||
|
if got_it.is_visible():
|
||||||
|
got_it.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
for sel in shot.clicks:
|
||||||
|
page.locator(sel).first.click()
|
||||||
|
settle(page)
|
||||||
|
masks = [page.locator(s) for s in shot.mask]
|
||||||
|
if mask:
|
||||||
|
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||||
|
masks += [page.get_by_text(t) for t in terms]
|
||||||
|
clip = None
|
||||||
|
if shot.height:
|
||||||
|
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||||
|
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||||
|
mask_color="#334155", animations="disabled")
|
||||||
|
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||||
|
browser.close()
|
||||||
|
if blocked:
|
||||||
|
print("Blocked non-GET requests (nothing was sent):")
|
||||||
|
for b in sorted(set(blocked)):
|
||||||
|
print(f" {b}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--login", action="store_true", help="log in and save the session")
|
||||||
|
ap.add_argument("--list-devices", action="store_true", help="print device IDs")
|
||||||
|
ap.add_argument("--var", action="append", default=[], metavar="NAME=VALUE",
|
||||||
|
help="fill a {placeholder} in the shot paths")
|
||||||
|
ap.add_argument("--only", nargs="*", default=[], help="only these shot names")
|
||||||
|
ap.add_argument("--mask", action="store_true",
|
||||||
|
help="cover public IPs, e-mails and the mask-file terms (real instances)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.login:
|
||||||
|
login()
|
||||||
|
elif args.list_devices:
|
||||||
|
list_devices()
|
||||||
|
else:
|
||||||
|
capture(dict(v.split("=", 1) for v in args.var), set(args.only), args.mask)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""The screenshots the website uses, as data.
|
||||||
|
|
||||||
|
Each shot is one page of the netOrk UI. `path` may contain `{placeholders}`
|
||||||
|
that are filled from `--var name=value` on the command line (device IDs
|
||||||
|
differ per instance, so they are never hard-coded here). Device detail
|
||||||
|
sections are addressed through the URL hash the UI itself writes
|
||||||
|
(`#security/assessment`, `#config`, ...), so no clicking is needed.
|
||||||
|
|
||||||
|
`mask` lists extra CSS selectors to cover on top of the automatic masks
|
||||||
|
(public IPv4 addresses, e-mail addresses, and the terms from the mask file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Shot:
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
# Selector that must be visible before the shot is taken.
|
||||||
|
wait_for: str = "main"
|
||||||
|
mask: list[str] = field(default_factory=list)
|
||||||
|
full_page: bool = False
|
||||||
|
# Crop height in CSS pixels; None keeps the viewport height.
|
||||||
|
height: int | None = None
|
||||||
|
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||||
|
width: int = 1600
|
||||||
|
# Selectors clicked in order before the shot, first match each. Only for
|
||||||
|
# controls that change the view (filters, tabs); the API guard in
|
||||||
|
# capture.py aborts anything that would write.
|
||||||
|
clicks: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
SHOTS: list[Shot] = [
|
||||||
|
Shot("devices", "/devices", width=2400),
|
||||||
|
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||||
|
Shot("vlans", "/vlans"),
|
||||||
|
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||||
|
Shot("vulnerabilities", "/vulnerabilities"),
|
||||||
|
Shot("dashboard", "/"),
|
||||||
|
# Background polls drown out what people did: filter the scheduler out,
|
||||||
|
# the way a reader would (click a source badge, then flip it to exclude).
|
||||||
|
Shot("audit-log", "/audit-log", clicks=[
|
||||||
|
"tbody td >> text=scheduler",
|
||||||
|
"button[title='Click to toggle include/exclude']",
|
||||||
|
]),
|
||||||
|
Shot("service-checks", "/monitoring/checks"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import Nav from './components/Nav'
|
||||||
|
import Footer from './components/Footer'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import Features from './pages/Features'
|
||||||
|
import Drivers from './pages/Drivers'
|
||||||
|
import GettingStarted from './pages/GettingStarted'
|
||||||
|
import Roadmap from './pages/Roadmap'
|
||||||
|
import Nis2 from './pages/Nis2'
|
||||||
|
import Plugins from './pages/Plugins'
|
||||||
|
import Glossary from './pages/Glossary'
|
||||||
|
import ForItDepartment from './pages/ForItDepartment'
|
||||||
|
import ForItSupport from './pages/ForItSupport'
|
||||||
|
import ForMsp from './pages/ForMsp'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="min-h-screen bg-slate-950 flex flex-col">
|
||||||
|
<Nav />
|
||||||
|
<main className="flex-1">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/features" element={<Features />} />
|
||||||
|
<Route path="/drivers" element={<Drivers />} />
|
||||||
|
<Route path="/docs/getting-started" element={<GettingStarted />} />
|
||||||
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
|
<Route path="/nis2" element={<Nis2 />} />
|
||||||
|
<Route path="/plugins" element={<Plugins />} />
|
||||||
|
<Route path="/glossary" element={<Glossary />} />
|
||||||
|
<Route path="/for/it-department" element={<ForItDepartment />} />
|
||||||
|
<Route path="/for/it-support" element={<ForItSupport />} />
|
||||||
|
<Route path="/for/msp" element={<ForMsp />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { t } = useLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-slate-900 border-t border-slate-800 mt-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 md:gap-16">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Link to="/" className="flex items-center gap-2.5 mb-3">
|
||||||
|
<img src="/logo.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
||||||
|
<span className="font-semibold text-slate-100 tracking-tight">
|
||||||
|
net<span className="text-sky-400">Ork</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-slate-500">{t.footer.tagline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-10 flex-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-400 mb-3">{t.footer.links}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link to="/features" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.features}</Link></li>
|
||||||
|
<li><Link to="/drivers" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.drivers}</Link></li>
|
||||||
|
<li><Link to="/plugins" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.plugins}</Link></li>
|
||||||
|
<li><Link to="/roadmap" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.roadmap}</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-400 mb-3">{t.footer.resources}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link to="/docs/getting-started" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.gettingStarted}</Link></li>
|
||||||
|
<li><Link to="/docs/architecture" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.architecture}</Link></li>
|
||||||
|
<li><span className="text-slate-600">{t.footer.changelog}</span></li>
|
||||||
|
<li><Link to="/nis2" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.nis2}</Link></li>
|
||||||
|
<li><Link to="/glossary" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.glossary}</Link></li>
|
||||||
|
<li><Link to="/for/it-department" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItDepartment}</Link></li>
|
||||||
|
<li><Link to="/for/it-support" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItSupport}</Link></li>
|
||||||
|
<li><Link to="/for/msp" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forMsp}</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-400 mb-3">{t.footer.legal}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><span className="text-slate-600">{t.footer.mit}</span></li>
|
||||||
|
<li><span className="text-slate-600">{t.footer.privacy}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { GLOSSARY } from '../glossary/terms'
|
||||||
|
|
||||||
|
const BY_ID = new Map(GLOSSARY.map((entry) => [entry.id, entry]))
|
||||||
|
|
||||||
|
const TOOLTIP_WIDTH = 256 // px, matches w-64
|
||||||
|
const VIEWPORT_MARGIN = 16 // px, keep clear of the screen edge
|
||||||
|
|
||||||
|
type Placement = 'start' | 'center' | 'end'
|
||||||
|
|
||||||
|
const PLACEMENT_CLASS: Record<Placement, string> = {
|
||||||
|
start: 'left-0',
|
||||||
|
center: 'left-1/2 -translate-x-1/2',
|
||||||
|
end: 'right-0',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlossaryMark({ id, children }: { id: string; children: ReactNode }) {
|
||||||
|
const { lang } = useLang()
|
||||||
|
const entry = BY_ID.get(id)
|
||||||
|
const anchorRef = useRef<HTMLAnchorElement>(null)
|
||||||
|
const [placement, setPlacement] = useState<Placement>('center')
|
||||||
|
|
||||||
|
if (!entry) return <>{children}</>
|
||||||
|
|
||||||
|
const name = entry.fullName ? entry.fullName[lang] : entry.display
|
||||||
|
|
||||||
|
function handleEnter() {
|
||||||
|
const rect = anchorRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const half = TOOLTIP_WIDTH / 2
|
||||||
|
if (centerX - half < VIEWPORT_MARGIN) {
|
||||||
|
setPlacement('start')
|
||||||
|
} else if (centerX + half > window.innerWidth - VIEWPORT_MARGIN) {
|
||||||
|
setPlacement('end')
|
||||||
|
} else {
|
||||||
|
setPlacement('center')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={anchorRef}
|
||||||
|
to={`/glossary#${entry.id}`}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{/* display:none while hidden: an invisible box would still widen the page on phones */}
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute bottom-full z-20 mb-2 w-64 rounded-lg
|
||||||
|
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
|
||||||
|
text-slate-400 shadow-2xl hidden group-hover:block group-focus-visible:block
|
||||||
|
${PLACEMENT_CLASS[placement]}`}
|
||||||
|
>
|
||||||
|
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
|
||||||
|
{entry.definition[lang]}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import type { Lang } from '../i18n/translations'
|
||||||
|
|
||||||
|
const dropdownItemCls =
|
||||||
|
'block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors'
|
||||||
|
|
||||||
|
const mobileItemCls =
|
||||||
|
'block rounded-lg px-3 py-2 text-sm text-slate-300 hover:text-slate-100 hover:bg-slate-800 transition-colors'
|
||||||
|
const mobileHeadingCls = 'px-3 pt-4 pb-1 text-xs font-semibold uppercase tracking-widest text-slate-500'
|
||||||
|
|
||||||
|
function NavDropdown({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`flex items-center gap-1 text-sm transition-colors ${
|
||||||
|
active ? 'text-slate-100' : 'text-slate-400 hover:text-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ChevronDownIcon className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full mt-2 left-0 w-52 rounded-lg border border-slate-800 bg-slate-900 py-1 shadow-xl">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Nav() {
|
||||||
|
const location = useLocation()
|
||||||
|
const { lang, setLang, t } = useLang()
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
const linkCls = (path: string) =>
|
||||||
|
`text-sm transition-colors ${
|
||||||
|
location.pathname === path
|
||||||
|
? 'text-slate-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'
|
||||||
|
}`
|
||||||
|
|
||||||
|
const isDocsActive =
|
||||||
|
location.pathname.startsWith('/docs') || location.pathname === '/nis2' || location.pathname === '/glossary'
|
||||||
|
const isForActive = location.pathname.startsWith('/for/') || location.pathname === '/nis2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur border-b border-slate-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-8">
|
||||||
|
<Link to="/" className="flex items-center gap-2.5 mr-2">
|
||||||
|
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||||
|
<span className="font-semibold text-slate-100 tracking-tight">
|
||||||
|
net<span className="text-sky-400">Ork</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden lg:flex items-center gap-8">
|
||||||
|
<Link to="/features" className={linkCls('/features')}>
|
||||||
|
{t.nav.features}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/drivers" className={linkCls('/drivers')}>
|
||||||
|
{t.nav.drivers}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<NavDropdown label={t.nav.docs} active={isDocsActive}>
|
||||||
|
<Link to="/docs/getting-started" className={dropdownItemCls}>
|
||||||
|
{t.nav.docsItems.gettingStarted}
|
||||||
|
</Link>
|
||||||
|
<Link to="/docs/architecture" className={dropdownItemCls}>
|
||||||
|
{t.nav.docsItems.architecture}
|
||||||
|
</Link>
|
||||||
|
<div className="my-1 border-t border-slate-800" />
|
||||||
|
<Link to="/nis2" className={dropdownItemCls}>
|
||||||
|
{t.nav.docsItems.nis2}
|
||||||
|
</Link>
|
||||||
|
<Link to="/glossary" className={dropdownItemCls}>
|
||||||
|
{t.nav.docsItems.glossary}
|
||||||
|
</Link>
|
||||||
|
</NavDropdown>
|
||||||
|
|
||||||
|
<NavDropdown label={t.nav.forWhom} active={isForActive}>
|
||||||
|
<Link to="/for/it-department" className={dropdownItemCls}>
|
||||||
|
{t.nav.forItems.itDepartment}
|
||||||
|
</Link>
|
||||||
|
<Link to="/for/it-support" className={dropdownItemCls}>
|
||||||
|
{t.nav.forItems.itSupport}
|
||||||
|
</Link>
|
||||||
|
<Link to="/for/msp" className={dropdownItemCls}>
|
||||||
|
{t.nav.forItems.msp}
|
||||||
|
</Link>
|
||||||
|
<div className="my-1 border-t border-slate-800" />
|
||||||
|
<Link to="/nis2" className={dropdownItemCls}>
|
||||||
|
{t.nav.docsItems.nis2}
|
||||||
|
</Link>
|
||||||
|
</NavDropdown>
|
||||||
|
|
||||||
|
<Link to="/plugins" className={linkCls('/plugins')}>
|
||||||
|
{t.nav.plugins}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/roadmap" className={linkCls('/roadmap')}>
|
||||||
|
{t.nav.roadmap}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-4">
|
||||||
|
<LangSwitch lang={lang} setLang={setLang} />
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="hidden sm:inline-flex items-center gap-2 px-4 py-1.5 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.nav.getStarted}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-label="Menu"
|
||||||
|
className="lg:hidden -mr-1 p-1 text-slate-400 hover:text-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="lg:hidden border-t border-slate-800 bg-slate-900 max-h-[calc(100vh-3.5rem)] overflow-y-auto">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Link to="/features" className={mobileItemCls}>{t.nav.features}</Link>
|
||||||
|
<Link to="/drivers" className={mobileItemCls}>{t.nav.drivers}</Link>
|
||||||
|
<Link to="/plugins" className={mobileItemCls}>{t.nav.plugins}</Link>
|
||||||
|
<Link to="/roadmap" className={mobileItemCls}>{t.nav.roadmap}</Link>
|
||||||
|
<p className={mobileHeadingCls}>{t.nav.docs}</p>
|
||||||
|
<Link to="/docs/getting-started" className={mobileItemCls}>{t.nav.docsItems.gettingStarted}</Link>
|
||||||
|
<Link to="/docs/architecture" className={mobileItemCls}>{t.nav.docsItems.architecture}</Link>
|
||||||
|
<Link to="/nis2" className={mobileItemCls}>{t.nav.docsItems.nis2}</Link>
|
||||||
|
<Link to="/glossary" className={mobileItemCls}>{t.nav.docsItems.glossary}</Link>
|
||||||
|
<p className={mobileHeadingCls}>{t.nav.forWhom}</p>
|
||||||
|
<Link to="/for/it-department" className={mobileItemCls}>{t.nav.forItems.itDepartment}</Link>
|
||||||
|
<Link to="/for/it-support" className={mobileItemCls}>{t.nav.forItems.itSupport}</Link>
|
||||||
|
<Link to="/for/msp" className={mobileItemCls}>{t.nav.forItems.msp}</Link>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="sm:hidden mt-4 flex justify-center px-4 py-2 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.nav.getStarted}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LangSwitch({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('de')}
|
||||||
|
className={`transition-colors ${lang === 'de' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</button>
|
||||||
|
<span className="text-slate-700">/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('en')}
|
||||||
|
className={`transition-colors ${lang === 'en' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import { translations, type Lang } from '../i18n/translations'
|
||||||
|
|
||||||
|
interface LangContextValue {
|
||||||
|
lang: Lang
|
||||||
|
setLang: (l: Lang) => void
|
||||||
|
t: typeof translations.en
|
||||||
|
}
|
||||||
|
|
||||||
|
const LangContext = createContext<LangContextValue | null>(null)
|
||||||
|
|
||||||
|
export function LangProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [lang, setLangState] = useState<Lang>(() => {
|
||||||
|
const stored = localStorage.getItem('lang')
|
||||||
|
return stored === 'de' || stored === 'en' ? stored : 'de'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('lang', lang)
|
||||||
|
}, [lang])
|
||||||
|
|
||||||
|
function setLang(l: Lang) {
|
||||||
|
setLangState(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
||||||
|
{children}
|
||||||
|
</LangContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLang() {
|
||||||
|
const ctx = useContext(LangContext)
|
||||||
|
if (!ctx) throw new Error('useLang must be used inside LangProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
export type GlossaryCategory = 'networking' | 'security' | 'compliance' | 'architecture' | 'general'
|
||||||
|
|
||||||
|
export interface GlossaryEntry {
|
||||||
|
id: string
|
||||||
|
category: GlossaryCategory
|
||||||
|
display: string
|
||||||
|
fullName?: { en: string; de: string }
|
||||||
|
definition: { en: string; de: string }
|
||||||
|
match: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_ORDER: GlossaryCategory[] = ['networking', 'security', 'compliance', 'architecture', 'general']
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<GlossaryCategory, { en: string; de: string }> = {
|
||||||
|
networking: { en: 'Networking & Protocols', de: 'Netzwerk & Protokolle' },
|
||||||
|
security: { en: 'Security & Access', de: 'Sicherheit & Zugriff' },
|
||||||
|
compliance: { en: 'Compliance', de: 'Compliance' },
|
||||||
|
architecture: { en: 'netOrk Architecture', de: 'netOrk-Architektur' },
|
||||||
|
general: { en: 'General Tech', de: 'Allgemeine Technik' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOSSARY: GlossaryEntry[] = [
|
||||||
|
// ── Networking & Protocols ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ap',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'AP',
|
||||||
|
fullName: { en: 'Access Point', de: 'Access Point' },
|
||||||
|
definition: {
|
||||||
|
en: 'A device that lets wireless clients join the network — in netOrk, typically an OpenWRT-managed radio.',
|
||||||
|
de: 'Ein Gerät, über das sich WLAN-Clients mit dem Netzwerk verbinden — in netOrk in der Regel ein von OpenWRT verwalteter Access Point.',
|
||||||
|
},
|
||||||
|
match: ['APs', 'AP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'ARP',
|
||||||
|
fullName: { en: 'Address Resolution Protocol', de: 'Address Resolution Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol that maps an IP address to the physical MAC address of a device on the same network segment.',
|
||||||
|
de: 'Das Protokoll, das eine IP-Adresse auf die physische MAC-Adresse eines Geräts im selben Netzwerksegment abbildet.',
|
||||||
|
},
|
||||||
|
match: ['ARP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dhcp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'DHCP',
|
||||||
|
fullName: { en: 'Dynamic Host Configuration Protocol', de: 'Dynamic Host Configuration Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol that automatically hands out IP addresses and network settings to devices as they join.',
|
||||||
|
de: 'Das Protokoll, das Geräten beim Verbinden automatisch IP-Adressen und Netzwerkeinstellungen zuweist.',
|
||||||
|
},
|
||||||
|
match: ['DHCP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dns',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'DNS',
|
||||||
|
fullName: { en: 'Domain Name System', de: 'Domain Name System' },
|
||||||
|
definition: {
|
||||||
|
en: 'Translates human-readable hostnames into IP addresses.',
|
||||||
|
de: 'Übersetzt menschenlesbare Hostnamen in IP-Adressen.',
|
||||||
|
},
|
||||||
|
match: ['DNS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fqdn',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'FQDN',
|
||||||
|
fullName: { en: 'Fully Qualified Domain Name', de: 'Fully Qualified Domain Name' },
|
||||||
|
definition: {
|
||||||
|
en: 'The complete domain name that uniquely identifies a host, e.g. router.example.com.',
|
||||||
|
de: 'Der vollständige Domainname, der einen Host eindeutig identifiziert, z. B. router.example.com.',
|
||||||
|
},
|
||||||
|
match: ['FQDN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'http-https',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'HTTP/HTTPS',
|
||||||
|
fullName: { en: 'Hypertext Transfer Protocol (Secure)', de: 'Hypertext Transfer Protocol (Secure)' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol web browsers and APIs use to exchange data; HTTPS adds TLS encryption on top.',
|
||||||
|
de: 'Das Protokoll, über das Webbrowser und APIs Daten austauschen; HTTPS ergänzt es um TLS-Verschlüsselung.',
|
||||||
|
},
|
||||||
|
match: ['HTTP/HTTPS', 'HTTPS', 'HTTP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'icmp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'ICMP',
|
||||||
|
fullName: { en: 'Internet Control Message Protocol', de: 'Internet Control Message Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol behind network diagnostics like ping — used by netOrk\'s discovery sweep to find live hosts.',
|
||||||
|
de: 'Das Protokoll hinter Netzwerkdiagnosen wie Ping — netOrk nutzt es beim Discovery-Sweep, um aktive Hosts zu finden.',
|
||||||
|
},
|
||||||
|
match: ['ICMP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'IP',
|
||||||
|
fullName: { en: 'Internet Protocol', de: 'Internet Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The addressing scheme (IPv4/IPv6) that lets devices find and reach each other on a network.',
|
||||||
|
de: 'Das Adressierungsschema (IPv4/IPv6), über das Geräte sich im Netzwerk finden und erreichen.',
|
||||||
|
},
|
||||||
|
match: ['IP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lldp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'LLDP',
|
||||||
|
fullName: { en: 'Link Layer Discovery Protocol', de: 'Link Layer Discovery Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'Lets neighboring devices advertise their identity and capabilities, which netOrk uses to build the topology graph.',
|
||||||
|
de: 'Ermöglicht benachbarten Geräten, Identität und Fähigkeiten bekanntzugeben — netOrk nutzt das für den Topologie-Graphen.',
|
||||||
|
},
|
||||||
|
match: ['LLDP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mac',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'MAC',
|
||||||
|
fullName: { en: 'Media Access Control (address)', de: 'Media Access Control (Adresse)' },
|
||||||
|
definition: {
|
||||||
|
en: 'The hardware address burned into a network interface, unique per device.',
|
||||||
|
de: 'Die in eine Netzwerkschnittstelle eingebrannte Hardware-Adresse, eindeutig pro Gerät.',
|
||||||
|
},
|
||||||
|
match: ['MAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mtu',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'MTU',
|
||||||
|
fullName: { en: 'Maximum Transmission Unit', de: 'Maximum Transmission Unit' },
|
||||||
|
definition: {
|
||||||
|
en: 'The largest packet size an interface will forward without fragmenting it.',
|
||||||
|
de: 'Die größte Paketgröße, die eine Schnittstelle weiterleitet, ohne sie zu fragmentieren.',
|
||||||
|
},
|
||||||
|
match: ['MTU'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ntp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'NTP',
|
||||||
|
fullName: { en: 'Network Time Protocol', de: 'Network Time Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'Keeps device clocks synchronized — netOrk pushes NTP settings as part of AP profiles.',
|
||||||
|
de: 'Synchronisiert die Uhrzeit von Geräten — netOrk setzt NTP-Einstellungen als Teil von AP-Profilen.',
|
||||||
|
},
|
||||||
|
match: ['NTP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ptr-record',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'PTR record',
|
||||||
|
definition: {
|
||||||
|
en: 'A reverse-DNS record that maps an IP address back to a hostname.',
|
||||||
|
de: 'Ein Reverse-DNS-Eintrag, der eine IP-Adresse auf einen Hostnamen zurückführt.',
|
||||||
|
},
|
||||||
|
match: ['PTR record', 'PTR'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snmp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SNMP',
|
||||||
|
fullName: { en: 'Simple Network Management Protocol', de: 'Simple Network Management Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol netOrk uses to pull health metrics — CPU, memory, interface counters — straight from devices.',
|
||||||
|
de: 'Das Protokoll, über das netOrk Gesundheitsmetriken — CPU, Speicher, Schnittstellenzähler — direkt von Geräten abfragt.',
|
||||||
|
},
|
||||||
|
match: ['SNMP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SSH',
|
||||||
|
fullName: { en: 'Secure Shell', de: 'Secure Shell' },
|
||||||
|
definition: {
|
||||||
|
en: 'An encrypted remote-access protocol — netOrk uses it to run commands, push config, and stream the browser-based console.',
|
||||||
|
de: 'Ein verschlüsseltes Protokoll für den Fernzugriff — netOrk nutzt es, um Befehle auszuführen, Konfigurationen zu pushen und die browserbasierte Konsole zu streamen.',
|
||||||
|
},
|
||||||
|
match: ['SSH'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssid',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SSID',
|
||||||
|
fullName: { en: 'Service Set Identifier', de: 'Service Set Identifier' },
|
||||||
|
definition: {
|
||||||
|
en: 'The public name of a Wi-Fi network, e.g. what shows up in a phone\'s Wi-Fi list.',
|
||||||
|
de: 'Der öffentliche Name eines WLANs — das, was z. B. in der WLAN-Liste eines Smartphones erscheint.',
|
||||||
|
},
|
||||||
|
match: ['SSIDs', 'SSID'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vlan',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'VLAN',
|
||||||
|
fullName: { en: 'Virtual Local Area Network', de: 'Virtual Local Area Network' },
|
||||||
|
definition: {
|
||||||
|
en: 'A logically segmented broadcast domain that runs on shared switching hardware.',
|
||||||
|
de: 'Eine logisch abgegrenzte Broadcast-Domäne, die auf gemeinsam genutzter Switching-Hardware läuft.',
|
||||||
|
},
|
||||||
|
match: ['VLANs', 'VLAN'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security & Access ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'cis-benchmark',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CIS',
|
||||||
|
fullName: { en: 'Center for Internet Security', de: 'Center for Internet Security' },
|
||||||
|
definition: {
|
||||||
|
en: 'A vendor-neutral hardening standard — netOrk surfaces a device\'s CIS benchmark score via the Wazuh integration.',
|
||||||
|
de: 'Ein herstellerneutraler Hardening-Standard — netOrk zeigt den CIS-Benchmark-Score eines Geräts über die Wazuh-Integration.',
|
||||||
|
},
|
||||||
|
match: ['CIS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cve',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CVE',
|
||||||
|
fullName: { en: 'Common Vulnerabilities and Exposures', de: 'Common Vulnerabilities and Exposures' },
|
||||||
|
definition: {
|
||||||
|
en: 'A public identifier for a known security vulnerability, e.g. CVE-2026-44405.',
|
||||||
|
de: 'Eine öffentliche Kennung für eine bekannte Sicherheitslücke, z. B. CVE-2026-44405.',
|
||||||
|
},
|
||||||
|
match: ['CVEs', 'CVE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cvss',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CVSS',
|
||||||
|
fullName: { en: 'Common Vulnerability Scoring System', de: 'Common Vulnerability Scoring System' },
|
||||||
|
definition: {
|
||||||
|
en: 'A standardized 0–10 score for how severe a vulnerability is.',
|
||||||
|
de: 'Ein standardisierter Score von 0–10 für den Schweregrad einer Sicherheitslücke.',
|
||||||
|
},
|
||||||
|
match: ['CVSS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eol',
|
||||||
|
category: 'security',
|
||||||
|
display: 'EOL',
|
||||||
|
fullName: { en: 'End of Life', de: 'End of Life' },
|
||||||
|
definition: {
|
||||||
|
en: 'Software or firmware that no longer receives vendor security updates.',
|
||||||
|
de: 'Software oder Firmware, die keine Sicherheitsupdates vom Hersteller mehr erhält.',
|
||||||
|
},
|
||||||
|
match: ['EOL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hmac-sha256',
|
||||||
|
category: 'security',
|
||||||
|
display: 'HMAC-SHA256',
|
||||||
|
fullName: { en: 'Hash-based Message Authentication Code (SHA-256)', de: 'Hash-based Message Authentication Code (SHA-256)' },
|
||||||
|
definition: {
|
||||||
|
en: 'A cryptographic signature that proves a webhook payload wasn\'t tampered with in transit.',
|
||||||
|
de: 'Eine kryptografische Signatur, die belegt, dass ein Webhook-Payload unterwegs nicht manipuliert wurde.',
|
||||||
|
},
|
||||||
|
match: ['HMAC-SHA256', 'HMAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jwt',
|
||||||
|
category: 'security',
|
||||||
|
display: 'JWT',
|
||||||
|
fullName: { en: 'JSON Web Token', de: 'JSON Web Token' },
|
||||||
|
definition: {
|
||||||
|
en: 'A signed token that proves a logged-in user\'s identity on every API request.',
|
||||||
|
de: 'Ein signiertes Token, das die Identität eines angemeldeten Benutzers bei jeder API-Anfrage belegt.',
|
||||||
|
},
|
||||||
|
match: ['JWT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mfa',
|
||||||
|
category: 'security',
|
||||||
|
display: 'MFA',
|
||||||
|
fullName: { en: 'Multi-Factor Authentication', de: 'Multi-Faktor-Authentifizierung' },
|
||||||
|
definition: {
|
||||||
|
en: 'Requiring a second proof of identity — like a TOTP code — in addition to a password.',
|
||||||
|
de: 'Verlangt neben dem Passwort einen zweiten Identitätsnachweis — etwa einen TOTP-Code.',
|
||||||
|
},
|
||||||
|
match: ['MFA'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kea',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'Kea',
|
||||||
|
fullName: { en: 'ISC Kea DHCP', de: 'ISC Kea DHCP' },
|
||||||
|
definition: {
|
||||||
|
en: 'The DHCP server from ISC that OPNsense uses to hand out addresses; netOrk manages its subnets and reservations.',
|
||||||
|
de: 'Der DHCP-Server von ISC, mit dem OPNsense Adressen vergibt; netOrk verwaltet seine Subnetze und Reservierungen.',
|
||||||
|
},
|
||||||
|
match: ['Kea'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'winrm',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'WinRM',
|
||||||
|
fullName: { en: 'Windows Remote Management', de: 'Windows Remote Management' },
|
||||||
|
definition: {
|
||||||
|
en: 'Microsoft\'s remote management protocol for Windows hosts, the Windows counterpart to SSH for automation.',
|
||||||
|
de: 'Microsofts Protokoll zur Fernverwaltung von Windows-Hosts, das Windows-Gegenstück zu SSH für Automatisierung.',
|
||||||
|
},
|
||||||
|
match: ['WinRM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lapi',
|
||||||
|
category: 'security',
|
||||||
|
display: 'LAPI',
|
||||||
|
fullName: { en: 'CrowdSec Local API', de: 'CrowdSec Local API' },
|
||||||
|
definition: {
|
||||||
|
en: 'The CrowdSec service that collects what its agents detect and holds the resulting ban decisions for one site or host.',
|
||||||
|
de: 'Der CrowdSec-Dienst, der sammelt, was seine Agenten erkennen, und die daraus folgenden Sperrentscheidungen für einen Standort oder Host hält.',
|
||||||
|
},
|
||||||
|
match: ['LAPI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nvd',
|
||||||
|
category: 'security',
|
||||||
|
display: 'NVD',
|
||||||
|
fullName: { en: 'National Vulnerability Database', de: 'National Vulnerability Database' },
|
||||||
|
definition: {
|
||||||
|
en: 'The U.S. government\'s public feed of known CVEs.',
|
||||||
|
de: 'Die öffentliche CVE-Datenbank der US-Regierung.',
|
||||||
|
},
|
||||||
|
match: ['NVD'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'osv',
|
||||||
|
category: 'security',
|
||||||
|
display: 'OSV',
|
||||||
|
fullName: { en: 'Open Source Vulnerabilities', de: 'Open Source Vulnerabilities' },
|
||||||
|
definition: {
|
||||||
|
en: 'A community-run vulnerability database focused on open-source packages.',
|
||||||
|
de: 'Eine community-betriebene Schwachstellendatenbank mit Fokus auf Open-Source-Pakete.',
|
||||||
|
},
|
||||||
|
match: ['OSV'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac',
|
||||||
|
category: 'security',
|
||||||
|
display: 'RBAC',
|
||||||
|
fullName: { en: 'Role-Based Access Control', de: 'Rollenbasierte Zugriffskontrolle' },
|
||||||
|
definition: {
|
||||||
|
en: 'Restricting what a user can see or do based on the role they\'ve been assigned.',
|
||||||
|
de: 'Beschränkt, was ein Benutzer sehen oder tun darf, basierend auf der zugewiesenen Rolle.',
|
||||||
|
},
|
||||||
|
match: ['RBAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tls',
|
||||||
|
category: 'security',
|
||||||
|
display: 'TLS',
|
||||||
|
fullName: { en: 'Transport Layer Security', de: 'Transport Layer Security' },
|
||||||
|
definition: {
|
||||||
|
en: 'The encryption protocol behind HTTPS — also used to terminate traffic at an ingress in a Kubernetes deployment.',
|
||||||
|
de: 'Das Verschlüsselungsprotokoll hinter HTTPS — wird z. B. auch zur TLS-Terminierung am Ingress eines Kubernetes-Deployments genutzt.',
|
||||||
|
},
|
||||||
|
match: ['TLS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'totp',
|
||||||
|
category: 'security',
|
||||||
|
display: 'TOTP',
|
||||||
|
fullName: { en: 'Time-based One-Time Password', de: 'Time-based One-Time Password' },
|
||||||
|
definition: {
|
||||||
|
en: 'The rotating 6-digit code generated by an authenticator app, used as a second login factor.',
|
||||||
|
de: 'Der rotierende 6-stellige Code aus einer Authenticator-App, genutzt als zweiter Anmeldefaktor.',
|
||||||
|
},
|
||||||
|
match: ['TOTP'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Compliance ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'nis2',
|
||||||
|
category: 'compliance',
|
||||||
|
display: 'NIS2',
|
||||||
|
fullName: { en: 'Network and Information Security Directive 2', de: 'Network and Information Security Directive 2' },
|
||||||
|
definition: {
|
||||||
|
en: 'The EU\'s second cybersecurity directive — Article 21 sets baseline technical and organizational measures for essential and important entities.',
|
||||||
|
de: 'Die zweite EU-Richtlinie zur Netzwerk- und Informationssicherheit — Art. 21 legt technische und organisatorische Mindestmaßnahmen für wesentliche und wichtige Einrichtungen fest.',
|
||||||
|
},
|
||||||
|
match: ['NIS2'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── netOrk Architecture ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'napalm',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'NAPALM',
|
||||||
|
fullName: {
|
||||||
|
en: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
|
||||||
|
de: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
en: 'The open-source Python library netOrk uses to talk to network devices through one common interface, regardless of vendor.',
|
||||||
|
de: 'Die Open-Source-Python-Bibliothek, über die netOrk mit Netzwerkgeräten über eine gemeinsame Schnittstelle spricht — herstellerunabhängig.',
|
||||||
|
},
|
||||||
|
match: ['NAPALM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uci',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'UCI',
|
||||||
|
fullName: { en: 'Unified Configuration Interface', de: 'Unified Configuration Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'OpenWRT\'s own configuration system — netOrk pushes VLAN, SSID, and radio settings through it.',
|
||||||
|
de: 'OpenWRTs eigenes Konfigurationssystem — netOrk setzt VLAN-, SSID- und Radio-Einstellungen darüber.',
|
||||||
|
},
|
||||||
|
match: ['UCI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-drift',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Config drift',
|
||||||
|
definition: {
|
||||||
|
en: 'When a device\'s actual configuration silently diverges from the state netOrk expects — usually from a manual change made directly on the device.',
|
||||||
|
de: 'Wenn die tatsächliche Konfiguration eines Geräts stillschweigend vom erwarteten Sollzustand abweicht — meist durch eine manuelle Änderung direkt am Gerät.',
|
||||||
|
},
|
||||||
|
match: ['Config drift', 'config drift', 'Drifted', 'drifted', 'Drift', 'drift'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hook-bus',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Hook bus',
|
||||||
|
definition: {
|
||||||
|
en: 'netOrk\'s internal event system — plugins subscribe handlers to events like poll.complete instead of core code calling into them directly.',
|
||||||
|
de: 'netOrks internes Event-System — Plugins registrieren Handler für Events wie poll.complete, statt dass der Kern-Code sie direkt aufruft.',
|
||||||
|
},
|
||||||
|
match: ['Hook bus', 'hook bus'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plugin-registry',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Plugin registry',
|
||||||
|
definition: {
|
||||||
|
en: 'The database-backed record of which plugins exist and whether each is enabled, checked before a plugin\'s router or hooks run.',
|
||||||
|
de: 'Die datenbankgestützte Übersicht, welche Plugins existieren und ob sie aktiviert sind — wird geprüft, bevor Router oder Hooks eines Plugins laufen.',
|
||||||
|
},
|
||||||
|
match: ['Plugin registry', 'plugin registry'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-snapshot',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Configuration snapshot',
|
||||||
|
definition: {
|
||||||
|
en: 'A full copy of a device\'s configuration captured at poll time and committed to Git, so any point in history can be diffed or restored.',
|
||||||
|
de: 'Eine vollständige Kopie der Gerätekonfiguration, die bei jedem Poll erfasst und in Git committet wird — jeder Zeitpunkt lässt sich so diffen oder wiederherstellen.',
|
||||||
|
},
|
||||||
|
match: ['Configuration snapshot', 'configuration snapshot', 'Config snapshot', 'config snapshot'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── General Tech ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'api',
|
||||||
|
category: 'general',
|
||||||
|
display: 'API',
|
||||||
|
fullName: { en: 'Application Programming Interface', de: 'Application Programming Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'A defined set of endpoints other software — or netOrk\'s own UI — uses to talk to a system.',
|
||||||
|
de: 'Eine definierte Menge an Endpunkten, über die andere Software — oder netOrks eigene UI — mit einem System spricht.',
|
||||||
|
},
|
||||||
|
match: ['API'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crud',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CRUD',
|
||||||
|
fullName: { en: 'Create, Read, Update, Delete', de: 'Create, Read, Update, Delete' },
|
||||||
|
definition: {
|
||||||
|
en: 'The four basic operations for managing a record: creating, viewing, editing, and deleting it.',
|
||||||
|
de: 'Die vier Grundoperationen zur Verwaltung eines Datensatzes: anlegen, anzeigen, bearbeiten, löschen.',
|
||||||
|
},
|
||||||
|
match: ['CRUD'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'csv',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CSV',
|
||||||
|
fullName: { en: 'Comma-Separated Values', de: 'Comma-Separated Values' },
|
||||||
|
definition: {
|
||||||
|
en: 'A plain-text spreadsheet format used for exporting tabular data like the audit log.',
|
||||||
|
de: 'Ein textbasiertes Tabellenformat für den Export tabellarischer Daten wie des Audit-Logs.',
|
||||||
|
},
|
||||||
|
match: ['CSV'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cpu',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CPU',
|
||||||
|
fullName: { en: 'Central Processing Unit', de: 'Central Processing Unit' },
|
||||||
|
definition: {
|
||||||
|
en: 'A device\'s processor — netOrk tracks its load as a health metric.',
|
||||||
|
de: 'Der Prozessor eines Geräts — netOrk erfasst dessen Auslastung als Gesundheitsmetrik.',
|
||||||
|
},
|
||||||
|
match: ['CPU'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db',
|
||||||
|
category: 'general',
|
||||||
|
display: 'DB',
|
||||||
|
fullName: { en: 'Database', de: 'Datenbank' },
|
||||||
|
definition: {
|
||||||
|
en: 'Shorthand for the database — where netOrk stores desired device state and configuration.',
|
||||||
|
de: 'Kurzform für die Datenbank — dort speichert netOrk den Sollzustand und die Konfiguration der Geräte.',
|
||||||
|
},
|
||||||
|
match: ['DB'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fk',
|
||||||
|
category: 'general',
|
||||||
|
display: 'FK',
|
||||||
|
fullName: { en: 'Foreign Key', de: 'Foreign Key' },
|
||||||
|
definition: {
|
||||||
|
en: 'A database reference from one record to another — e.g. linking a device to its Site record.',
|
||||||
|
de: 'Ein Datenbank-Verweis von einem Datensatz auf einen anderen — z. B. die Verknüpfung eines Geräts mit seinem Standort-Datensatz.',
|
||||||
|
},
|
||||||
|
match: ['FK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nas',
|
||||||
|
category: 'general',
|
||||||
|
display: 'NAS',
|
||||||
|
fullName: { en: 'Network-Attached Storage', de: 'Network-Attached Storage' },
|
||||||
|
definition: {
|
||||||
|
en: 'A dedicated file-storage device that other machines on the network read and write to.',
|
||||||
|
de: 'Ein dediziertes Speichergerät im Netzwerk, auf das andere Rechner lesend und schreibend zugreifen.',
|
||||||
|
},
|
||||||
|
match: ['NAS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'os',
|
||||||
|
category: 'general',
|
||||||
|
display: 'OS',
|
||||||
|
fullName: { en: 'Operating System', de: 'Betriebssystem' },
|
||||||
|
definition: {
|
||||||
|
en: 'The system software running on a device — firmware, a Linux distribution, etc. — netOrk tracks its version per device.',
|
||||||
|
de: 'Die auf einem Gerät laufende Systemsoftware — Firmware, eine Linux-Distribution usw. — netOrk erfasst die Version pro Gerät.',
|
||||||
|
},
|
||||||
|
match: ['OS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pdf',
|
||||||
|
category: 'general',
|
||||||
|
display: 'PDF',
|
||||||
|
fullName: { en: 'Portable Document Format', de: 'Portable Document Format' },
|
||||||
|
definition: {
|
||||||
|
en: 'A fixed-layout document format used for audit log exports meant for an auditor.',
|
||||||
|
de: 'Ein Dokumentformat mit festem Layout — genutzt für Audit-Log-Exporte, die an einen Prüfer gehen.',
|
||||||
|
},
|
||||||
|
match: ['PDF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'saas',
|
||||||
|
category: 'general',
|
||||||
|
display: 'SaaS',
|
||||||
|
fullName: { en: 'Software as a Service', de: 'Software as a Service' },
|
||||||
|
definition: {
|
||||||
|
en: 'Hosted software you access over the internet and don\'t run yourself — the opposite of netOrk\'s self-hosted model.',
|
||||||
|
de: 'Gehostete Software, die über das Internet genutzt wird, statt sie selbst zu betreiben — das Gegenteil von netOrks Self-Hosted-Modell.',
|
||||||
|
},
|
||||||
|
match: ['SaaS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sse',
|
||||||
|
category: 'general',
|
||||||
|
display: 'SSE',
|
||||||
|
fullName: { en: 'Server-Sent Events', de: 'Server-Sent Events' },
|
||||||
|
definition: {
|
||||||
|
en: 'A one-way streaming connection the server uses to push live output — like fix-stream logs — to the browser.',
|
||||||
|
de: 'Eine unidirektionale Streaming-Verbindung, über die der Server Live-Output — etwa Fix-Stream-Logs — an den Browser sendet.',
|
||||||
|
},
|
||||||
|
match: ['SSE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ui',
|
||||||
|
category: 'general',
|
||||||
|
display: 'UI',
|
||||||
|
fullName: { en: 'User Interface', de: 'User Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'The interface you interact with — in netOrk\'s case, the web app itself.',
|
||||||
|
de: 'Die Oberfläche, mit der interagiert wird — bei netOrk die Web-App selbst.',
|
||||||
|
},
|
||||||
|
match: ['UI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vm',
|
||||||
|
category: 'general',
|
||||||
|
display: 'VM',
|
||||||
|
fullName: { en: 'Virtual Machine', de: 'Virtuelle Maschine' },
|
||||||
|
definition: {
|
||||||
|
en: 'An emulated computer running on a hypervisor like Proxmox, sharing physical hardware with other VMs.',
|
||||||
|
de: 'Ein emulierter Computer auf einem Hypervisor wie Proxmox, der sich die physische Hardware mit anderen VMs teilt.',
|
||||||
|
},
|
||||||
|
match: ['VMs', 'VM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'websocket',
|
||||||
|
category: 'general',
|
||||||
|
display: 'WebSocket',
|
||||||
|
definition: {
|
||||||
|
en: 'A persistent two-way connection between browser and server — used for the interactive Web-SSH console.',
|
||||||
|
de: 'Eine dauerhafte, bidirektionale Verbindung zwischen Browser und Server — genutzt für die interaktive Web-SSH-Konsole.',
|
||||||
|
},
|
||||||
|
match: ['WebSocket'],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-950 text-slate-100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
|
import GlossaryMark from '../components/GlossaryMark'
|
||||||
|
import { GLOSSARY } from '../glossary/terms'
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATCH_TO_ID = new Map<string, string>()
|
||||||
|
for (const entry of GLOSSARY) {
|
||||||
|
for (const surface of entry.match) {
|
||||||
|
MATCH_TO_ID.set(surface, entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_SURFACES = [...MATCH_TO_ID.keys()].sort((a, b) => b.length - a.length)
|
||||||
|
const GLOSSARY_REGEX = new RegExp(`\\b(${ALL_SURFACES.map(escapeRegExp).join('|')})\\b`, 'g')
|
||||||
|
|
||||||
|
/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked <GlossaryMark>.
|
||||||
|
* Always returns a single inline element — never a bare array — so callers can safely place the
|
||||||
|
* result inside a `flex` container (e.g. a bulleted `<li>`) without each fragment becoming its own
|
||||||
|
* flex item and breaking the reading order. */
|
||||||
|
export function linkify(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(GLOSSARY_REGEX)
|
||||||
|
if (parts.length === 1) return text
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
const id = MATCH_TO_ID.get(part)
|
||||||
|
if (id === undefined) return <Fragment key={i}>{part}</Fragment>
|
||||||
|
return (
|
||||||
|
<GlossaryMark key={i} id={id}>
|
||||||
|
{part}
|
||||||
|
</GlossaryMark>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
import { LangProvider } from './context/LangContext'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<LangProvider>
|
||||||
|
<App />
|
||||||
|
</LangProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Cap = '✓' | '—'
|
||||||
|
|
||||||
|
interface Driver {
|
||||||
|
name: string
|
||||||
|
deviceType: string
|
||||||
|
facts: Cap
|
||||||
|
interfaces: Cap
|
||||||
|
lldp: Cap
|
||||||
|
vlans: Cap
|
||||||
|
ssids: Cap
|
||||||
|
health: Cap
|
||||||
|
docker: Cap
|
||||||
|
reboot: Cap
|
||||||
|
configPush: Cap
|
||||||
|
status: 'stable' | 'beta'
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRIVERS: Driver[] = [
|
||||||
|
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers (read-only)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'stable' },
|
||||||
|
{ name: 'hpe_officeconnect', deviceType: 'HPE OfficeConnect 1820 / 1920S switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||||
|
{ name: 'netgear_plus', deviceType: 'Netgear Plus switches (web UI)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
{ name: 'netgear_smart', deviceType: 'Netgear Smart Managed Pro switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'beta' },
|
||||||
|
{ name: 'openmediavault', deviceType: 'OpenMediaVault NAS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||||
|
{ name: 'openwrt', deviceType: 'OpenWrt routers and access points', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||||
|
{ name: 'opnsense', deviceType: 'OPNsense firewalls', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||||
|
{ name: 'procurve', deviceType: 'HPE ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||||
|
{ name: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||||
|
{ name: 'qnap_qts', deviceType: 'QNAP NAS on QTS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '—', docker: '✓', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
{ name: 'sonos', deviceType: 'Sonos speakers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
{ name: 'tplink_jetstream', deviceType: 'TP-Link JetStream switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||||
|
{ name: 'yealink', deviceType: 'Yealink IP phones', facts: '✓', interfaces: '—', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
{ name: 'zyxel', deviceType: 'Zyxel VMG routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const NAPALM_BUILTIN = ['Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Juniper JunOS']
|
||||||
|
|
||||||
|
const CAPS: { key: keyof Driver; label: string }[] = [
|
||||||
|
{ key: 'facts', label: 'Facts' },
|
||||||
|
{ key: 'interfaces', label: 'Ifaces' },
|
||||||
|
{ key: 'lldp', label: 'LLDP' },
|
||||||
|
{ key: 'vlans', label: 'VLANs' },
|
||||||
|
{ key: 'ssids', label: 'SSIDs' },
|
||||||
|
{ key: 'health', label: 'Health' },
|
||||||
|
{ key: 'docker', label: 'Docker' },
|
||||||
|
{ key: 'reboot', label: 'Reboot' },
|
||||||
|
{ key: 'configPush', label: 'Config push' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: 'stable' | 'beta' }) {
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||||
|
status === 'stable'
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Drivers() {
|
||||||
|
const { t } = useLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.drivers.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{linkify(t.drivers.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-16 overflow-x-auto">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.customHeading}</h2>
|
||||||
|
<div className="rounded-xl border border-slate-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 bg-slate-900">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-slate-500">Driver</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-slate-500">Device type</th>
|
||||||
|
{CAPS.map((c) => (
|
||||||
|
<th key={c.key} className="px-3 py-3 text-center font-medium text-slate-500 whitespace-nowrap">
|
||||||
|
{c.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-slate-500">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DRIVERS.map((d) => (
|
||||||
|
<tr key={d.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-sky-400 whitespace-nowrap">{d.name}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300 whitespace-nowrap">{d.deviceType}</td>
|
||||||
|
{CAPS.map((c) => (
|
||||||
|
<td key={c.key} className="px-3 py-3 text-center">
|
||||||
|
<span className={d[c.key] === '✓' ? 'text-green-400' : 'text-slate-700'}>
|
||||||
|
{d[c.key]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={d.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-4 space-y-1 text-xs text-slate-500">
|
||||||
|
{t.drivers.notes.map((n) => <li key={n}>{linkify(n)}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.napalmHeading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 mb-6">{linkify(t.drivers.napalmSub)}</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{NAPALM_BUILTIN.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
function Section({ title, items }: { title: string; items: string[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item} className="flex items-start gap-2 text-sm text-slate-400 leading-relaxed">
|
||||||
|
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||||
|
{linkify(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Features() {
|
||||||
|
const { t } = useLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.features.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{t.features.sub}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12">
|
||||||
|
{t.features.sections.map((s) => (
|
||||||
|
<Section key={s.title} title={s.title} items={s.items} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ServerStackIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Item = { title: string; body: string }
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string; drivers: string }> = {
|
||||||
|
en: { pain: 'Your day today', features: 'Where netOrk helps', drivers: 'Runs on what you already have' },
|
||||||
|
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft', drivers: 'Läuft auf dem, was ihr schon habt' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'A different admin UI per vendor', body: 'OPNsense here, HP ProCurve there, a separate tab for OpenWRT and Proxmox. Every task starts with figuring out which UI to open.' },
|
||||||
|
{ title: 'No single view of what\'s running where', body: 'Vendor, model, firmware, and status per device live in nobody\'s head — or a spreadsheet that\'s already out of date.' },
|
||||||
|
{ title: 'Config changes with no audit trail', body: 'Someone tweaks a firewall rule directly on the device. Nobody documents it. It breaks something three weeks later.' },
|
||||||
|
{ title: 'Manual SSH just to check a VLAN', body: 'Verifying interface status or VLAN membership means logging into the device directly, every time.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Für jeden Hersteller eine andere Oberfläche', body: 'OPNsense hier, HP ProCurve dort, ein eigener Tab für OpenWRT und Proxmox. Jede Aufgabe beginnt damit, herauszufinden, welche UI überhaupt zuständig ist.' },
|
||||||
|
{ title: 'Keine einzige Ansicht, was wo läuft', body: 'Hersteller, Modell, Firmware und Status pro Gerät stehen in niemandes Kopf — oder in einer Tabelle, die längst veraltet ist.' },
|
||||||
|
{ title: 'Konfigurationsänderungen ohne Audit-Trail', body: 'Jemand ändert eine Firewall-Regel direkt am Gerät. Niemand dokumentiert es. Drei Wochen später bricht etwas.' },
|
||||||
|
{ title: 'Manuelles SSH nur um ein VLAN zu prüfen', body: 'Um Interface-Status oder VLAN-Zugehörigkeit zu verifizieren, muss man sich jedes Mal direkt am Gerät einloggen.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Device Management', body: 'CRUD for every device with credential profiles and SSH key management. Vendor, model, and OS auto-populate from NAPALM get_facts() — no manual data entry.' },
|
||||||
|
{ title: 'Config drift detection & one-click fix', body: 'Every poll compares device state against the desired state in netOrk. Drifted devices get a warning; fixing it streams live SSH output straight to the browser.' },
|
||||||
|
{ title: 'Git-backed config history', body: 'Every poll snapshots the config into a local Git repository — full history, side-by-side diffs between any two points in time, and one-click restore for OPNsense.' },
|
||||||
|
{ title: 'Ansible automation', body: '16 built-in roles (base, docker, adguard, wireguard, fail2ban, a mail server, and more) with automatic dependency resolution. Write your own roles too, with full run history per device.' },
|
||||||
|
{ title: 'VM Provisioning', body: 'Cloud-Init VMs on Proxmox straight from the hypervisor\'s VMs tab — pick an image, a VLAN, and an IP, and netOrk handles the DHCP reservation and Device linking.' },
|
||||||
|
{ title: 'Dashboards', body: 'Configurable, shareable dashboards built from 18 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
|
||||||
|
{ title: 'Security assessment per device', body: 'TLS and SSH graded A to F, installed software and container images matched against known vulnerabilities, hardening benchmarks, and which networks can reach the device — without an agent on it.' },
|
||||||
|
{ title: 'Vulnerability triage with owners', body: 'One queue across all devices, ordered by what is exploited and overdue. Every decision needs a reason; accepting a risk needs a permission operators do not have, and fixes are verified by the next assessments.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Geräteverwaltung', body: 'CRUD für jedes Gerät mit Credential-Profilen und SSH-Schlüsselverwaltung. Hersteller, Modell und OS werden automatisch über NAPALM get_facts() befüllt — keine manuelle Eingabe.' },
|
||||||
|
{ title: 'Konfigurationsdrift-Erkennung & Ein-Klick-Fix', body: 'Bei jedem Poll wird der Gerätezustand mit dem Sollzustand in netOrk verglichen. Abweichende Geräte erhalten eine Warnung; der Fix streamt Live-SSH-Output direkt in den Browser.' },
|
||||||
|
{ title: 'Git-basierte Konfigurationshistorie', body: 'Bei jedem Poll wird die Konfiguration in ein lokales Git-Repository gesnapshottet — vollständige Historie, Side-by-Side-Diffs zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense.' },
|
||||||
|
{ title: 'Ansible-Automatisierung', body: '16 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban, ein Mailserver und mehr) mit automatischer Abhängigkeitsauflösung. Eigene Rollen schreiben inklusive vollständiger Lauf-Historie pro Gerät.' },
|
||||||
|
{ title: 'VM-Provisioning', body: 'Cloud-Init-VMs auf Proxmox direkt aus dem VMs-Tab des Hypervisors — Image, VLAN und IP auswählen, netOrk übernimmt DHCP-Reservierung und Geräteverknüpfung.' },
|
||||||
|
{ title: 'Dashboards', body: 'Konfigurierbare, teilbare Dashboards aus 18 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
|
||||||
|
{ title: 'Sicherheitsbewertung pro Gerät', body: 'TLS und SSH mit Note A bis F, installierte Software und Container-Images gegen bekannte Schwachstellen abgeglichen, Härtungs-Benchmarks, und welche Netze das Gerät erreichen — ohne Agent darauf.' },
|
||||||
|
{ title: 'Schwachstellen-Triage mit Verantwortlichen', body: 'Eine Queue über alle Geräte, geordnet nach Ausgenutztem und Überfälligem. Jede Entscheidung braucht eine Begründung; ein Risiko zu akzeptieren braucht eine Berechtigung, die Operatoren nicht haben, und Behebungen verifizieren die nächsten Bewertungen.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForItDepartment() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const labels = SECTION_LABELS[lang]
|
||||||
|
const painPoints = PAIN_POINTS[lang]
|
||||||
|
const features = FEATURES[lang]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<ServerStackIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItDepartment.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItDepartment.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.pain}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{painPoints.map((p) => (
|
||||||
|
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.features}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||||
|
{labels.drivers}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-4">
|
||||||
|
{lang === 'en'
|
||||||
|
? 'Custom NAPALM drivers for 15 device types, plus the built-in NAPALM drivers.'
|
||||||
|
: 'Eigene NAPALM-Treiber für 15 Gerätetypen, plus die integrierten NAPALM-Treiber.'}
|
||||||
|
</p>
|
||||||
|
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{lang === 'en' ? 'Full driver reference →' : 'Vollständige Treiberreferenz →'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItDepartment.cta.heading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItDepartment.cta.body}</p>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.forItDepartment.cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LifebuoyIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Item = { title: string; body: string }
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||||
|
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||||
|
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Is this device actually up right now?', body: 'Checking status means opening the vendor UI for that one device — or pinging it yourself and hoping.' },
|
||||||
|
{ title: 'The same manual reboot, over and over', body: 'A flaky AP needs a power cycle every few weeks, and someone has to remember to do it and be there for it.' },
|
||||||
|
{ title: '"Who changed this?" — with no record', body: 'A setting is different than it was yesterday. Nobody can say who touched it, when, or why.' },
|
||||||
|
{ title: 'Full admin access just to fix one ticket', body: 'Resolving a single warning shouldn\'t require the keys to the whole network configuration.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Läuft das Gerät gerade wirklich?', body: 'Status prüfen heißt: die Hersteller-UI für genau dieses Gerät öffnen — oder selbst pingen und hoffen.' },
|
||||||
|
{ title: 'Derselbe manuelle Reboot, immer wieder', body: 'Ein zickiger AP braucht alle paar Wochen einen Power-Cycle — und jemand muss daran denken und dafür vor Ort sein.' },
|
||||||
|
{ title: '„Wer hat das geändert?" — ohne Protokoll', body: 'Eine Einstellung ist anders als gestern. Niemand kann sagen, wer sie wann und warum geändert hat.' },
|
||||||
|
{ title: 'Voller Admin-Zugriff nur für ein Ticket', body: 'Eine einzelne Warnung zu beheben sollte nicht den Schlüssel zur gesamten Netzwerkkonfiguration erfordern.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Status at a glance', body: 'Per-device warning system with severity levels (error / warning / info); a dashboard warnings widget surfaces every open issue across sites without hunting per device.' },
|
||||||
|
{ title: 'One-click Ack', body: 'Clears a warning immediately and writes an audit log entry. Config-change warnings accept the current state as the new baseline in the same click.' },
|
||||||
|
{ title: 'Wake-on-LAN', body: 'Saved WOL targets per device, sent via the firewall\'s driver. On-demand "Wake now" or a recurring schedule; save a seen host as a target directly from the DHCP/ARP tabs.' },
|
||||||
|
{ title: 'Scheduled reboots & updates', body: 'Scheduled reboots for OpenWRT APs with per-site concurrency locking, and package updates scheduled or applied with one click.' },
|
||||||
|
{ title: 'Full audit log, filterable', body: 'Every action — who, when, what — filterable by date range, user, action, or resource, exportable to CSV or PDF.' },
|
||||||
|
{ title: 'Roles scoped to the job', body: 'Viewer/operator roles, or a custom permission set, hand out exactly the access support work needs — without granting engineer-level config rights.' },
|
||||||
|
{ title: 'Problems reach you on Signal', body: 'Failed devices, dead tunnels, expiring certificates — one message per outage instead of one per device, quiet hours per person, and any kind of message muted with one click.' },
|
||||||
|
{ title: 'Service checks every minute', body: 'DNS, time servers, VPN tunnels, core daemons and gateways are checked about once a minute, derived from what netOrk already knows — a dead resolver shows up in minutes, not at the next ticket.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Status auf einen Blick', body: 'Gerätespezifisches Warnsystem mit Schweregraden (Fehler / Warnung / Info); ein Dashboard-Warnungs-Widget zeigt jedes offene Problem standortübergreifend, ohne pro Gerät zu suchen.' },
|
||||||
|
{ title: 'Ein-Klick-Ack', body: 'Löscht eine Warnung sofort und protokolliert die Aktion im Audit-Log. Config-Change-Warnungen akzeptieren im selben Klick den aktuellen Zustand als neue Baseline.' },
|
||||||
|
{ title: 'Wake-on-LAN', body: 'Gespeicherte WOL-Ziele pro Gerät, gesendet über den Treiber der Firewall. Ein-Klick-„Jetzt wecken" oder wiederkehrender Zeitplan; ein gesehener Host lässt sich direkt aus den DHCP-/ARP-Tabs als Ziel speichern.' },
|
||||||
|
{ title: 'Geplante Reboots & Updates', body: 'Geplante Neustarts für OpenWRT-APs mit standortbezogener Concurrency-Sperre, Paket-Updates geplant oder per Ein-Klick angewendet.' },
|
||||||
|
{ title: 'Vollständiges, filterbares Audit-Log', body: 'Jede Aktion — wer, wann, was — filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource, exportierbar als CSV oder PDF.' },
|
||||||
|
{ title: 'Rollen passend zur Aufgabe', body: 'Betrachter-/Operator-Rollen oder ein eigener Berechtigungssatz geben genau den Zugriff, den Support-Arbeit braucht — ohne Engineer-Rechte für die Konfiguration.' },
|
||||||
|
{ title: 'Probleme kommen per Signal', body: 'Ausgefallene Geräte, tote Tunnel, ablaufende Zertifikate — eine Nachricht pro Ausfall statt einer pro Gerät, Ruhezeiten pro Person, und jede Nachrichtenart mit einem Klick stumm.' },
|
||||||
|
{ title: 'Service-Checks jede Minute', body: 'DNS, Zeitserver, VPN-Tunnel, Kerndienste und Gateways werden etwa einmal pro Minute geprüft, abgeleitet aus dem, was netOrk ohnehin weiß — ein toter Resolver fällt in Minuten auf, nicht erst beim nächsten Ticket.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForItSupport() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const labels = SECTION_LABELS[lang]
|
||||||
|
const painPoints = PAIN_POINTS[lang]
|
||||||
|
const features = FEATURES[lang]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<LifebuoyIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItSupport.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItSupport.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.pain}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{painPoints.map((p) => (
|
||||||
|
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.features}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItSupport.cta.heading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItSupport.cta.body}</p>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.forItSupport.cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Item = { title: string; body: string }
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||||
|
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||||
|
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Client sites you can\'t reach directly', body: 'Firewalled or disconnected networks mean juggling a VPN per client just to check a device.' },
|
||||||
|
{ title: 'No unified view across clients', body: 'Each client\'s network lives in its own silo — there\'s no single dashboard across every site you manage.' },
|
||||||
|
{ title: 'Proving what you did, and when', body: 'A client asks what changed after an incident, and there\'s no clean record to hand over — just memory and Slack messages.' },
|
||||||
|
{ title: 'Client data in someone else\'s cloud', body: 'SaaS network-management tools mean client infrastructure data sits on a vendor\'s servers you don\'t control.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Kundenstandorte, die ihr nicht direkt erreicht', body: 'Firewalled oder getrennte Netzwerke bedeuten VPN-Jonglieren pro Kunde, nur um ein Gerät zu prüfen.' },
|
||||||
|
{ title: 'Keine einheitliche Sicht über alle Kunden', body: 'Jedes Kundennetzwerk lebt in seiner eigenen Silo — kein einziges Dashboard über alle betreuten Standorte.' },
|
||||||
|
{ title: 'Belegen, was ihr wann gemacht habt', body: 'Ein Kunde fragt nach einem Vorfall, was sich geändert hat — und es gibt kein sauberes Protokoll, nur Erinnerung und Slack-Nachrichten.' },
|
||||||
|
{ title: 'Kundendaten in fremder Cloud', body: 'SaaS-Netzwerk-Tools bedeuten, dass die Infrastrukturdaten eurer Kunden auf Servern eines Anbieters liegen, den ihr nicht kontrolliert.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Satellite Deployments', body: 'A lightweight Docker agent deployed at a client site netOrk can\'t reach directly — polls devices locally and syncs results back to Central over HTTPS. Deployed in one flow via VM Provisioning.' },
|
||||||
|
{ title: 'Central auto-routes around unreachable sites', body: 'Once a satellite is online and heartbeating, Central automatically skips direct polling for that site — no manual toggling per client.' },
|
||||||
|
{ title: 'Actions work the same everywhere', body: 'Scheduled/on-demand reboots, the SNMP auto-fix flow, and discovery jobs all route through the same command channel, direct or behind a satellite. SNMP health-metric polling and WebSSH still need direct reach today.' },
|
||||||
|
{ title: 'A real audit trail to hand a client', body: 'Every orchestration action is logged — who, what, when — filterable and exportable to CSV or PDF. Evidence, not a verbal assurance.' },
|
||||||
|
{ title: 'Roles scoped per technician', body: 'Custom roles control exactly what each technician can do, from read-only visibility to full config access, per engagement.' },
|
||||||
|
{ title: 'Self-hosted, no per-seat SaaS', body: 'Docker Compose deployment, no telemetry, no cloud dependency — runs on your infrastructure or a client\'s, not a vendor\'s.' },
|
||||||
|
{ title: 'A dropped client site is one warning', body: 'When the tunnel to a site goes down, netOrk pauses polling there instead of turning every device red, names the tunnel, and re-polls everything the moment it is back.' },
|
||||||
|
{ title: 'Checks from inside the client network', body: 'Service checks run from the site satellite, so DNS, time servers and gateways are tested from where the client sits — including a DHCP check that only works on the local network.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Satellite-Deployments', body: 'Ein leichtgewichtiger Docker-Agent an einem Kundenstandort, den netOrk nicht direkt erreicht — pollt Geräte lokal und synct Ergebnisse per HTTPS zurück an Central. In einem Ablauf per VM-Provisioning deployt.' },
|
||||||
|
{ title: 'Central umgeht nicht erreichbare Standorte automatisch', body: 'Sobald ein Satellite online ist und heartbeatet, überspringt Central automatisch das direkte Polling für diesen Standort — kein manuelles Umschalten pro Kunde.' },
|
||||||
|
{ title: 'Aktionen funktionieren überall gleich', body: 'Geplante/On-Demand-Reboots, der SNMP-Auto-Fix und Discovery-Jobs laufen über denselben Command-Kanal, egal ob direkt oder hinter einem Satellite. SNMP-Health-Metrik-Polling und WebSSH brauchen heute noch direkten Zugriff.' },
|
||||||
|
{ title: 'Ein echter Audit-Trail für den Kunden', body: 'Jede Orchestrierungsaktion wird protokolliert — wer, was, wann — filterbar und exportierbar als CSV oder PDF. Beleg statt mündlicher Zusicherung.' },
|
||||||
|
{ title: 'Rollen pro Techniker', body: 'Benutzerdefinierte Rollen legen genau fest, was jeder Techniker darf — von reinem Lesezugriff bis vollem Konfigurationszugriff, je nach Einsatz.' },
|
||||||
|
{ title: 'Self-hosted, kein Pro-Seat-SaaS', body: 'Docker-Compose-Deployment, keine Telemetrie, keine Cloud-Abhängigkeit — läuft auf eurer Infrastruktur oder der eines Kunden, nicht bei einem Anbieter.' },
|
||||||
|
{ title: 'Ein ausgefallener Kundenstandort ist eine Warnung', body: 'Fällt der Tunnel zu einem Standort, pausiert netOrk dort das Polling, statt jedes Gerät rot zu färben, nennt den Tunnel und fragt alles sofort neu ab, sobald er zurück ist.' },
|
||||||
|
{ title: 'Checks aus dem Kundennetz heraus', body: 'Service-Checks laufen vom Standort-Satellite, DNS, Zeitserver und Gateways werden also von dort geprüft, wo der Kunde sitzt — inklusive eines DHCP-Checks, der nur im lokalen Netz funktioniert.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForMsp() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const labels = SECTION_LABELS[lang]
|
||||||
|
const painPoints = PAIN_POINTS[lang]
|
||||||
|
const features = FEATURES[lang]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<BuildingOffice2Icon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forMsp.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forMsp.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.pain}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{painPoints.map((p) => (
|
||||||
|
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{labels.features}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forMsp.cta.heading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forMsp.cta.body}</p>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.forMsp.cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { EnvelopeIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
|
||||||
|
export default function GettingStarted() {
|
||||||
|
const { t } = useLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-24 md:py-40">
|
||||||
|
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||||
|
<p className="text-sm font-medium text-sky-400 mb-4 font-mono">{t.gettingStarted.label}</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 leading-tight mb-6 whitespace-pre-line">
|
||||||
|
{t.gettingStarted.heading}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed mb-10">
|
||||||
|
{t.gettingStarted.sub}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:christian@manivong.de"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<EnvelopeIcon className="h-4 w-4" />
|
||||||
|
{t.gettingStarted.cta}
|
||||||
|
</a>
|
||||||
|
<div className="mt-12 border-t border-slate-800 pt-8">
|
||||||
|
<p className="text-sm text-slate-500 mb-4">{t.gettingStarted.bottomText}</p>
|
||||||
|
<div className="flex gap-6 justify-center">
|
||||||
|
<Link to="/features" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{t.gettingStarted.bottomFeatures}
|
||||||
|
</Link>
|
||||||
|
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{t.gettingStarted.bottomDrivers}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { GLOSSARY, CATEGORY_ORDER, CATEGORY_LABELS } from '../glossary/terms'
|
||||||
|
|
||||||
|
export default function Glossary() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = location.hash.slice(1)
|
||||||
|
if (!id) return
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}, [location.hash])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
<div className="mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.glossary.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{t.glossary.sub}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-14">
|
||||||
|
{CATEGORY_ORDER.map((category) => {
|
||||||
|
const entries = GLOSSARY.filter((e) => e.category === category).sort((a, b) =>
|
||||||
|
a.display.localeCompare(b.display),
|
||||||
|
)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{CATEGORY_LABELS[category][lang]}
|
||||||
|
</h2>
|
||||||
|
<dl className="space-y-6">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.id} id={entry.id} className="scroll-mt-24">
|
||||||
|
<dt className="flex flex-wrap items-baseline gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm font-semibold text-sky-400">{entry.display}</span>
|
||||||
|
{entry.fullName && (
|
||||||
|
<span className="text-sm text-slate-300">{entry.fullName[lang]}</span>
|
||||||
|
)}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-slate-500 leading-relaxed">{entry.definition[lang]}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
PuzzlePieceIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
const DRIVERS = [
|
||||||
|
'Fritz!Box', 'HPE OfficeConnect', 'HPE ProCurve / Aruba', 'Linux', 'Netgear',
|
||||||
|
'OpenMediaVault', 'OpenWrt', 'OPNsense', 'Proxmox VE', 'QNAP QTS', 'Sonos',
|
||||||
|
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
|
||||||
|
]
|
||||||
|
|
||||||
|
function Screenshot({ src, label, alt }: { src: string; label: string; alt: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||||
|
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
|
||||||
|
</div>
|
||||||
|
<img src={src} alt={alt} loading="lazy" className="block w-full" width={1600} height={1000} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { t } = useLang()
|
||||||
|
const h = t.home
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1 — Hero */}
|
||||||
|
<section className="py-24 md:py-32">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold text-slate-100 leading-tight mb-6">
|
||||||
|
{h.hero.line1}<br />
|
||||||
|
<span className="text-sky-400">{h.hero.line2}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mx-auto mb-10">
|
||||||
|
{linkify(h.hero.sub)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="mailto:christian@manivong.de"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{h.hero.cta1}
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
to="/features"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-slate-700 hover:border-slate-500 text-slate-300 hover:text-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
{h.hero.cta2}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-7xl mx-auto px-6 mt-16">
|
||||||
|
<Screenshot src="/screenshots/devices.webp" label="netork / devices" alt={h.shotAlt.devices} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 2 — Problem Statement */}
|
||||||
|
<section className="py-16">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 text-center">
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.problem)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 3 — Core Capabilities */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<MagnifyingGlassIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap1.title}</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap1.body)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<ChartBarIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap2.title}</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap2.body)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<WrenchScrewdriverIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap3.title}</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap3.body)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 4 — Driver Grid */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.driversHeading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-xl mx-auto mb-10">{linkify(h.driversSub)}</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
{DRIVERS.map((d) => (
|
||||||
|
<span key={d} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{h.driversLink}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 5 — Screenshot Walkthrough */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 space-y-24">
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot1.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot1.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/device-detail.webp" label="netork / devices / ap-floor1" alt={h.shotAlt.deviceDetail} />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="md:order-2">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/vlans.webp" label="netork / vlans" alt={h.shotAlt.vlans} />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot3.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot3.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/device-security.webp" label="netork / devices / proxy-01 / security" alt={h.shotAlt.deviceSecurity} />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="md:order-2">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/vulnerabilities.webp" label="netork / vulnerabilities" alt={h.shotAlt.vulnerabilities} />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot5.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot5.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/dashboard.webp" label="netork / dashboard" alt={h.shotAlt.dashboard} />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="md:order-2">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot6.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot6.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/service-checks.webp" label="netork / monitoring / checks" alt={h.shotAlt.serviceChecks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 5b — NIS2 */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-widest uppercase text-sky-500 mb-3">{h.nis2Label}</p>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.nis2Heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed mb-8">{linkify(h.nis2Body)}</p>
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link to="/nis2" className="inline-flex items-center gap-2 text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
<ShieldCheckIcon className="h-4 w-4" />
|
||||||
|
{h.nis2Link}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{h.nis2Items.map((m) => (
|
||||||
|
<div key={m.art} className="flex gap-4">
|
||||||
|
<span className="mt-0.5 shrink-0 w-28 text-xs font-mono text-sky-500">{m.art}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-200">{m.label}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{linkify(m.detail)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/audit-log.webp" label="netork / audit-log" alt={h.shotAlt.auditLog} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 6 — Plugin System */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 md:p-12">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.pluginsHeading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mb-6">{linkify(h.pluginsBody)}</p>
|
||||||
|
<Link to="/plugins" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{h.pluginsLink}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 7 — Deployment */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-start">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.deployHeading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.deployBody)}</p>
|
||||||
|
</div>
|
||||||
|
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||||
|
<code>{`# Clone + configure
|
||||||
|
git clone https://gitea.example.com/netork/netork.git
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env (DB URL, Redis password, secret key)
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
bash scripts/deploy.sh 192.168.1.10`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* 8 — CTA */}
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.ctaHeading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed mb-8">{h.ctaBody}</p>
|
||||||
|
<a
|
||||||
|
href="mailto:christian@manivong.de"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{h.ctaButton}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ShieldCheckIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Coverage = 'covered' | 'partial' | 'roadmap' | 'na'
|
||||||
|
type Requirement = { article: string; label: string; coverage: Coverage; netork: string }
|
||||||
|
type EvidenceBlock = { trigger: string; produces: string[] }
|
||||||
|
type ComingItem = { title: string; detail: string }
|
||||||
|
|
||||||
|
const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
|
||||||
|
en: [
|
||||||
|
{ article: 'Art. 21 (2a)', label: 'Risk analysis & information system security policies', coverage: 'partial', netork: 'Config drift detection, SNMP health metrics, and security agent coverage across all devices provide a continuous risk baseline. A formal risk register is out of scope for netOrk.' },
|
||||||
|
{ article: 'Art. 21 (2b)', label: 'Incident handling', coverage: 'partial', netork: 'Wazuh alert history, CrowdSec decisions, and Graylog syslog per device surface incidents at the network layer. A structured incident record with NIS2 Art. 23 reporting timers is on the roadmap.' },
|
||||||
|
{ article: 'Art. 21 (2c)', label: 'Business continuity, backup management, disaster recovery', coverage: 'partial', netork: 'Every poll captures a configuration snapshot into a local Git repository — full history, a side-by-side diff viewer between any two points in time, and one-click restore for OPNsense. Backup/recovery for full device state beyond configuration is out of scope.' },
|
||||||
|
{ article: 'Art. 21 (2d)', label: 'Supply chain security', coverage: 'covered', netork: 'Vendor, model, firmware, and OS version are tracked per device after every poll. The EOL Tracking plugin checks each device\'s OS version against the endoflife.date API daily and flags unsupported or soon-to-be-unsupported software.' },
|
||||||
|
{ article: 'Art. 21 (2e)', label: 'Vulnerability handling in acquisition, development & maintenance', coverage: 'covered', netork: 'Installed software and container images on every device are matched against known vulnerabilities, rated by what each flaw means on that device. A triage queue records a decision per vulnerability — not applicable, accepted until, deferred until, fixed — each with a reason, a date and who decided, written to the audit log. Deferrals come back by themselves, and a daily reassessment verifies fixes. Vulnerability data requires a netOrk licence.' },
|
||||||
|
{ article: 'Art. 21 (2f)', label: 'Assessing effectiveness of cybersecurity measures', coverage: 'partial', netork: 'The audit log records all orchestration actions. A per-site compliance dashboard (on roadmap) will aggregate security agent coverage, drift status, and patch metrics into a single view.' },
|
||||||
|
{ article: 'Art. 21 (2g)', label: 'Basic cyber hygiene & cybersecurity training', coverage: 'na', netork: 'Out of scope for a network orchestration platform. Training and hygiene policies are handled at the organizational level.' },
|
||||||
|
{ article: 'Art. 21 (2h)', label: 'Access control, asset management, human resources security', coverage: 'covered', netork: 'Full device inventory maintained automatically via discovery and continuous polling. RBAC with four built-in roles (viewer / operator / engineer / administrator) and custom role combinations. Complete audit log of all orchestration actions, filterable by date range, user, action, or resource — export to CSV or PDF for audit submissions.' },
|
||||||
|
{ article: 'Art. 21 (2i)', label: 'Multi-factor authentication', coverage: 'covered', netork: 'TOTP-based MFA for netOrk user accounts — authenticator app at login, backup codes for emergencies, session invalidation on TOTP changes, enforceable per role. Terminal sessions to devices log in with each user\'s own SSH key, never a shared account, and opened and refused sessions are recorded.' },
|
||||||
|
{ article: 'Art. 21 (2j)', label: 'Physical and environmental security', coverage: 'na', netork: 'Out of scope. Physical security of the infrastructure hosting netOrk is an organizational and facility concern.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ article: 'Art. 21 (2a)', label: 'Risikoanalyse und Sicherheitsrichtlinien für Informationssysteme', coverage: 'partial', netork: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken und Security-Agent-Abdeckung über alle Geräte liefern eine kontinuierliche Risiko-Baseline. Ein formales Risikoregister liegt außerhalb des Scopes von netOrk.' },
|
||||||
|
{ article: 'Art. 21 (2b)', label: 'Bewältigung von Sicherheitsvorfällen', coverage: 'partial', netork: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen und Graylog-Syslog pro Gerät decken Vorfälle auf Netzwerkebene auf. Ein strukturierter Incident-Datensatz mit NIS2 Art. 23 Melde-Timern ist auf der Roadmap.' },
|
||||||
|
{ article: 'Art. 21 (2c)', label: 'Geschäftskontinuität, Backup-Management, Disaster Recovery', coverage: 'partial', netork: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert — vollständige Historie, ein Side-by-Side-Diff-Viewer zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense. Backup/Recovery für den vollständigen Gerätezustand über die Konfiguration hinaus liegt außerhalb des Scopes.' },
|
||||||
|
{ article: 'Art. 21 (2d)', label: 'Supply-Chain-Sicherheit', coverage: 'covered', netork: 'Hersteller, Modell, Firmware und OS-Version werden nach jedem Poll pro Gerät erfasst. Das EOL-Tracking-Plugin gleicht die OS-Version jedes Geräts täglich mit der endoflife.date-API ab und kennzeichnet nicht mehr oder bald nicht mehr unterstützte Software.' },
|
||||||
|
{ article: 'Art. 21 (2e)', label: 'Schwachstellenbehandlung bei Erwerb, Entwicklung & Wartung', coverage: 'covered', netork: 'Installierte Software und Container-Images jedes Geräts werden gegen bekannte Schwachstellen abgeglichen und danach bewertet, was die Lücke auf genau diesem Gerät bedeutet. Eine Triage-Queue hält pro Schwachstelle eine Entscheidung fest — nicht zutreffend, akzeptiert bis, zurückgestellt bis, behoben — jeweils mit Begründung, Datum und Entscheider, im Audit-Log protokolliert. Zurückgestelltes kommt von selbst zurück, eine tägliche Neubewertung verifiziert Behebungen. Die Schwachstellendaten setzen eine netOrk-Lizenz voraus.' },
|
||||||
|
{ article: 'Art. 21 (2f)', label: 'Beurteilung der Wirksamkeit von Cybersicherheitsmaßnahmen', coverage: 'partial', netork: 'Das Audit-Log erfasst alle Orchestrierungsaktionen. Ein Compliance-Dashboard pro Standort (auf der Roadmap) wird Security-Agent-Abdeckung, Drift-Status und Patch-Metriken in einer Ansicht zusammenfassen.' },
|
||||||
|
{ article: 'Art. 21 (2g)', label: 'Grundlegende Cyberhygiene und Cybersicherheitsschulungen', coverage: 'na', netork: 'Außerhalb des Scopes einer Netzwerk-Orchestrierungsplattform. Schulungen und Hygiene-Richtlinien werden auf Organisationsebene gehandhabt.' },
|
||||||
|
{ article: 'Art. 21 (2h)', label: 'Zugangskontrolle, Asset-Management, Personalsicherheit', coverage: 'covered', netork: 'Vollständiges Geräteinventar automatisch über Discovery und kontinuierliches Polling gepflegt. RBAC mit vier integrierten Rollen (Betrachter / Operator / Ingenieur / Administrator) und benutzerdefinierten Rollenkombinationen. Vollständiges Audit-Log aller Orchestrierungsaktionen, filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource — Export als CSV oder PDF für Audit-Einreichungen.' },
|
||||||
|
{ article: 'Art. 21 (2i)', label: 'Multi-Faktor-Authentifizierung', coverage: 'covered', netork: 'TOTP-basierte MFA für netOrk-Benutzerkonten — Authenticator-App beim Login, Backup-Codes für Notfälle, Session-Invalidierung bei TOTP-Änderungen, pro Rolle erzwingbar. Terminal-Sitzungen zu Geräten melden sich mit dem eigenen SSH-Schlüssel des Benutzers an, nie mit einem geteilten Konto; geöffnete und verweigerte Sitzungen werden protokolliert.' },
|
||||||
|
{ article: 'Art. 21 (2j)', label: 'Physische und umgebungsbezogene Sicherheit', coverage: 'na', netork: 'Außerhalb des Scopes. Die physische Sicherheit der Infrastruktur, die netOrk hostet, ist eine organisatorische und gebäudetechnische Angelegenheit.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVIDENCE: Record<'en' | 'de', EvidenceBlock[]> = {
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
trigger: 'Every device poll',
|
||||||
|
produces: [
|
||||||
|
'Installed package list with versions',
|
||||||
|
'Available update count',
|
||||||
|
'Interface status, ARP table, DHCP leases',
|
||||||
|
'VLAN membership vs. desired state (drift)',
|
||||||
|
'SNMP health metrics (CPU, memory, interface counters)',
|
||||||
|
'Wazuh agent status and CVE counts by severity',
|
||||||
|
'Graylog syslog forwarding status',
|
||||||
|
'CrowdSec decisions and ban counts',
|
||||||
|
'Git-backed configuration snapshot, diffed against the previous one to detect unauthorized changes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'Every user action',
|
||||||
|
produces: [
|
||||||
|
'Audit log entry: user, timestamp, resource, action',
|
||||||
|
'Before/after values for configuration changes',
|
||||||
|
'Acknowledged warnings logged with the accepting user',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'On demand',
|
||||||
|
produces: [
|
||||||
|
'Topology graph — network segmentation view',
|
||||||
|
'Subnet browser — IP space coverage',
|
||||||
|
'VLAN matrix — which devices carry which VLANs',
|
||||||
|
'Configuration diff between any two snapshots; one-click restore (OPNsense)',
|
||||||
|
'Audit log export to PDF / CSV, filtered by date range, user, action, or resource',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{
|
||||||
|
trigger: 'Bei jedem Geräte-Poll',
|
||||||
|
produces: [
|
||||||
|
'Installierte Paketliste mit Versionen',
|
||||||
|
'Anzahl verfügbarer Updates',
|
||||||
|
'Schnittstellenstatus, ARP-Tabelle, DHCP-Leases',
|
||||||
|
'VLAN-Zugehörigkeit vs. Sollzustand (Drift)',
|
||||||
|
'SNMP-Gesundheitsmetriken (CPU, Speicher, Schnittstellenzähler)',
|
||||||
|
'Wazuh-Agent-Status und CVE-Anzahl nach Schweregrad',
|
||||||
|
'Graylog-Syslog-Weiterleitungsstatus',
|
||||||
|
'CrowdSec-Entscheidungen und Ban-Anzahl',
|
||||||
|
'Git-basierter Konfigurationssnapshot, gegen den vorherigen geprüft, um nicht autorisierte Änderungen zu erkennen',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'Bei jeder Benutzeraktion',
|
||||||
|
produces: [
|
||||||
|
'Audit-Log-Eintrag: Benutzer, Zeitstempel, Ressource, Aktion',
|
||||||
|
'Vorher/Nachher-Werte für Konfigurationsänderungen',
|
||||||
|
'Bestätigte (acked) Warnungen werden mit dem bestätigenden Benutzer protokolliert',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'Auf Abruf',
|
||||||
|
produces: [
|
||||||
|
'Topologie-Graph — Netzwerksegmentierungs-Ansicht',
|
||||||
|
'Subnetz-Browser — IP-Raum-Abdeckung',
|
||||||
|
'VLAN-Matrix — welche Geräte welche VLANs führen',
|
||||||
|
'Konfigurations-Diff zwischen zwei beliebigen Snapshots; Ein-Klick-Restore (OPNsense)',
|
||||||
|
'Audit-Log-Export als PDF / CSV, gefiltert nach Datumsbereich, Benutzer, Aktion oder Ressource',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMING: Record<'en' | 'de', ComingItem[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Compliance dashboard', detail: 'Per-site Art. 21 checklist: asset coverage, patch status, agent deployment, drift, syslog, audit activity.' },
|
||||||
|
{ title: 'Incident workflow', detail: 'Structured incident records with NIS2 Art. 23 reporting timers (24 h / 72 h) and external webhook delivery.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Compliance-Dashboard', detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Agent-Deployment, Drift, Syslog, Audit-Aktivität.' },
|
||||||
|
{ title: 'Incident-Workflow', detail: 'Strukturierte Incident-Datensätze mit NIS2 Art. 23 Melde-Timern (24 h / 72 h) und externer Webhook-Zustellung.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoverageTag({ coverage, labels }: { coverage: Coverage; labels: Record<string, string> }) {
|
||||||
|
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap border'
|
||||||
|
if (coverage === 'covered') return <span className={`${base} bg-green-500/15 text-green-400 border-green-500/20`}>{labels.covered}</span>
|
||||||
|
if (coverage === 'partial') return <span className={`${base} bg-yellow-500/15 text-yellow-400 border-yellow-500/20`}>{labels.partial}</span>
|
||||||
|
if (coverage === 'roadmap') return <span className={`${base} bg-sky-500/15 text-sky-400 border-sky-500/20`}>{labels.roadmap}</span>
|
||||||
|
return <span className={`${base} bg-slate-700/50 text-slate-500 border-slate-700`}>{labels.na}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Nis2() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const reqs = REQUIREMENTS[lang]
|
||||||
|
const evidence = EVIDENCE[lang]
|
||||||
|
const coming = COMING[lang]
|
||||||
|
const coverageLabels = t.nis2.coverageLabels
|
||||||
|
const legendLabels = t.nis2.legendLabels
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<ShieldCheckIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.nis2.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.nis2.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.nis2.mappingHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-0 rounded-xl border border-slate-800 overflow-hidden">
|
||||||
|
{reqs.map((r, i) => (
|
||||||
|
<div key={r.article} className={`p-5 ${i < reqs.length - 1 ? 'border-b border-slate-800' : ''}`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-sky-500 shrink-0">{r.article}</span>
|
||||||
|
<span className="text-sm font-medium text-slate-200">{r.label}</span>
|
||||||
|
</div>
|
||||||
|
<CoverageTag coverage={r.coverage} labels={coverageLabels} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed md:pl-24">{linkify(r.netork)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-500">
|
||||||
|
{(['covered', 'partial', 'roadmap', 'na'] as Coverage[]).map((c) => (
|
||||||
|
<span key={c} className="flex items-center gap-1.5">
|
||||||
|
<CoverageTag coverage={c} labels={coverageLabels} />
|
||||||
|
{legendLabels[c]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-2 pb-2 border-b border-slate-800">
|
||||||
|
{t.nis2.evidenceHeading}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 mb-6">{linkify(t.nis2.evidenceSub)}</p>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{evidence.map((e) => (
|
||||||
|
<div key={e.trigger} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-xs font-semibold text-sky-500 uppercase tracking-widest mb-3">{e.trigger}</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{e.produces.map((item) => (
|
||||||
|
<li key={item} className="flex items-start gap-2 text-sm text-slate-400">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||||
|
{linkify(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.nis2.comingHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{coming.map((item) => (
|
||||||
|
<div key={item.title} className="flex gap-4">
|
||||||
|
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-200">{item.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-0.5">{linkify(item.detail)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link to="/roadmap" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||||
|
{lang === 'de' ? 'Vollständige Roadmap →' : 'Full roadmap →'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.nis2.cta.heading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.nis2.cta.body}</p>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.nis2.cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { PuzzlePieceIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Building = { title: string; body: string }
|
||||||
|
type BuiltinPlugin = { label: string; body: string; hosts: string[]; hooks: string[] }
|
||||||
|
type Step = { title: string; body: string }
|
||||||
|
type HookKind = { name: string; behavior: string; use: string }
|
||||||
|
|
||||||
|
const BUILDING_BLOCKS: Record<'en' | 'de', Building[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Metadata (PluginMeta)', body: 'name, label, description, version, a declared permission surface (db_read, db_write, external_hosts, hooks — for review, not runtime enforcement), and an optional Celery Beat schedule.' },
|
||||||
|
{ title: 'Router', body: 'A FastAPI APIRouter, mounted under /api/v1 at startup — but only for plugins that are enabled.' },
|
||||||
|
{ title: 'Tasks', body: 'Celery tasks for anything that talks to an external system. Hooks queue work; they never block the event loop on I/O.' },
|
||||||
|
{ title: 'Hooks', body: 'Handlers registered against the central event bus (hook_registry) via the @hook decorator — react to events like poll.complete.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Metadaten (PluginMeta)', body: 'name, label, description, version, eine deklarierte Permission-Surface (db_read, db_write, external_hosts, hooks — für Review, nicht zur Laufzeit erzwungen) und ein optionaler Celery-Beat-Schedule.' },
|
||||||
|
{ title: 'Router', body: 'Ein FastAPI APIRouter, beim Start unter /api/v1 gemountet — aber nur für aktivierte Plugins.' },
|
||||||
|
{ title: 'Tasks', body: 'Celery-Tasks für alles, was mit einem externen System spricht. Hooks reihen Arbeit ein; sie blockieren den Event-Loop nie mit I/O.' },
|
||||||
|
{ title: 'Hooks', body: 'Handler, die über den @hook-Decorator am zentralen Event-Bus (hook_registry) registriert werden — reagieren auf Events wie poll.complete.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN: Record<'en' | 'de', BuiltinPlugin[]> = {
|
||||||
|
en: [
|
||||||
|
{ label: 'Wazuh Security', body: 'Syncs device enrollment with the Wazuh manager and surfaces agent health warnings, vulnerability counts, alerts, and CIS benchmark scores.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||||
|
{ label: 'Graylog Syslog', body: 'Checks whether syslog-capable devices forward logs to Graylog via rsyslog, and can automatically write the forwarding rule.', hosts: [], hooks: [] },
|
||||||
|
{ label: 'CrowdSec', body: 'Surfaces org-level CrowdSec security health data per device: active decisions, blocked requests, remediation metrics, and top attack scenarios.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||||
|
{ label: 'apt-cacher-ng', body: "Auto-detects apt-cacher-ng on Docker hosts and configures the APT proxy on devices within the same site so APT traffic is routed through the local cache.", hosts: [], hooks: ['poll.complete'] },
|
||||||
|
{ label: 'EOL Tracking', body: "Flags devices running end-of-life or soon-to-be-end-of-life firmware/OS versions, checked daily against the endoflife.date API. Reuses the OS version already collected during polling — no extra device I/O.", hosts: ['endoflife.date'], hooks: [] },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ label: 'Wazuh Security', body: 'Synchronisiert die Geräte-Registrierung mit dem Wazuh-Manager und zeigt Agent-Health-Warnungen, Schwachstellenanzahl, Alerts und CIS-Benchmark-Scores an.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||||
|
{ label: 'Graylog Syslog', body: 'Prüft, ob syslog-fähige Geräte Logs via rsyslog an Graylog weiterleiten, und kann die Weiterleitungsregel automatisch schreiben.', hosts: [], hooks: [] },
|
||||||
|
{ label: 'CrowdSec', body: 'Zeigt Org-Level-CrowdSec-Sicherheitsdaten pro Gerät: aktive Entscheidungen, geblockte Requests, Remediation-Metriken und Top-Angriffsszenarien.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||||
|
{ label: 'apt-cacher-ng', body: 'Erkennt apt-cacher-ng automatisch auf Docker-Hosts und konfiguriert den APT-Proxy auf Geräten im selben Standort, sodass APT-Traffic über den lokalen Cache läuft.', hosts: [], hooks: ['poll.complete'] },
|
||||||
|
{ label: 'EOL-Tracking', body: 'Kennzeichnet Geräte mit End-of-Life- oder bald End-of-Life-Firmware/OS, täglich gegen die endoflife.date-API geprüft. Nutzt die beim Polling bereits erfasste OS-Version — keine zusätzliche Geräte-I/O.', hosts: ['endoflife.date'], hooks: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: Record<'en' | 'de', Step[]> = {
|
||||||
|
en: [
|
||||||
|
{ title: 'Declare metadata', body: 'Create netork/plugins/<name>/__init__.py with a PluginMeta — permissions are declarative, for human review, not enforced at runtime.' },
|
||||||
|
{ title: 'Register', body: 'Implement register(): register the metadata with plugin_registry and import your hooks module so its @hook decorators run.' },
|
||||||
|
{ title: 'Add hooks', body: 'hooks.py: handlers for the events you care about, e.g. poll.complete.' },
|
||||||
|
{ title: 'Add a router (optional)', body: 'router.py: a FastAPI APIRouter, returned from a _get_router() callable passed to plugin_registry.register().' },
|
||||||
|
{ title: 'Add tasks', body: 'tasks.py: Celery tasks for anything that talks to an external system — hooks should queue work, not block.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ title: 'Metadaten deklarieren', body: 'netork/plugins/<name>/__init__.py mit einer PluginMeta anlegen — Permissions sind deklarativ, für menschliches Review, nicht zur Laufzeit erzwungen.' },
|
||||||
|
{ title: 'Registrieren', body: 'register() implementieren: Metadaten bei plugin_registry registrieren und das hooks-Modul importieren, damit dessen @hook-Decorators laufen.' },
|
||||||
|
{ title: 'Hooks hinzufügen', body: 'hooks.py: Handler für die Events, die dich interessieren, z. B. poll.complete.' },
|
||||||
|
{ title: 'Router hinzufügen (optional)', body: 'router.py: ein FastAPI APIRouter, zurückgegeben von einem _get_router()-Callable, das an plugin_registry.register() übergeben wird.' },
|
||||||
|
{ title: 'Tasks hinzufügen', body: 'tasks.py: Celery-Tasks für alles, was mit einem externen System spricht — Hooks sollten Arbeit einreihen, nicht blockieren.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_KINDS: Record<'en' | 'de', HookKind[]> = {
|
||||||
|
en: [
|
||||||
|
{ name: 'fire()', behavior: 'Fire-and-forget. All handlers run concurrently; exceptions are logged, never raised. 30 s timeout per handler.', use: 'Side effects after an event, e.g. "queue a Wazuh sync after a poll completes".' },
|
||||||
|
{ name: 'call()', behavior: 'Blocking. Handlers run sequentially by priority; exceptions propagate to the caller. 10 s timeout per handler.', use: 'When the caller needs to know a handler failed.' },
|
||||||
|
{ name: 'transform()', behavior: 'Pipeline. Handlers run sequentially, each receiving and returning value. 10 s timeout per handler.', use: 'Letting plugins enrich or modify a value in place, e.g. annotate a payload before it\'s persisted.' },
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{ name: 'fire()', behavior: 'Fire-and-forget. Alle Handler laufen gleichzeitig; Exceptions werden geloggt, nie geworfen. 30 s Timeout pro Handler.', use: 'Seiteneffekte nach einem Event, z. B. „Wazuh-Sync nach abgeschlossenem Poll einreihen".' },
|
||||||
|
{ name: 'call()', behavior: 'Blockierend. Handler laufen sequenziell nach Priorität; Exceptions propagieren zum Aufrufer. 10 s Timeout pro Handler.', use: 'Wenn der Aufrufer wissen muss, dass ein Handler fehlgeschlagen ist.' },
|
||||||
|
{ name: 'transform()', behavior: 'Pipeline. Handler laufen sequenziell, jeder erhält und liefert value zurück. 10 s Timeout pro Handler.', use: 'Plugins lassen einen Wert anreichern oder verändern, z. B. ein Payload vor dem Speichern annotieren.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTRY_PARAGRAPHS: Record<'en' | 'de', string[]> = {
|
||||||
|
en: [
|
||||||
|
'Enabled state lives in the database as a Setting row: plugin.<name>.enabled = "true" | "false". The registry exposes enable() and disable(), gated behind RBAC like every other orchestration action.',
|
||||||
|
"Routers are mounted once at FastAPI startup for every enabled plugin — FastAPI doesn't support unmounting routers at runtime, so disabling a plugin's API surface takes effect after the next deploy.",
|
||||||
|
"Hook handlers are registered as soon as a plugin's hooks module is imported. Whether a handler is a no-op until configured is up to the handler itself — the bundled plugins check their own Settings before acting.",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
'Der Aktivierungsstatus liegt in der Datenbank als Setting-Zeile: plugin.<name>.enabled = "true" | "false". Die Registry stellt enable() und disable() bereit, abgesichert durch RBAC wie jede andere Orchestrierungsaktion.',
|
||||||
|
'Router werden beim FastAPI-Start einmal für jedes aktivierte Plugin gemountet — FastAPI unterstützt kein Unmounten zur Laufzeit, daher wirkt sich das Deaktivieren der API-Oberfläche eines Plugins erst nach dem nächsten Deploy aus.',
|
||||||
|
'Hook-Handler werden registriert, sobald das hooks-Modul eines Plugins importiert wird. Ob ein Handler bis zur Konfiguration ein No-op bleibt, entscheidet der Handler selbst — die mitgelieferten Plugins prüfen vor dem Handeln ihre eigenen Settings.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_CODE = `# netork/plugins/myplugin/__init__.py
|
||||||
|
from netork.plugins import PluginMeta, PluginPermissions
|
||||||
|
from netork.plugins.registry import plugin_registry
|
||||||
|
|
||||||
|
_META = PluginMeta(
|
||||||
|
name="myplugin",
|
||||||
|
label="My Plugin",
|
||||||
|
description="What this plugin does.",
|
||||||
|
permissions=PluginPermissions(
|
||||||
|
db_read=["Device"],
|
||||||
|
external_hosts=["myservice:443"],
|
||||||
|
hooks=["poll.complete"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def register() -> None:
|
||||||
|
plugin_registry.register(_META, get_router=_get_router)
|
||||||
|
from netork.plugins.myplugin import hooks as _hooks # noqa: F401
|
||||||
|
|
||||||
|
def _get_router():
|
||||||
|
from netork.plugins.myplugin.router import router
|
||||||
|
return router`
|
||||||
|
|
||||||
|
const HOOK_CODE = `# netork/plugins/myplugin/hooks.py
|
||||||
|
from netork.plugins.hooks import hook
|
||||||
|
|
||||||
|
@hook("poll.complete", priority=100)
|
||||||
|
async def _on_poll_complete(device_id: str, session_factory, **kwargs) -> None:
|
||||||
|
# queue a Celery task — never block the event loop here
|
||||||
|
from netork.plugins.myplugin.tasks import sync_device
|
||||||
|
sync_device.apply_async(args=[device_id], queue="default")`
|
||||||
|
|
||||||
|
function CodeBlock({ code }: { code: string }) {
|
||||||
|
return (
|
||||||
|
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostTag({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono text-slate-400 border border-slate-700 bg-slate-950">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Plugins() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const blocks = BUILDING_BLOCKS[lang]
|
||||||
|
const builtin = BUILTIN[lang]
|
||||||
|
const steps = STEPS[lang]
|
||||||
|
const hookKinds = HOOK_KINDS[lang]
|
||||||
|
const registryParagraphs = REGISTRY_PARAGRAPHS[lang]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||||
|
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.plugins.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.plugins.sub)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.plugins.whatHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{blocks.map((b) => (
|
||||||
|
<div key={b.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{b.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(b.body)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.plugins.builtinHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{builtin.map((p) => (
|
||||||
|
<div key={p.label} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.label}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed mb-3">{linkify(p.body)}</p>
|
||||||
|
{(p.hosts.length > 0 || p.hooks.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{p.hosts.map((h) => <HostTag key={h} value={h} />)}
|
||||||
|
{p.hooks.map((h) => <HostTag key={h} value={`hook: ${h}`} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.plugins.writingHeading}
|
||||||
|
</h2>
|
||||||
|
<ol className="space-y-4 mb-8">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={s.title} className="flex gap-4">
|
||||||
|
<span className="shrink-0 h-6 w-6 rounded-full bg-sky-600/10 text-sky-400 text-xs font-semibold flex items-center justify-center mt-0.5">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-200">{s.title}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(s.body)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CodeBlock code={META_CODE} />
|
||||||
|
<CodeBlock code={HOOK_CODE} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.plugins.hookBusHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{hookKinds.map((h) => (
|
||||||
|
<div key={h.name} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<p className="text-sm font-mono font-semibold text-sky-400 mb-1.5">{h.name}</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-2">{linkify(h.behavior)}</p>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(h.use)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{t.plugins.registryHeading}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{registryParagraphs.map((p) => (
|
||||||
|
<p key={p} className="text-sm text-slate-400 leading-relaxed">{linkify(p)}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.plugins.cta.heading}</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.plugins.cta.body}</p>
|
||||||
|
<Link
|
||||||
|
to="/docs/getting-started"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t.plugins.cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useLang } from '../context/LangContext'
|
||||||
|
import { linkify } from '../lib/glossary'
|
||||||
|
|
||||||
|
type Item = { title: string; detail: string; nis2?: boolean }
|
||||||
|
type Group = { label: string; items: Item[] }
|
||||||
|
|
||||||
|
const GROUPS: Record<'en' | 'de', Group[]> = {
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
label: 'Next release',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'CrowdSec across sites',
|
||||||
|
detail: 'The CrowdSec plugin grows into its own section: every LAPI instance, decisions and alerts across sites, how many sites one address reached, bans inside your own subnets counted separately, and which internet-facing hosts nobody watches yet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Windows driver',
|
||||||
|
detail: 'Windows hosts over WinRM: facts, interfaces, ARP, routes and services, including service control.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Single-use console tickets',
|
||||||
|
detail: 'The browser terminal opens with a one-time ticket instead of passing the session token in the WebSocket URL.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Honest reboots',
|
||||||
|
detail: 'A reboot request for a device whose driver cannot restart it is refused with a reason instead of being reported as done.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Planned',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Compliance dashboard',
|
||||||
|
detail: 'Per-site Art. 21 checklist: asset coverage, patch status, security agent deployment, config drift, syslog forwarding, audit activity — aggregated into a single view.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Webhook engine',
|
||||||
|
detail: 'Outbound HTTP webhooks for events: device discovered, config change detected, job failed, alert threshold exceeded. HMAC-SHA256 payload signing. Integrates with n8n, Slack, PagerDuty.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live job log streaming',
|
||||||
|
detail: 'WebSocket endpoint for real-time Celery task output in the UI. Currently streamed via SSE for fix-flows; full job log streaming for all long-running operations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'NetBox sync — manual trigger & status view',
|
||||||
|
detail: 'POST /api/v1/netbox/sync endpoint with progress feedback and a sync history view in the UI. Currently sync runs automatically; the manual trigger and status are missing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'HashiCorp Vault integration',
|
||||||
|
detail: 'Real secret management as the first security plugin, replacing the current Fernet-based encryption at rest for device credentials and SSH keys.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Firewall profile management — next steps',
|
||||||
|
detail: 'Per-site profiles with a diff against a live OPNsense and step-by-step apply shipped in 0.12. Profile types, OpenWrt as a target, and the push mechanism beyond that are still under review.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Under consideration',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'VLAN visualization',
|
||||||
|
detail: 'Heatmap or matrix view of which devices carry which VLANs, without digging through per-device VLAN lists.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Incident workflow',
|
||||||
|
detail: 'Structured incident record tied to devices and security events. Deadline tracker for NIS2 Art. 23 reporting windows (24 h early warning, 72 h full notification). Webhook to external ticketing systems.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'mDNS scanner',
|
||||||
|
detail: 'Discover media devices (Apple TV, Chromecast, Sonos) via mDNS/Bonjour without needing a NAPALM driver. Inventory visibility and firewall segmentation suggestions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Prometheus metrics + Grafana dashboards',
|
||||||
|
detail: 'Expose per-device SNMP health metrics as a Prometheus scrape endpoint. Pre-built Grafana dashboard for interface counters, CPU, memory, and poll lag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kubernetes Helm chart',
|
||||||
|
detail: 'Helm chart for production-grade deployments: horizontal scaling for the API and worker pods, Secrets via Vault Agent Injector, Ingress with TLS termination.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
{
|
||||||
|
label: 'Nächstes Release',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'CrowdSec über alle Standorte',
|
||||||
|
detail: 'Das CrowdSec-Plugin wird ein eigener Bereich: jede LAPI-Instanz, Entscheidungen und Alerts über alle Standorte, wie viele Standorte eine Adresse erreicht hat, Sperren im eigenen Netz getrennt gezählt, und welche vom Internet erreichbaren Hosts noch niemand überwacht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Windows-Treiber',
|
||||||
|
detail: 'Windows-Hosts über WinRM: Fakten, Interfaces, ARP, Routen und Dienste, inklusive Dienststeuerung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Einmal-Tickets für die Konsole',
|
||||||
|
detail: 'Das Browser-Terminal öffnet mit einem einmal gültigen Ticket, statt das Sitzungstoken in der WebSocket-URL mitzugeben.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ehrliche Neustarts',
|
||||||
|
detail: 'Eine Neustart-Anfrage für ein Gerät, dessen Treiber es nicht neu starten kann, wird mit Begründung abgelehnt, statt als erledigt gemeldet zu werden.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Geplant',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Compliance-Dashboard',
|
||||||
|
detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Security-Agent-Deployment, Config-Drift, Syslog-Weiterleitung, Audit-Aktivität — zusammengefasst in einer Ansicht.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Webhook-Engine',
|
||||||
|
detail: 'Ausgehende HTTP-Webhooks für Events: Gerät entdeckt, Konfigurationsänderung erkannt, Job fehlgeschlagen, Warnschwelle überschritten. HMAC-SHA256-Payload-Signierung. Integrierbar mit n8n, Slack, PagerDuty.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live-Job-Log-Streaming',
|
||||||
|
detail: 'WebSocket-Endpunkt für Echtzeit-Celery-Task-Output in der UI. Aktuell wird via SSE für Fix-Flows gestreamt; vollständiges Job-Log-Streaming für alle lang laufenden Operationen geplant.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'NetBox-Sync — manueller Trigger & Statusansicht',
|
||||||
|
detail: 'POST /api/v1/netbox/sync-Endpunkt mit Fortschrittsfeedback und Sync-Verlaufsansicht in der UI. Derzeit läuft der Sync automatisch; manueller Trigger und Status fehlen noch.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'HashiCorp-Vault-Integration',
|
||||||
|
detail: 'Echtes Secret-Management als erstes Security-Plugin — löst die aktuelle Fernet-basierte Verschlüsselung von Geräte-Credentials und SSH-Schlüsseln ab.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Firewall-Profile — nächste Schritte',
|
||||||
|
detail: 'Profile pro Standort mit Diff gegen eine echte OPNsense und schrittweisem Anwenden kamen mit 0.12. Profiltypen, OpenWrt als Ziel und der Push-Mechanismus darüber hinaus stehen noch auf dem Prüfstand.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'In Erwägung',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'VLAN-Visualisierung',
|
||||||
|
detail: 'Heatmap- oder Matrixansicht, welche Geräte welche VLANs führen — ohne sich durch geräteweise VLAN-Listen zu graben.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Incident-Workflow',
|
||||||
|
detail: 'Strukturierter Incident-Datensatz, verknüpft mit Geräten und Sicherheitsereignissen. Fristen-Tracker für NIS2 Art. 23 Meldepflichten (24 h Frühwarnung, 72 h vollständige Meldung). Webhook zu externen Ticketing-Systemen.',
|
||||||
|
nis2: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'mDNS-Scanner',
|
||||||
|
detail: 'Entdeckt Mediengeräte (Apple TV, Chromecast, Sonos) über mDNS/Bonjour ohne NAPALM-Treiber. Inventarsichtbarkeit und Empfehlungen zur Firewall-Segmentierung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Prometheus-Metriken + Grafana-Dashboards',
|
||||||
|
detail: 'Veröffentlicht gerätebezogene SNMP-Gesundheitsmetriken als Prometheus-Scrape-Endpunkt. Vorgefertigtes Grafana-Dashboard für Schnittstellenzähler, CPU, Speicher und Poll-Lag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kubernetes Helm Chart',
|
||||||
|
detail: 'Helm Chart für produktionsreife Deployments: horizontale Skalierung für API- und Worker-Pods, Secrets via Vault Agent Injector, Ingress mit TLS-Terminierung.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nis2Badge() {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-mono font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||||
|
NIS2
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Roadmap() {
|
||||||
|
const { lang, t } = useLang()
|
||||||
|
const groups = GROUPS[lang]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 md:py-24">
|
||||||
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.roadmap.heading}</h1>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
|
||||||
|
{t.roadmap.sub.slice(0, t.roadmap.sub.indexOf('NIS2'))}
|
||||||
|
<Nis2Badge />
|
||||||
|
{t.roadmap.sub.slice(t.roadmap.sub.indexOf('NIS2') + 'NIS2'.length)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-14">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||||
|
{group.label}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div key={item.title} className="flex gap-4">
|
||||||
|
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-slate-200">{item.title}</span>
|
||||||
|
{item.nis2 && <Nis2Badge />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed">{linkify(item.detail)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||