Author SHA1 Message Date
Christian ManivongandClaude Opus 5.5 8a1cf7b2b6 fix: usable on phones — menu, no sideways scrolling, whole roadmap intro
CI / TypeScript — type-check (push) Successful in 19s
CI / Publish — build & push image (push) Skipped
CI / TypeScript — type-check (pull_request) Successful in 17s
CI / Publish — build & push image (pull_request) Skipped
- The navigation had no mobile layout: every page was 413px wider than a
  phone. Below lg the links now sit behind a menu button, grouped like the
  desktop dropdowns; the menu closes on navigation.
- Glossary tooltips were invisible but still laid out, so a term near the
  right edge widened the page. Hidden tooltips no longer take up space;
  they show on hover and on keyboard focus as before.
- Long German words in page headings ("produktionstauglich",
  "Konfigurationstiefe") overflowed at 360px; headings start a size smaller
  on phones.
- The roadmap intro dropped everything after its second "NIS2" ("…the
  baseline requirements of"); it is split at the first one only now.

Checked at 360, 390 and 768px on every page: no horizontal overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:52:13 +02:00
Christian ManivongandClaude Opus 5.5 011f816fc9 feat: replace UI mockups with real netOrk screenshots
The homepage showed seven hand-built JSX imitations of the netOrk UI. They
are gone; every image is now a screenshot of netOrk v0.28.0 itself, taken
from an anonymized copy of a production database (scripts/demo) with
scripts/screenshots/capture.py and published as WebP (~630 KB for all eight).

- Hero: the device inventory. Walkthrough: device detail, VLANs, the Security
  tab, the vulnerability triage queue (replacing the config-diff row), the
  dashboard and service checks (new row 6). NIS2: the audit log, filtered to
  what people did.
- Copy follows the images: row 3 describes the security assessment, row 4 the
  triage queue; row 2 no longer claims corrections are always automatic;
  18 widgets. Alt texts in both languages.
- Also fixed on the homepage: the NIS2 teaser for Art. 21 (2e) and the
  container list of a deployment (three worker pools, plus Flower, registry,
  APT cache and the Signal gateway).
- Demo tooling hardened on the real dump: secrets inside JSON (Wi-Fi keys),
  reverse DNS zones, glued identifiers, tens of thousands of CrowdSec
  addresses, a schema newer than the release (anonymize, then downgrade),
  MFA-enforcing roles, and click steps for view filters.
- DESIGN.md: real screenshots only. PAGES.md: the six rows as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:24:51 +02:00
Christian ManivongandClaude Opus 5.5 38834100d1 feat(screenshots): real netOrk screenshots from an anonymized demo copy
The site has shown hand-built JSX mockups of the UI so far. This adds the
tooling to replace them with screenshots of the real application:

- scripts/demo/up.sh restores a pg_dump of a production database into a
  local Postgres and starts netOrk (a pinned release, default v0.28.0) with
  only the API and the UI: no worker, no beat, no Redis, a random encryption
  key. Nothing polls and nothing can reach a device.
- scripts/demo/anonymize.py rewrites every text, JSON and address column of
  every table: domains to example.demo, private IPv4 per /16 with the host
  part kept, public addresses into the documentation ranges, MACs with the
  vendor prefix kept, e-mail addresses and configured names. Secrets are
  emptied by column name, one admin "netork" is left. It refuses non-local
  databases and ends with a leak report. The real-to-demo name map lives
  outside the repo.
- scripts/screenshots/capture.py drives headless Chromium through a
  declarative list of pages, logs in to the demo copy by itself, and aborts
  every non-GET API request, so taking screenshots cannot change anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:33:18 +02:00
Christian ManivongandClaude Opus 5.5 4cba6e156c feat: reflect netOrk v0.12.0–v0.28.0 release notes
Seventeen releases since the site was last brought up to date, checked
against the changelog and the code at the v0.28.0 tag.

- New feature sections: Security Assessment (TLS/SSH grades, CVE and
  container-image matching, exposure, deep scans; Knowledge Base licence),
  Vulnerability Management (triage queue, decisions with reasons, deferrals
  that come back, verified fixes), DHCP, Managed Services, Notifications
  (Signal).
- Existing sections gain per-user SSH keys and session windows, multi-role
  devices, one device per address per site, LAN Scan, MAC-table topology,
  service checks, site reachability, per-site firewall profiles with diff,
  honoured drift auto-correct, 16 Ansible roles, 18 dashboard widgets.
- Corrections: Docker status is Linux/OMV/QNAP, not Proxmox.
- Roadmap: CVE tracking shipped and is gone from "Planned"; a "Next release"
  group lists what is on main but unreleased (CrowdSec across sites, Windows
  driver, single-use console tickets, reboots refused instead of faked).
- NIS2: Art. 21 (2e) now describes the vulnerability handling that exists,
  (2i) adds attributable terminal sessions; CVE tracking left "coming".
- Persona pages: two new items each, counts updated. Glossary: Kea, WinRM,
  LAPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:33:02 +02:00
Christian ManivongandClaude Opus 5.5 04c5c00280 fix: correct the driver matrix against netOrk v0.28.0
Audited the drivers that actually ship in v0.28.0 (vendor-drivers.txt at the
tag, each driver package at its pin) instead of carrying the old table on.

- Reboot: netOrk's reboot really restarts only OpenWrt and Proxmox. For every
  other driver the request reported success while nothing happened, so the
  column now says so. Rebooting through an update run is mentioned in a note.
- Config push: OPNsense, ProCurve, TP-Link JetStream, Netgear Smart and Proxmox
  do get configuration written by netOrk; the column was missing them.
- Zyxel is a VMG residential gateway, not a switch: no LLDP, VLANs or health,
  but SSIDs. Fritz!Box is read-only and has no health metrics. Proxmox has no
  Docker view.
- netgear is two drivers, netgear_smart and netgear_plus. New: hpe_officeconnect,
  qnap_qts, yealink.
- The built-in NAPALM drivers are installed but untested with netOrk and get
  none of its driver-specific features; the page no longer says "supported".
- Notes explain the Health, LLDP, Config push and Reboot columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:28:16 +02:00
christianmanivongandClaude Opus 5 8aa158aa3b docs: Infrastrukturänderungen gehören ins Infrastruktur-Repo — und vorher
CI / TypeScript — type-check (push) Successful in 16s
CI / Publish — build & push image (push) Successful in 22s
Diese Anwendung teilt sich Netz, Datenbankcluster, öffentlichen Eingang und
Backup mit anderen Projekten. Wer dort etwas ändert, ändert es für alle, und
dem eigenen Repo sieht man es nicht an.

Die Regel steht vollständig in christianmanivong/infrastructure; hier nur der
Verweis, damit sie dort gelesen wird, wo gearbeitet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:27:57 +02:00
christianmanivong 451a98c14a Merge pull request 'feat(ci): publish to Gitea's registry, and stop deploying to a random host' (#1) from feature/gitea-registry into main
CI / TypeScript — type-check (push) Successful in 9s
CI / Publish — build & push image (push) Successful in 12s
2026-09-08 21:51:49 +00:00
Christian ManivongandClaude Opus 5 c40fa97cd1 feat(ci): publish to Gitea's registry, and stop deploying to a random host
CI / TypeScript — type-check (pull_request) Successful in 10s
CI / Publish — build & push image (pull_request) Skipped
CI / TypeScript — type-check (push) Successful in 9m48s
CI / Publish — build & push image (push) Skipped
The image moves off registry.netork.io. That registry 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. Verified -- a customer server's credentials list the whole catalogue.
It keeps the images those instances are meant to pull; the marketing site is not
one of them. Gitea scopes packages to their owning account, and no customer has
one. netOrk #172.

Login uses a REGISTRY_TOKEN secret (a Gitea token with write:package). The token
Actions injects per run does not work here -- the package registry rejects it
with a bare "unauthorized", which is a confusing way to spend an afternoon.

The deploy job is removed rather than migrated, because it had quietly stopped
being correct. 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, plus the original
netork-runner -- and none of them is on 10.7.224.11, where this site runs and
where the proxy-net it attaches to lives. The next push would have started a
second website container on the wrong host and reported success while netork.io
went on serving the old one. Nothing had failed yet; the last deploy was
2026-07-17, back when the pool was one runner.

scripts/deploy.sh replaces it: it names the target, pulls before it removes
anything, compares the running container's image id against what was pulled, and
finishes by checking that netork.io actually answers 200.

Push-to-deploy can come back by registering a runner on .11 with a label of its
own and pinning `runs-on:` to it, or by giving CI an ssh key. Both decide where a
credential lives, so neither was decided here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:41:10 +02:00
Christian ManivongandClaude Sonnet 5 2d85aa2e47 feat: add persona landing pages for IT department, IT support, and MSPs
CI / TypeScript — type-check (push) Successful in 9s
CI / Publish — build & push image (push) Successful in 9s
CI / Deploy — pull & restart on host (push) Successful in 2s
Adds three "who it's for" pages reachable via a new nav dropdown, each
built around a distinct day-to-day workflow rather than a reworded
homepage pitch:

- /for/it-department — the core admin/engineer audience: drift-fix,
  Ansible automation, VM provisioning, dashboards
- /for/it-support — day-to-day operators: status at a glance, one-click
  Ack, Wake-on-LAN, scheduled reboots, filterable audit log
- /for/msp — managed service providers: Satellite deployments for
  unreachable client sites, audit trail as client-facing evidence,
  self-hosted with no per-seat SaaS

The MSP page deliberately avoids claiming per-site/per-customer RBAC —
verified against netork/models/role.py that permissions are global
role-based sets, not site-scoped, and phrased the copy accordingly.

Nav.tsx's Docs dropdown logic is extracted into a reusable NavDropdown
component (open state, outside-click, route-change-close) instead of
being duplicated for the new "Für wen" dropdown, which also links to
the existing /nis2 page rather than rebuilding that persona.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 08:54:54 +02:00
Christian ManivongandClaude Sonnet 5 d57734767d feat: reflect netOrk v0.10.0–v0.11.0 release notes
CI / TypeScript — type-check (push) Successful in 20s
CI / Publish — build & push image (push) Successful in 13s
CI / Deploy — pull & restart on host (push) Successful in 2s
Corrects a claim from the previous update: Satellite Phase 3 shipped, so
discovery scans now run through satellite-covered sites too (only SNMP
health-metric polling and WebSSH remain Central-only).

New capabilities added to the feature list: per-device availability
windows (suppress false OFFLINE warnings during expected downtime),
per-SSID MAC access-control lists with a dedicated Wireless ACL tab, a
new RADIUS Management section (global FreeRADIUS server/NAS/user
management), and three OPNsense monitoring additions (BGP neighbors, TLS
certificate/Trust-store monitoring, DDNS-down warning). Also notes that
netOrk's own config pushes are now auto-recognized so they're never
mistaken for an unauthorized change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 07:51:10 +02:00
Christian ManivongandClaude Sonnet 5 d8fd9fe675 feat: reflect netOrk v0.5.0–v0.9.0 release notes across the site
Dashboards (configurable/shareable, 13 widgets, WYSIWYG grid editor) and
the EOL Tracking plugin ship in v0.5.x, so both move from roadmap to
shipped: Features, Plugins, NIS2 mapping, and a homepage screenshot row.

v0.6.0–v0.9.0 add three more major capabilities, verified against code
rather than the (partly stale) TODO.md: VM Provisioning (Cloud-Init VMs
from a hypervisor's VMs tab), Ansible-based configuration automation (11
built-in roles, VM-provisioning integration), and Satellite deployments
(a remote polling agent for sites Central can't reach directly, with its
current limitations noted honestly). Wake-on-LAN and the audit log
CSV/PDF export (previously a roadmap item) round out the update.

Roadmap and the NIS2 coverage page are reconciled to match: shipped items
removed from "planned"/"coming", Art. 21 (2d) and (2h) text updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 10:22:16 +02:00
Christian ManivongandClaude Sonnet 5 d8892c53ce fix: glossary tooltip layout and viewport overflow
CI / TypeScript — type-check (push) Successful in 9s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
linkify() was returning a bare array, so its fragments landed as
direct children of flex <li> bullets — each text/link piece became
its own flex item and scrambled the reading order on wrap. Now
wrapped in a single <span> so text flows normally.

Tooltips also always centered under the term regardless of position,
overflowing off-screen near the viewport edges. GlossaryMark now
measures the anchor on hover and flips to a left- or right-aligned
placement when centering would push it out of view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:46:54 +02:00
Christian ManivongandClaude Sonnet 5 38c7fb1db9 feat: add site-wide glossary with hover tooltips
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
Every abbreviation and technical term used in the site's copy (RBAC,
NAPALM, config drift, ...) now links to a new /glossary page and shows
a short definition on hover, wherever it appears in body text. Product
and vendor brand names are deliberately excluded — the glossary stays
a dictionary of vocabulary, not a company directory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:37:35 +02:00
Christian ManivongandClaude Sonnet 5 2a8a87bdd1 feat: reflect netOrk v0.4.1–v0.4.4 release notes and reconcile roadmap
CI / TypeScript — type-check (push) Failing after 10s
CI / Publish — build & push image (push) Has been skipped
CI / Deploy — pull & restart on host (push) Has been skipped
- Document the Web-SSH browser console and end-to-end RBAC enforcement
  (frontend gating added in v0.4.3), both previously missing from the
  feature list.
- Sync the roadmap with netOrk's docs/TODO.md: add Vault integration
  and the firewall-profile rework (top engineering priorities) and
  VLAN visualization.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 15:16:59 +02:00
Christian ManivongandClaude Sonnet 4.6 b37703bbbb feat: build out the /plugins page
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
The nav and footer have linked to /plugins since the start, but no
route or page existed, so it rendered blank. Adds the page per the
docs/PAGES.md spec — plugin building blocks, the four built-in
plugins, a step-by-step "writing a plugin" guide with real code from
netork/plugins/, and the fire/call/transform hook bus — sourced from
the actual plugin system in the main NetOrk repo for accuracy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:37:56 +02:00
Christian ManivongandClaude Sonnet 4.6 2f8cbf48af feat: reflect netOrk v0.4.0 features (MFA, config backup, ack warnings)
CI / TypeScript — type-check (push) Successful in 9s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
Moves MFA/TOTP and config backup & versioning from roadmap to shipped
across the NIS2 coverage page, features list, and roadmap, and adds a
homepage screenshot row for the new config snapshot/diff/restore UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:23:37 +02:00
Christian Manivong 77a73030b4 fix: openmediavault inherits LLDP from LinuxDriver
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 17:19:00 +02:00
Christian Manivong c72bfcf0f1 fix: correct capability matrix based on code audit
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
- fritzbox: health ✓ (inherited from ResidentialGatewayDriver)
- netgear: health ✓ (own get_health_metrics implementation)
- zyxel: health ✓ (inherited from ResidentialGatewayDriver), reboot ✓ (def reboot())
- sonos: interfaces ✓ (get_interfaces() implemented), lldp stays — (stub returning {})
2026-06-28 17:14:56 +02:00
Christian Manivong 382407d51f fix: mark VLAN as supported for proxmox driver (OVS+SDN and LinuxBridge)
CI / TypeScript — type-check (push) Successful in 7s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 1s
2026-06-28 17:10:13 +02:00
Christian Manivong 664f6ae4d0 fix: mark Health as supported for tplink_jetstream driver
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 17:09:35 +02:00
Christian Manivong 5541fb7cd9 fix: mark LLDP as supported for proxmox driver
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 17:09:08 +02:00
Christian Manivong 51bb2f9319 fix: mark LLDP as supported for openwrt driver
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 17:08:20 +02:00
Christian ManivongandClaude Sonnet 4.6 d48c7143aa feat: bilingual website (DE/EN) with language switcher
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Successful in 2s
Add LangContext with localStorage persistence (default: DE).
DE/EN toggle in Nav (top-right, before CTA button).

All pages translated:
- Home: all 8 sections incl. NIS2 block
- Features: all 13 sections with full German bullet points
- Drivers: headings and descriptions
- Getting Started (coming soon)
- Roadmap: bilingual data arrays inline (Planned + Under consideration)
- NIS2: full Art. 21 mapping, evidence blocks, coming-soon list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 17:00:22 +02:00
Christian ManivongandClaude Sonnet 4.6 f194c379e4 feat: add NIS2 landing page, roadmap page, and compliance section
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
- New /nis2 page: full Art. 21 mapping with covered/partial/roadmap/n-a
  tags, evidence-by-trigger breakdown, and roadmap callout
- New /roadmap page: planned and under-consideration items, NIS2-tagged
  items highlighted with monospace badge
- Home: new NIS2 section between screenshots and plugin block — Art. 21
  mapping list + MockCompliance UI + link to /nis2
- Features: new "Compliance & Audit (NIS2)" section
- Nav: Roadmap link added; NIS2 in Docs dropdown
- Footer: Roadmap and NIS2 links added
- docs/PRODUCT.md: NIS2 evidence foundation as value proposition #9
- docs/PAGES.md: /nis2, /roadmap, and NIS2 home section documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 11:35:37 +02:00
Christian Manivong 0f6ec6241f fix: sort driver lists alphabetically
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 11:05:17 +02:00
Christian Manivong fa6f55570d fix: replace Add device button with Discovery in mock UI
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 9s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 11:00:39 +02:00
Christian ManivongandClaude Sonnet 4.6 78ff06d15b feat: replace getting-started with coming soon page
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
Deployment path not yet available; commercial plans in preparation.
Replace step-by-step install guide with a coming-soon page and
mailto CTA. Update hero + footer CTA on landing page accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 10:58:47 +02:00
Christian Manivong 403551a50d ci: move deploy to ubuntu-latest — fixes needs ordering with build runner
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Successful in 2s
2026-06-28 10:47:48 +02:00
Christian Manivong 6a92cacde1 ci: simplify deploy — direct docker run via build runner, no SSH
CI / TypeScript — type-check (push) Successful in 7s
CI / Publish — build & push image (push) Successful in 8s
CI / Deploy — pull & restart on host (push) Failing after 0s
2026-06-28 10:40:51 +02:00
Christian Manivong 7ac530a691 ci: trigger — set runner to instance-wide scope
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 3s
CI / Deploy — pull & restart on host (push) Failing after 1s
2026-06-28 10:33:46 +02:00
Christian Manivong 0c9501fea1 ci: trigger build
CI / TypeScript — type-check (push) Successful in 7s
CI / Publish — build & push image (push) Successful in 4s
CI / Deploy — pull & restart on host (push) Failing after 0s
2026-06-28 10:30:55 +02:00
Christian ManivongandClaude Sonnet 4.6 3ddf3b78b4 ci: add Gitea Actions workflow + fix docker-compose for registry pull
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 7s
CI / Deploy — pull & restart on host (push) Failing after 1s
Workflow (3 jobs, all on the local netork-runner on 10.7.224.11):

  typecheck (ubuntu-latest)
    → npm ci + tsc --noEmit on every push/PR

  publish (ubuntu-latest, main only)
    → docker build + push to registry.netork.io/netork/website:latest
      and registry.netork.io/netork/website:main-<sha>

  deploy (build/host, after publish)
    → runs directly on the host (no SSH needed)
    → copies docker-compose.yml to /opt/netork-website/
    → docker compose pull + up -d --remove-orphans

docker-compose.yml: replaced local build: . with
registry.netork.io/netork/website:latest so the deploy job
pulls the just-published image instead of building again.

Required Gitea secrets: REGISTRY_USER, REGISTRY_PASSWORD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 10:23:31 +02:00
Christian ManivongandClaude Sonnet 4.6 efa01b5f41 feat: initial website implementation
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 11s
CI / Deploy — pull & restart on host (push) Failing after 3s
React 18 + TypeScript + Vite + Tailwind CSS website for netOrk.
Built by a parallel instance based on the product docs.

Pages: Home (landing), Features, Drivers, Getting Started
Components: Nav, Footer
Assets: public/logo.png (1024x1024 RGBA)
Docker: multi-stage Dockerfile (node:20 builder + nginx:alpine)
Compose: netork-website service on proxy-net (Zoraxy integration)

docs/DESIGN.md: Logo Assets section added (PNG only, SVG removed);
wordmark pattern and <head> references documented.
.gitignore: fixed (was literal \n, now proper newlines); excludes
node_modules/, dist/, memory/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 10:19:50 +02:00
55 changed files with 8013 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.md
+91
View File
@@ -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.
+14 -1
View File
@@ -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
+21
View File
@@ -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/`:
+14
View File
@@ -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;"]
+15
View File
@@ -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
+45 -2
View File
@@ -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>
``` ```
+134 -31
View File
@@ -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`
+217 -20
View File
@@ -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.
+17
View File
@@ -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>
+27
View File
@@ -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";
}
}
+2771
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+25
View File
@@ -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.
+439
View File
@@ -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())
+89
View File
@@ -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
+62
View File
@@ -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; }
+217
View File
@@ -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()
+48
View File
@@ -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"),
]
+40
View File
@@ -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>
)
}
+56
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+207
View File
@@ -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>
)
}
+37
View File
@@ -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
}
+612
View File
@@ -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'],
},
]
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-950 text-slate-100;
}
+40
View File
@@ -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>
)
}
+13
View File
@@ -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>,
)
+132
View File
@@ -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>
)
}
+40
View File
@@ -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>
)
}
+124
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+56
View File
@@ -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>
)
}
+271
View File
@@ -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>
</>
)
}
+233
View File
@@ -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>
)
}
+250
View File
@@ -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>
)
}
+222
View File
@@ -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>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
+21
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})