Website: netOrk v0.28.0 and real screenshots #2

Merged
christianmanivong merged 5 commits from feature/v0.28-real-screenshots into main 2026-09-26 08:58:00 +00:00
29 changed files with 1358 additions and 451 deletions
+4
View File
@@ -8,3 +8,7 @@ memory/
# Deploy target + registry token
deploy.env
# Screenshot tooling: bytecode, and database dumps that hold production data
__pycache__/
*.dump
+8 -2
View File
@@ -195,10 +195,16 @@ against the dark background:
```tsx
<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>
```
**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:
```tsx
@@ -206,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-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">netork.local</span>
<span className="ml-4 text-xs text-slate-500 font-mono">netork / devices</span>
</div>
```
+14 -38
View File
@@ -124,45 +124,21 @@ Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
### 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**
- Heading: `Device detail at a glance`
- Copy: `Hostname, IP, vendor, OS version, last poll time, and active
warnings on one card. Tabbed detail view for interfaces, LLDP neighbors,
ARP table, VLAN membership, packages, services, and scheduled jobs.`
- Screenshot: DeviceDetailPage
| Row | Heading | Screenshot |
|---|---|---|
| 1 | Device detail at a glance | `device-detail` — an access point, Networking → Interfaces |
| 2 | Intent-based VLAN and SSID management | `vlans` — VLAN list by site |
| 3 | A security assessment for every device | `device-security` — a server, Security → Assessment |
| 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**
- Heading: `Intent-based VLAN and SSID management`
- 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
— all linked to the device record. One-click agent install if the
agent is missing. Graylog syslog forwarding status with auto-fix.`
- Screenshot: SecurityTab inside DeviceDetailPage
**Row 4 — Right text, left screenshot**
- Heading: `Configuration backup and versioning`
- Copy: `Every poll captures a config snapshot into a local Git
repository. The Config tab shows the full snapshot history, a
side-by-side diff between any two points in time, and — for
OPNsense — a Restore button. Unauthorized changes show up as a
device warning.`
- Screenshot: ConfigTab inside DeviceDetailPage
**Row 5 — Left text, right screenshot**
- Heading: `Dashboards you actually build`
- Copy: `Pick from 13 widgets and arrange them on a WYSIWYG grid — no
more fixed layout. Share a dashboard with a colleague, let them
subscribe to your live version or clone it into their own, and pin
favorites to the main menu.`
- Screenshot: DashboardDetailPage (edit mode)
Text sits left on odd rows and right on even rows; on mobile the text always
comes first.
---
@@ -170,7 +146,7 @@ Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
**Layout:** Left column — label + Art. 21 mapping list. Right column — mock compliance overview UI.
**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)
+80 -15
View File
@@ -95,12 +95,22 @@ hardware and want operational visibility beyond what consumer dashboards offer.
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
- Site assignment with FK to structured Site records
- 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
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
- Device fingerprinting: vendor + platform confidence scoring
- FQDN resolution (reverse DNS)
- 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
@@ -124,23 +134,32 @@ Custom NAPALM drivers for all of the following:
| Driver | Device type |
|---|---|
| `openwrt` | OpenWRT access points |
| `opnsense` | OPNsense firewalls |
| `proxmox` | Proxmox VE hypervisors |
| `fritzbox` | AVM Fritz!Box routers (read-only) |
| `hpe_officeconnect` | HPE OfficeConnect 1820 / 1920S switches |
| `linux` | Generic Linux servers |
| `procurve` | HP ProCurve / Aruba switches |
| `tplink_jetstream` | TP-Link Jetstream managed switches |
| `netgear` | Netgear switches |
| `fritzbox` | AVM Fritz!Box routers |
| `zyxel` | Zyxel switches |
| `netgear_plus` | Netgear Plus switches (web UI) |
| `netgear_smart` | Netgear Smart Managed Pro switches |
| `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 |
| `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
- 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
- Subnet browser with interface-to-subnet assignments
- VLAN list grouped by site; per-VLAN device membership view
@@ -170,8 +189,10 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
### Configuration Automation (Ansible)
- Reusable Ansible roles and playbooks stored and edited directly in
netOrk — no separate git checkout
- 11 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
portainer, watchtower, uptime-kuma, vaultwarden, wireguard, fail2ban
- 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
@@ -215,7 +236,7 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
- 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 (Proxmox/Linux)
- Docker container and image status (Linux, OpenMediaVault, QNAP)
- Service status and start/stop/restart (systemd)
- VM/container list with OS device cross-linking (Proxmox)
- Per-device availability windows — suppress OFFLINE status and poll-failure
@@ -225,21 +246,65 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
- 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
- 13 widget types: stats, device warnings, recently updated devices, network
- 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
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)
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
recent alert history, CIS benchmark scores, one-click agent install fix stream
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
+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"),
]
+2 -1
View File
@@ -48,10 +48,11 @@ export default function GlossaryMark({ id, children }: { id: string; children: R
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 opacity-0 shadow-2xl transition-opacity group-hover:opacity-100
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>
+48 -3
View File
@@ -1,12 +1,16 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
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,
@@ -57,6 +61,11 @@ function NavDropdown({
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 ${
@@ -71,7 +80,7 @@ export default function Nav() {
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-6 h-14 flex items-center gap-8">
<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">
@@ -79,6 +88,7 @@ export default function Nav() {
</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
<Link to="/features" className={linkCls('/features')}>
{t.nav.features}
</Link>
@@ -126,17 +136,52 @@ export default function Nav() {
<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="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"
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>
)
}
+33
View File
@@ -286,6 +286,39 @@ export const GLOSSARY: GlossaryEntry[] = [
},
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',
+240 -50
View File
@@ -60,7 +60,7 @@ const en = {
body: 'Define desired state in netOrk. On every poll, device config is compared against it. Drifted devices get a warning; a one-click fix stream applies the correction and shows you live SSH output.',
},
driversHeading: 'Works with your hardware',
driversSub: 'netOrk ships with custom NAPALM drivers for 11 device types, plus all built-in NAPALM drivers. New drivers follow a documented registration pattern.',
driversSub: 'netOrk ships with custom NAPALM drivers for 15 device types, plus the built-in NAPALM drivers. New drivers follow a documented registration pattern.',
driversLink: 'Full driver reference →',
screenshot1: {
heading: 'Device detail at a glance',
@@ -68,26 +68,40 @@ const en = {
},
screenshot2: {
heading: 'Intent-based VLAN and SSID management',
body: "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.",
body: "Define VLAN names and SSID settings once. netOrk compares them against every polled device, shows each difference, and corrects it via UCI (OpenWrt) or the device's native API — automatically only where you switched that on.",
},
screenshot3: {
heading: 'Security visibility per device',
body: 'Wazuh agent status, CVE counts by severity, and recent alerts — all linked to the device record. One-click agent install if the agent is missing. Graylog syslog forwarding status with auto-fix.',
heading: 'A security assessment for every device',
body: 'TLS and SSH graded A to F, installed software and container images matched against known vulnerabilities, hardening benchmarks and network exposure — rated by what each finding means on this device, with no agent to install.',
},
screenshot4: {
heading: 'Configuration backup and versioning',
body: 'Every poll captures a config snapshot into a local Git repository. The Config tab shows the full snapshot history, a side-by-side diff between any two points in time, and — for OPNsense — a Restore button. Unauthorized changes show up as a device warning.',
heading: 'One triage queue, decisions that hold',
body: 'Every vulnerability across every device in one list, most urgent first, each row saying why. Mark it not applicable, accept or defer it until a date, or fixed — always with a reason. Deferrals come back on their own, and fixes are verified by the next assessments.',
},
screenshot5: {
heading: 'Dashboards you actually build',
body: 'Pick from 13 widgets and arrange them on a WYSIWYG grid — no more fixed layout. Share a dashboard with a colleague, let them subscribe to your live version or clone it into their own, and pin favorites to the main menu.',
body: 'Pick from 18 widgets and arrange them on a WYSIWYG grid — no more fixed layout. Share a dashboard with a colleague, let them subscribe to your live version or clone it into their own, and pin favorites to the main menu.',
},
screenshot6: {
heading: '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, with nothing to set up. A check reports after three failures in a row, and a whole site going down is one message, not fifty.',
},
shotAlt: {
devices: 'netOrk device inventory with hostnames, vendors, device types, sites and status',
deviceDetail: 'Interfaces of an access point in netOrk, with VLANs, neighbors and link status',
vlans: 'VLAN list in netOrk, grouped by site with device counts',
deviceSecurity: 'Security tab of a server in netOrk: warnings, configuration grade and vulnerability counts',
vulnerabilities: 'Vulnerability triage queue in netOrk with severity, reason and affected devices',
dashboard: 'netOrk dashboard with device statistics, warnings, certificate expiry and scheduled actions',
serviceChecks: 'Service checks in netOrk: DNS, gateways and other checks with state and latency',
auditLog: 'netOrk audit log with user actions and background task results, exportable as CSV and PDF',
},
nis2Label: 'NIS2 · Art. 21',
nis2Heading: 'Evidence, not paperwork.',
nis2Body: "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.",
nis2Link: 'Full Art. 21 mapping →',
nis2Items: [
{ art: 'Art. 21 (2e)', label: 'Patch & vulnerability management', detail: 'Per-device update status, Wazuh CVE counts by severity' },
{ art: 'Art. 21 (2e)', label: 'Patch & vulnerability management', detail: 'Per-device update status, vulnerability triage with reasoned, audited decisions' },
{ art: 'Art. 21 (2h)', label: 'Asset management & access control', detail: 'Full device inventory, RBAC with four roles, complete audit log' },
{ art: 'Art. 21 (2a)', label: 'Risk analysis baseline', detail: 'Config drift detection, SNMP health metrics, security agent coverage' },
{ art: 'Art. 21 (2b)', label: 'Incident detection', detail: 'Wazuh alert history, CrowdSec decisions, Graylog syslog per device' },
@@ -96,7 +110,7 @@ const en = {
pluginsBody: 'Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL Tracking) are plugins that register into the plugin system — they can be enabled or disabled per deployment without code changes. Adding a new integration follows a documented pattern with a hook bus, typed metadata, and a plugin registry.',
pluginsLink: 'Plugin system docs →',
deployHeading: 'Self-hosted. One command.',
deployBody: 'netOrk runs in Docker Compose. Five containers: API, two worker pools, a Beat scheduler, and an nginx UI server. No external dependencies beyond Redis and PostgreSQL.',
deployBody: 'netOrk runs in Docker Compose: the API, three worker pools, a Beat scheduler and an nginx UI server, alongside Flower, a local image registry, an APT cache and a Signal gateway for notifications. Redis and PostgreSQL are all it needs besides — bundled, or your own database.',
ctaHeading: 'Interested?',
ctaBody: 'Deployment options and hosted plans are coming. Get in touch for early access.',
ctaButton: 'Get in touch →',
@@ -109,12 +123,17 @@ const en = {
title: 'Device Management',
items: [
'CRUD for devices with credential profiles and SSH key management',
'Interactive Web-SSH console — full terminal session to any device straight from the browser, no separate SSH client needed',
'Web SSH terminal to any device straight from the browser — sessions log in with each user\'s own SSH key, never with the device\'s shared account; opened and refused sessions are recorded',
'SSH sessions are windows, not dialogs: move and resize them, run several at once, park them in the session bar or dock one beside the page — they survive navigating away',
'Per-device poll intervals (minutes) or manual-only',
'Status tracking: planned / staged / active / decommissioning / offline / disabled',
'Vendor / model / OS auto-populated from NAPALM get_facts()',
'Site assignment with FK to structured Site records',
'AP Profile assignment for grouped OpenWRT config',
'A device can hold several roles at once — a NAS that also runs VMs is storage and hypervisor, and shows the tabs for both',
'One device per address per site: adding a duplicate is refused with the name of the device that already holds the address',
'Devices and sites carry a business criticality that feeds into vulnerability ranking',
'Device pages list their sections beside the facts; pin a networking section as a second panel, jump anywhere with ⌘J',
],
},
{
@@ -124,6 +143,9 @@ const en = {
'Device fingerprinting: vendor + platform confidence scoring',
'FQDN resolution (reverse DNS)',
'Manual adoption from scan results — no auto-create to avoid inventory noise',
'Discovery jobs in a sortable, filterable table, grouped per site with the satellite that serves it',
'LAN Scan: ping sweep from netOrk, every site satellite and every firewall at once; results appear live with MAC address and manufacturer, and a finished scan becomes a discovery job in one step',
'A scan suggests the vantage point that can actually see the network you picked, and says which one could have when a source comes back empty',
],
},
{
@@ -142,25 +164,30 @@ const en = {
{
title: 'Supported Drivers',
items: [
'fritzbox — AVM Fritz!Box routers',
'procurve — HP ProCurve / Aruba switches',
'fritzbox — AVM Fritz!Box routers (read-only)',
'hpe_officeconnect — HPE OfficeConnect 1820 / 1920S switches',
'linux — Generic Linux servers',
'netgear — Netgear switches',
'netgear_plus — Netgear Plus switches (web UI)',
'netgear_smart — Netgear Smart Managed Pro switches',
'openmediavault — OpenMediaVault NAS',
'openwrt — OpenWRT access points',
'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',
'tplink_jetstream — TP-Link Jetstream managed switches',
'zyxel — Zyxel switches',
'Plus all built-in NAPALM drivers: Cisco IOS / IOS-XE / NX-OS, Arista EOS, Juniper JunOS',
'tplink_jetstream — TP-Link JetStream managed switches',
'yealink — Yealink IP phones',
'zyxel — Zyxel VMG routers',
'A device can hold several roles at once, e.g. a QNAP is storage, hypervisor and Linux host',
'Built-in NAPALM drivers (Cisco IOS / IOS-XE / NX-OS, Arista EOS, Juniper JunOS) are installed, but not tested with netOrk',
],
},
{
title: 'Networking & Inventory',
items: [
'Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU',
'LLDP neighbor discovery and topology graph',
'LLDP neighbor discovery and topology graph — links worked out from switch MAC tables are drawn too (dashed), so devices that do not speak LLDP are connected as well',
'ARP table and DHCP lease browser per device',
'Subnet browser with interface-to-subnet assignments',
'VLAN list grouped by site; per-VLAN device membership view',
@@ -168,6 +195,27 @@ const en = {
'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',
'Radio problems between the access points of a site — several APs crowding the same spectrum — are reported, which no single AP\'s configuration could reveal',
],
},
{
title: 'DHCP',
items: [
'DHCP reservations managed in netOrk: import what the firewall already has in one step, see the diff, then apply',
'Checked before writing: no address claimed twice, no device listed twice, every address inside the subnet it is listed under',
'DHCP subnets (Kea on OPNsense) with their options and search domains, picked up by the regular poll',
'Settings that quietly break a network are refused up front — a search domain next to a public resolver, a bare "local" entry, a missing gateway',
'Applying only adds and updates; anything configured by hand on the firewall is left alone',
],
},
{
title: 'Managed Services',
items: [
'Every container-based service across all devices, with its endpoints, TLS certificates and access rules',
'Compose editor per service: secrets are masked, saving takes a backup snapshot, redeploying is a separate confirmed step',
'Zoraxy reverse proxy: vhost endpoints listed and editable, written straight back to the proxy',
'PostgreSQL containers list their databases with owner, size and encoding',
'Containers still running a superseded image are flagged; "Fix now" recreates them',
],
},
{
@@ -175,25 +223,29 @@ const en = {
items: [
'Config drift detection: desired state (DB) vs device state (poll snapshot)',
'One-click drift fix stream with live SSH output in the browser',
'Fix every drifted access point in one go: sites in parallel, the devices of a site one after another, so a change never takes a whole location down',
'netOrk writes to a device only where you asked it to — with automatic drift correction off for a profile, nothing is changed, including inside a scheduled fix window',
'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',
'Configuration backup & versioning: every poll snapshots config into a local Git repo, with full history and a side-by-side diff viewer',
'One-click config restore for OPNsense from any prior snapshot',
'Unauthorized 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',
'Firewall profiles scoped per site and compared against a live OPNsense device (Diff tab); applying writes the changes step by step and never deletes anything automatically',
],
},
{
title: 'Configuration Automation (Ansible)',
items: [
'Reusable Ansible roles and playbooks stored and edited directly in netOrk — no separate git checkout',
'11 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy, portainer, watchtower, uptime-kuma, vaultwarden, wireguard, fail2ban',
'16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy, portainer, watchtower, uptime-kuma, vaultwarden, stalwart (mail), bulwark (webmail), searxng, postiz, listmonk, wireguard, fail2ban',
'Each role states what it needs from a machine; a host that is too small is refused with the reason, before anything is built',
'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',
'Run history per device, snapshotting the exact role/playbook content that was executed — each run\'s full log can be opened',
'Wired into VM provisioning: assign roles at VM-creation time and they run automatically after boot',
],
},
@@ -224,13 +276,26 @@ const en = {
'SNMP health metrics (CPU, memory, interface counters) via get_health_metrics()',
'Per-device warning system with severity levels (error / warning / info)',
'One-click Ack on any warning — clears it immediately and logs the action; config-change warnings accept the current state as the new baseline',
'Docker container and image status (Proxmox/Linux)',
'Docker container and image status (Linux, OpenMediaVault, QNAP)',
'Service status and start/stop/restart (systemd)',
'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, time servers (including ones that answer but lost sync), VPN tunnels, core daemons and gateways — derived from what netOrk already knows, nothing to set up',
'A check reports after three failures in a row; checks can run from a site satellite, including a DHCP check from inside the local network',
'Site reachability: when the tunnel to a site is down, polling there pauses, one warning names the tunnel, and every device is polled again the moment it returns',
],
},
{
title: 'Notifications',
items: [
'Signal messages for everything netOrk watches — failed devices, unreachable sites, expiring certificates, dead tunnels, failed automation runs',
'Each person registers their own number; administrators connect netOrk to Signal once with a QR code',
'Kept quiet on purpose: one message per site outage instead of one per device, a daily summary for recurring items, bundling beyond an hourly limit',
'Quiet hours per number with emergencies still getting through; mute any kind of message for two hours or for good',
'A history of every notification, including the ones netOrk chose not to send and why',
],
},
{
@@ -238,12 +303,35 @@ const en = {
items: [
'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',
'13 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',
'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, e.g. two warnings widgets scoped to different sites',
'Share a dashboard with specific users; recipients can subscribe to always see 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',
],
},
{
title: 'Security Assessment',
items: [
'Every device has a Security tab: TLS and SSH configuration graded A to F, installed software matched against known vulnerabilities, configuration checked against hardening benchmarks — nothing to install on the device',
'Container images are inventoried and matched against the same data, so a long-running container is no blind spot',
'Ratings reflect the device, not only the published score: lowered when a flaw needs local access, sits in a trusted network, or affects software that is not running or a kernel that is not booted; raised when it is exploited in the wild',
'A reboot comes with numbers: the vulnerabilities on the running kernel that booting the installed newer one would clear',
'Exposure: which less-trusted networks reach a device and on which ports, read from the firewall rules; what the internet sees of your public addresses and whether one is reported for abuse',
'Deeper scans on demand — a hardening audit of the host and a web scan of its management interface',
'Vulnerability data comes from the netOrk Knowledge Base and needs a licence; netOrk keeps a local copy and never tells it what you have installed',
],
},
{
title: 'Vulnerability Management',
items: [
'Triage queue across every device: one row per vulnerability, ordered by remediation deadline, known exploitation, severity, likelihood, asset criticality and spread — each row says why it is where it is',
'Filters for exploited, overdue and patch available; hardening findings without a CVE have their own list',
'Decisions: not applicable (with the standard reasons an auditor\'s tooling reads), accept the risk until a date, defer until a date, fixed — per device, site, device kind or everywhere, the most specific one wins',
'Every decision needs a reason in your own words; accepting a risk or declaring something not applicable needs a permission operators do not have',
'Deferred and accepted items come back on their date by themselves, sooner if the vulnerability becomes exploited; ignored ones are marked overdue',
'Fixes close themselves: a daily reassessment closes a fix that two assessments in a row no longer find, and reopens one that comes back',
],
},
{
title: 'Security Integrations',
items: [
@@ -298,7 +386,8 @@ const en = {
'RBAC with four built-in roles and custom permission sets — access control evidence',
'Per-device patch status and installed package list — patch management baseline (Art. 21 (2e))',
'EOL Tracking plugin flags devices on unsupported firmware/OS via endoflife.date — supply chain security baseline (Art. 21 (2d))',
'Wazuh CVE counts by severity (critical / high / medium) linked to each device record',
'Vulnerability handling with evidence: every triage decision carries a reason, a date and who made it, and is written to the audit log (Art. 21 (2e))',
'Terminal sessions are attributable to a person — each user logs in with their own key, and opened and refused sessions are recorded (Art. 21 (2i))',
'Git-backed configuration snapshot history with diff viewer and OPNsense restore — config-level backup & recovery evidence (Art. 21 (2c))',
'Config drift tracking: desired state vs. polled state — detect unauthorized changes',
'Security agent coverage report: which devices have Wazuh, Graylog, CrowdSec active',
@@ -320,10 +409,16 @@ const en = {
},
drivers: {
heading: 'Supported Devices',
sub: 'netOrk ships with custom NAPALM drivers for 11 device types and supports all built-in NAPALM drivers. Capability availability varies by driver.',
sub: 'netOrk ships with custom NAPALM drivers for 15 device types and can use the built-in NAPALM drivers. Capability availability varies by driver.',
customHeading: 'Custom Drivers',
napalmHeading: 'Built-in NAPALM Drivers',
napalmSub: 'All standard NAPALM drivers are supported. Capabilities depend on the upstream NAPALM implementation.',
napalmSub: 'The standard NAPALM drivers are installed and read facts, interfaces, LLDP and VLANs as far as the upstream implementation goes. They are not tested with netOrk and get none of its driver-specific features such as device roles, SNMP health metrics or configuration writes.',
notes: [
'Health: SNMP metrics (CPU, memory, interface counters); needs SNMP enabled on the device and a community configured in netOrk.',
'LLDP on Linux, OpenMediaVault, QNAP and OPNsense needs lldpd (os-lldpd) installed on the host.',
'Config push: netOrk writes configuration to the device, e.g. VLANs and ports on switches, UCI profiles on OpenWrt, DHCP, DNS and firewall rules on OPNsense, SDN VNets on Proxmox.',
'Reboot: a reboot from netOrk actually restarts OpenWrt and Proxmox devices. OPNsense and Linux hosts can reboot as part of applying updates.',
],
},
gettingStarted: {
label: 'coming soon',
@@ -474,7 +569,7 @@ const de: Translations = {
body: 'Sollzustand in netOrk definieren. Bei jeder Abfrage wird die Gerätekonfiguration damit verglichen. Abweichende Geräte erhalten eine Warnung; ein Ein-Klick-Fix-Stream wendet die Korrektur an und zeigt den SSH-Output live im Browser.',
},
driversHeading: 'Funktioniert mit deiner Hardware',
driversSub: 'netOrk liefert eigene NAPALM-Treiber für 11 Gerätetypen, plus alle integrierten NAPALM-Treiber. Neue Treiber folgen einem dokumentierten Registrierungsmuster.',
driversSub: 'netOrk liefert eigene NAPALM-Treiber für 15 Gerätetypen, plus die integrierten NAPALM-Treiber. Neue Treiber folgen einem dokumentierten Registrierungsmuster.',
driversLink: 'Vollständige Treiberreferenz →',
screenshot1: {
heading: 'Gerätedetails auf einen Blick',
@@ -482,26 +577,40 @@ const de: Translations = {
},
screenshot2: {
heading: 'Intent-basiertes VLAN- und SSID-Management',
body: 'VLAN-Namen und SSID-Einstellungen einmal definieren. netOrk vergleicht sie bei jeder Abfrage mit jedem Gerät und korrigiert Abweichungen automatisch via UCI (OpenWRT) oder der nativen Geräte-API.',
body: 'VLAN-Namen und SSID-Einstellungen einmal definieren. netOrk vergleicht sie bei jeder Abfrage mit jedem Gerät, zeigt jede Abweichung und korrigiert sie via UCI (OpenWrt) oder der nativen Geräte-API — automatisch nur dort, wo du das eingeschaltet hast.',
},
screenshot3: {
heading: 'Sicherheitssichtbarkeit pro Gerät',
body: 'Wazuh-Agent-Status, CVE-Anzahl nach Schweregrad und aktuelle Alerts — alle mit dem Gerätedatensatz verknüpft. Ein-Klick-Agent-Installation falls der Agent fehlt. Graylog-Syslog-Weiterleitungsstatus mit Auto-Fix.',
heading: 'Eine Sicherheitsbewertung für jedes Gerät',
body: 'TLS und SSH mit Note A bis F, installierte Software und Container-Images gegen bekannte Schwachstellen abgeglichen, Härtungs-Benchmarks und Netz-Exposition — bewertet danach, was ein Befund auf genau diesem Gerät bedeutet, ohne Agent.',
},
screenshot4: {
heading: 'Konfigurationsbackup und -versionierung',
body: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert. Der Config-Tab zeigt die vollständige Snapshot-Historie, einen Side-by-Side-Diff zwischen beliebigen Zeitpunkten und — für OPNsense — einen Restore-Button. Nicht autorisierte Änderungen erscheinen als Gerätewarnung.',
heading: 'Eine Triage-Queue, Entscheidungen, die halten',
body: 'Jede Schwachstelle über alle Geräte in einer Liste, das Dringendste zuerst, jede Zeile mit Begründung. Als nicht zutreffend markieren, bis zu einem Datum akzeptieren oder zurückstellen, oder als behoben — immer mit Begründung. Zurückgestelltes kommt von selbst zurück, Behebungen verifizieren die nächsten Bewertungen.',
},
screenshot5: {
heading: 'Dashboards, die du wirklich selbst baust',
body: 'Aus 13 Widgets wählen und auf einem WYSIWYG-Grid anordnen — kein festes Layout mehr. Ein Dashboard mit einem Kollegen teilen, der es abonnieren oder in eine eigene Kopie klonen kann, und Favoriten im Hauptmenü anpinnen.',
body: 'Aus 18 Widgets wählen und auf einem WYSIWYG-Grid anordnen — kein festes Layout mehr. Ein Dashboard mit einem Kollegen teilen, der es abonnieren oder in eine eigene Kopie klonen kann, und Favoriten im Hauptmenü anpinnen.',
},
screenshot6: {
heading: '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ß, ohne Einrichtung. Ein Check meldet sich nach drei Fehlschlägen in Folge, und ein ganzer Standort, der ausfällt, ist eine Nachricht statt fünfzig.',
},
shotAlt: {
devices: 'Geräteinventar in netOrk mit Hostnamen, Herstellern, Gerätetypen, Standorten und Status',
deviceDetail: 'Schnittstellen eines Access Points in netOrk, mit VLANs, Nachbarn und Link-Status',
vlans: 'VLAN-Liste in netOrk, nach Standort gruppiert mit Geräteanzahl',
deviceSecurity: 'Security-Tab eines Servers in netOrk: Warnungen, Konfigurationsnote und Schwachstellenanzahl',
vulnerabilities: 'Schwachstellen-Triage-Queue in netOrk mit Schweregrad, Begründung und betroffenen Geräten',
dashboard: 'netOrk-Dashboard mit Gerätestatistik, Warnungen, Zertifikatsablauf und geplanten Aktionen',
serviceChecks: 'Service-Checks in netOrk: DNS, Gateways und weitere Checks mit Zustand und Latenz',
auditLog: 'Audit-Log in netOrk mit Benutzeraktionen und Ergebnissen von Hintergrundaufgaben, exportierbar als CSV und PDF',
},
nis2Label: 'NIS2 · Art. 21',
nis2Heading: 'Nachweise, keine Papierwüste.',
nis2Body: 'NIS2 Art. 21 schreibt Geräteinventar, Patch-Management, Zugangskontrolle und Audit-Trails als technische Basismaßnahmen vor. netOrk fügt keine Compliance-Schicht auf — das sind seine normalen Tagesausgaben.',
nis2Link: 'Vollständiges Art. 21-Mapping →',
nis2Items: [
{ art: 'Art. 21 (2e)', label: 'Patch- & Schwachstellen-Management', detail: 'Update-Status pro Gerät, Wazuh-CVE-Anzahl nach Schweregrad' },
{ art: 'Art. 21 (2e)', label: 'Patch- & Schwachstellen-Management', detail: 'Update-Status pro Gerät, Schwachstellen-Triage mit begründeten, protokollierten Entscheidungen' },
{ art: 'Art. 21 (2h)', label: 'Asset-Management & Zugangskontrolle', detail: 'Vollständiges Geräteinventar, RBAC mit vier Rollen, vollständiges Audit-Log' },
{ art: 'Art. 21 (2a)', label: 'Risikoanalyse-Baseline', detail: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken, Security-Agent-Abdeckung' },
{ art: 'Art. 21 (2b)', label: 'Incident-Erkennung', detail: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen, Graylog-Syslog pro Gerät' },
@@ -510,7 +619,7 @@ const de: Translations = {
pluginsBody: 'Integrationen (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL-Tracking) sind Plugins, die im Plugin-System registriert werden — sie lassen sich pro Deployment ohne Code-Änderungen aktivieren oder deaktivieren. Eine neue Integration folgt einem dokumentierten Muster mit Hook-Bus, typisiertem Metadatum und Plugin-Registry.',
pluginsLink: 'Plugin-System-Dokumentation →',
deployHeading: 'Self-hosted. Ein Befehl.',
deployBody: 'netOrk läuft in Docker Compose. Fünf Container: API, zwei Worker-Pools, ein Beat-Scheduler und ein nginx-UI-Server. Keine externen Abhängigkeiten außer Redis und PostgreSQL.',
deployBody: 'netOrk läuft in Docker Compose: die API, drei Worker-Pools, ein Beat-Scheduler und ein nginx-UI-Server, daneben Flower, eine lokale Image-Registry, ein APT-Cache und ein Signal-Gateway für Benachrichtigungen. Sonst braucht es nur Redis und PostgreSQL — mitgeliefert oder die eigene Datenbank.',
ctaHeading: 'Interesse?',
ctaBody: 'Deployment-Optionen und gehostete Pläne folgen. Jetzt Kontakt aufnehmen für Early Access.',
ctaButton: 'Kontakt aufnehmen →',
@@ -523,12 +632,17 @@ const de: Translations = {
title: 'Geräteverwaltung',
items: [
'CRUD für Geräte mit Credential-Profilen und SSH-Schlüsselverwaltung',
'Interaktive Web-SSH-Konsole — vollständige Terminal-Sitzung zu jedem Gerät direkt im Browser, kein separater SSH-Client nötig',
'Web-SSH-Terminal zu jedem Gerät direkt im Browser — Sitzungen melden sich mit dem eigenen SSH-Schlüssel des Benutzers an, nie mit dem geteilten Gerätekonto; geöffnete und verweigerte Sitzungen werden protokolliert',
'SSH-Sitzungen sind Fenster statt Dialoge: verschieben, Größe ändern, mehrere parallel, in der Sitzungsleiste parken oder neben der Seite andocken — sie überstehen den Seitenwechsel',
'Konfigurierbare Poll-Intervalle (Minuten) oder nur manuell',
'Statusverfolgung: geplant / bereitgestellt / aktiv / außer Betrieb / offline / deaktiviert',
'Hersteller / Modell / OS automatisch befüllt über NAPALM get_facts()',
'Standortzuweisung über FK zu strukturierten Standortdatensätzen',
'AP-Profil-Zuweisung für gruppierte OpenWRT-Konfiguration',
'Ein Gerät kann mehrere Rollen zugleich haben — ein NAS, das auch VMs betreibt, ist Storage und Hypervisor und zeigt die Tabs für beides',
'Ein Gerät pro Adresse und Standort: ein Duplikat wird mit dem Namen des Geräts abgelehnt, das die Adresse bereits hat',
'Geräte und Standorte tragen eine geschäftliche Kritikalität, die in die Schwachstellen-Priorisierung einfließt',
'Geräteseiten listen ihre Bereiche neben den Fakten; einen Netzwerk-Bereich als zweites Panel anheften, mit ⌘J überallhin springen',
],
},
{
@@ -538,6 +652,9 @@ const de: Translations = {
'Geräte-Fingerprinting: Hersteller + Plattform mit Confidence-Score',
'FQDN-Auflösung (Reverse DNS)',
'Manuelle Übernahme aus Scan-Ergebnissen — kein Auto-Create, um Inventar-Rauschen zu vermeiden',
'Discovery-Jobs in einer sortier- und filterbaren Tabelle, gruppiert nach Standort mit dem zuständigen Satellite',
'LAN-Scan: Ping-Sweep gleichzeitig von netOrk, jedem Standort-Satellite und jeder Firewall; Ergebnisse erscheinen live mit MAC-Adresse und Hersteller, ein fertiger Scan wird mit einem Schritt zum Discovery-Job',
'Ein Scan schlägt den Standpunkt vor, der das gewählte Netz tatsächlich sieht, und nennt bei einer leeren Quelle den, der es hätte sehen können',
],
},
{
@@ -556,25 +673,30 @@ const de: Translations = {
{
title: 'Unterstützte Treiber',
items: [
'fritzbox — AVM Fritz!Box Router',
'procurve — HP ProCurve / Aruba Switches',
'fritzbox — AVM Fritz!Box Router (nur lesend)',
'hpe_officeconnect — HPE OfficeConnect 1820 / 1920S Switches',
'linux — Generische Linux-Server',
'netgear — Netgear Switches',
'netgear_plus — Netgear Plus Switches (Web-UI)',
'netgear_smart — Netgear Smart Managed Pro Switches',
'openmediavault — OpenMediaVault NAS',
'openwrt — OpenWRT Access Points',
'openwrt — OpenWrt Router und Access Points',
'opnsense — OPNsense Firewalls',
'procurve — HPE ProCurve / Aruba Switches',
'proxmox — Proxmox VE Hypervisoren',
'qnap_qts — QNAP NAS mit QTS',
'sonos — Sonos Lautsprecher',
'tplink_jetstream — TP-Link Jetstream Managed Switches',
'zyxel — Zyxel Switches',
'Sowie alle integrierten NAPALM-Treiber: Cisco IOS / IOS-XE / NX-OS, Arista EOS, Juniper JunOS',
'tplink_jetstream — TP-Link JetStream Managed Switches',
'yealink — Yealink IP-Telefone',
'zyxel — Zyxel VMG Router',
'Ein Gerät kann mehrere Rollen zugleich haben, z. B. ist ein QNAP Storage, Hypervisor und Linux-Host',
'Die integrierten NAPALM-Treiber (Cisco IOS / IOS-XE / NX-OS, Arista EOS, Juniper JunOS) sind installiert, aber nicht mit netOrk getestet',
],
},
{
title: 'Netzwerk & Inventar',
items: [
'Schnittstellen-Browser mit IPv4/IPv6-Adressen, MAC, Geschwindigkeit, MTU',
'LLDP-Nachbarn-Erkennung und Topologie-Graph',
'LLDP-Nachbarn-Erkennung und Topologie-Graph — aus Switch-MAC-Tabellen abgeleitete Verbindungen werden mitgezeichnet (gestrichelt), sodass auch Geräte ohne LLDP verbunden erscheinen',
'ARP-Tabelle und DHCP-Lease-Browser pro Gerät',
'Subnetz-Browser mit Schnittstellen-zu-Subnetz-Zuordnungen',
'VLAN-Liste gruppiert nach Standort; VLAN-Mitgliedsansicht pro Gerät',
@@ -582,6 +704,27 @@ const de: Translations = {
'MAC-Zugriffskontrolllisten pro SSID (Whitelist / Blacklist), gepusht auf jeden AP, der die SSID ausstrahlt — Schnell-Hinzufügen direkt aus der Liste der verbundenen Clients',
'MAC-ACL-Zustand ist ein vollwertiges Drift-Item — abgedeckt von derselben Drift-Erkennung, geplanten Auto-Fixes und Warnungsaggregation wie jeder andere Config-Drift',
'Eigener Access-Control-Lists-Tab auf der Wireless-Seite, listet jede SSID mit ihrem ACL-Editor inline',
'Funkprobleme zwischen den Access Points eines Standorts — mehrere APs im selben Spektrum — werden gemeldet; die Konfiguration eines einzelnen APs könnte das nie zeigen',
],
},
{
title: 'DHCP',
items: [
'DHCP-Reservierungen in netOrk verwalten: Vorhandenes von der Firewall in einem Schritt importieren, Diff ansehen, dann anwenden',
'Vor dem Schreiben geprüft: keine Adresse doppelt vergeben, kein Gerät doppelt, jede Adresse im Subnetz, unter dem sie steht',
'DHCP-Subnetze (Kea auf OPNsense) mit Optionen und Suchdomänen, vom regulären Poll übernommen',
'Einstellungen, die ein Netz still kaputtmachen, werden vorab abgelehnt — Suchdomäne neben öffentlichem Resolver, ein nackter „local"-Eintrag, fehlendes Gateway',
'Anwenden ergänzt und aktualisiert nur; von Hand auf der Firewall Eingerichtetes bleibt unberührt',
],
},
{
title: 'Managed Services',
items: [
'Jeder containerbasierte Dienst über alle Geräte, mit Endpunkten, TLS-Zertifikaten und Zugriffsregeln',
'Compose-Editor pro Dienst: Geheimnisse maskiert, Speichern legt einen Backup-Snapshot an, Redeploy ist ein separater, bestätigter Schritt',
'Zoraxy-Reverse-Proxy: vhost-Endpunkte gelistet und bearbeitbar, direkt in den Proxy zurückgeschrieben',
'PostgreSQL-Container listen ihre Datenbanken mit Eigentümer, Größe und Encoding',
'Container, die noch ein veraltetes Image ausführen, werden markiert; „Fix now" erstellt sie neu',
],
},
{
@@ -589,25 +732,29 @@ const de: Translations = {
items: [
'Konfigurationsdrift-Erkennung: Sollzustand (DB) vs. Gerätezustand (Poll-Snapshot)',
'Ein-Klick-Drift-Fix-Stream mit Live-SSH-Output im Browser',
'Alle abweichenden Access Points auf einmal korrigieren: Standorte parallel, die Geräte eines Standorts nacheinander, damit nie ein ganzer Standort ausfällt',
'netOrk schreibt nur dort auf ein Gerät, wo es darum gebeten wurde — ist die automatische Drift-Korrektur für ein Profil aus, ändert sich nichts, auch nicht im geplanten Fix-Fenster',
'UCI-basierter Config-Push für OpenWRT (VLAN-Namen, SSID-Einstellungen, Radio-Konfiguration)',
'AP-Profil-System: Ländercode, HT/VHT-Modus, 802.11r, NTP, Syslog, SSH-Port',
'Konfigurationsbackup & -versionierung: bei jedem Poll wird ein Snapshot in ein lokales Git-Repository geschrieben — vollständige Historie und Side-by-Side-Diff-Viewer',
'Ein-Klick-Konfigurations-Restore für OPNsense aus jedem früheren Snapshot',
'Nicht autorisierte Konfigurationsänderungen werden als Gerätewarnung angezeigt',
'netOrks eigene Config-Pushes (Drift-Fixes, ACL-Provisioning) werden erkannt und automatisch als neue Baseline akzeptiert — nie mit einer nicht autorisierten Änderung verwechselt',
'Firewall-Profile pro Standort und im Vergleich mit einer echten OPNsense (Diff-Tab); Anwenden schreibt die Änderungen Schritt für Schritt und löscht nie automatisch etwas',
],
},
{
title: 'Konfigurationsautomatisierung (Ansible)',
items: [
'Wiederverwendbare Ansible-Rollen und -Playbooks, direkt in netOrk gespeichert und bearbeitet — kein separates Git-Checkout',
'11 eingebaute Rollen sofort zuweisbar: base, ubuntu, docker, adguard, zoraxy, portainer, watchtower, uptime-kuma, vaultwarden, wireguard, fail2ban',
'16 eingebaute Rollen sofort zuweisbar: base, ubuntu, docker, adguard, zoraxy, portainer, watchtower, uptime-kuma, vaultwarden, stalwart (Mail), bulwark (Webmail), searxng, postiz, listmonk, wireguard, fail2ban',
'Jede Rolle nennt, was sie von einer Maschine braucht; ein zu kleiner Host wird mit Begründung abgelehnt, bevor etwas gebaut wird',
'Automatische Abhängigkeitsauflösung — die Zuweisung von docker zieht base automatisch nach, keine manuelle Rollen-Reihenfolge nötig',
'Eingebaute Rollen lassen sich nicht löschen, sind aber vollständig editierbar; Anpassungen überstehen Updates, nur unveränderte Dateien heilen bei Bugfixes automatisch nach',
'ansible-doc-gestützte Autovervollständigung beim Schreiben von Rollen und Playbooks',
'Eigene Rolle als Archiv hochladen',
'Rollenzuweisung auf Geräteebene mit eigenem Ansible-Tab in der Gerätedetailansicht',
'Lauf-Historie pro Gerät, mit Snapshot des tatsächlich ausgeführten Rollen-/Playbook-Inhalts',
'Lauf-Historie pro Gerät, mit Snapshot des tatsächlich ausgeführten Rollen-/Playbook-Inhalts — das vollständige Log jedes Laufs lässt sich öffnen',
'In VM-Provisioning eingebunden: Rollen bei VM-Erstellung zuweisen — sie laufen automatisch nach dem Boot',
],
},
@@ -638,13 +785,26 @@ const de: Translations = {
'SNMP-Gesundheitsmetriken (CPU, Speicher, Schnittstellenzähler) via get_health_metrics()',
'Gerätespezifisches Warnsystem mit Schweregraden (Fehler / Warnung / Info)',
'Ein-Klick-Ack für jede Warnung — löscht sie sofort und protokolliert die Aktion; bei Config-Change-Warnungen wird der aktuelle Zustand als neue Baseline akzeptiert',
'Docker-Container- und Image-Status (Proxmox/Linux)',
'Docker-Container- und Image-Status (Linux, OpenMediaVault, QNAP)',
'Service-Status und Start/Stop/Neustart (systemd)',
'VM/Container-Liste mit OS-Geräteverknüpfung (Proxmox)',
'Verfügbarkeitsfenster pro Gerät — unterdrückt OFFLINE-Status und Poll-Fehler-Warnungen während erwarteter Ausfallzeiten (z. B. nächtliches Abschalten); Opt-in, unkonfigurierte Geräte sind nicht betroffen',
'OPNsense: BGP-Nachbarschaftsstatus-Polling und -Anzeige, mit Peer-Down-Warnung',
'OPNsense: TLS-Zertifikatsüberwachung für den Trust Store, mit „läuft bald ab"/„abgelaufen"-Warnungen',
'OPNsense: Dynamic-DNS-Service-Down-Warnung (os-ddclient)',
'Service-Checks etwa einmal pro Minute: DNS, Zeitserver (auch solche, die antworten, aber selbst nicht synchron sind), VPN-Tunnel, Kerndienste und Gateways — abgeleitet aus dem, was netOrk ohnehin weiß, nichts einzurichten',
'Ein Check meldet sich nach drei Fehlschlägen in Folge; Checks können vom Standort-Satellite laufen, inklusive DHCP-Check aus dem lokalen Netz',
'Standort-Erreichbarkeit: Ist der Tunnel zu einem Standort weg, pausiert das Polling dort, eine Warnung nennt den Tunnel, und sobald er zurück ist, wird jedes Gerät sofort neu abgefragt',
],
},
{
title: 'Benachrichtigungen',
items: [
'Signal-Nachrichten für alles, was netOrk überwacht — ausgefallene Geräte, unerreichbare Standorte, ablaufende Zertifikate, tote Tunnel, fehlgeschlagene Automatisierungsläufe',
'Jede Person hinterlegt ihre eigene Nummer; Administratoren verbinden netOrk einmalig per QR-Code mit Signal',
'Bewusst leise: eine Nachricht pro Standortausfall statt einer pro Gerät, eine Tageszusammenfassung für Wiederkehrendes, Bündelung ab einem Stundenlimit',
'Ruhezeiten pro Nummer, Notfälle kommen trotzdem durch; jede Nachrichtenart für zwei Stunden oder dauerhaft stummschalten',
'Eine Historie jeder Benachrichtigung, auch der nicht versendeten, mit Begründung',
],
},
{
@@ -652,12 +812,35 @@ const de: Translations = {
items: [
'Konfigurierbare, teilbare Dashboards — eigene Dashboards aus einer Widget-Auswahl bauen statt festes Layout',
'WYSIWYG-Grid-Layout-Editor: Widgets auf einem Canvas per Drag & Drop platzieren und in der Größe anpassen',
'13 Widget-Typen: Stats, Gerätewarnungen, kürzlich aktualisierte Geräte, Netzwerktopologie, EOL-Status, Konfigurationsdrift-Zusammenfassung, Wazuh-Sicherheitsalerts, Audit-Log-Aktivität, Discovery-Job-Status, anstehende geplante Aktionen, DNS-Zonen-Übersicht, Standortübersicht, Konfigurationssnapshot-Historie',
'18 Widget-Typen: Stats, Gerätewarnungen, kürzlich aktualisierte Geräte, Netzwerktopologie, EOL-Status, Konfigurationsdrift-Zusammenfassung, Wazuh-Sicherheitsalerts, Audit-Log-Aktivität, Discovery-Job-Status, anstehende geplante Aktionen, DNS-Zonen-Übersicht, Standortübersicht, Konfigurationssnapshot-Historie, Managed Services, Zertifikatsablauf, veraltete Docker-Images, Firewall-Profil-Deployment-Status, Service-Checks',
'Mehrfachinstanzen desselben Widgets mit unabhängigen Einstellungen pro Widget, z. B. zwei Warnungs-Widgets für unterschiedliche Standorte',
'Dashboard mit bestimmten Benutzern teilen; Empfänger können es abonnieren, um immer die aktuelle Version des Owners zu sehen, oder es als eigene, editierbare Kopie klonen',
'Dashboards als Favorit markieren für schnellen Zugriff über das Hauptmenü; jedes Dashboard als Home-Ansicht festlegen',
],
},
{
title: 'Sicherheitsbewertung',
items: [
'Jedes Gerät hat einen Security-Tab: TLS- und SSH-Konfiguration mit Note A bis F, installierte Software gegen bekannte Schwachstellen abgeglichen, Konfiguration gegen Härtungs-Benchmarks geprüft — nichts auf dem Gerät zu installieren',
'Container-Images werden inventarisiert und gegen dieselben Daten geprüft, ein lange laufender Container ist kein blinder Fleck',
'Die Bewertung richtet sich nach dem Gerät, nicht nur nach dem veröffentlichten Score: niedriger, wenn eine Lücke lokalen Zugriff braucht, im vertrauenswürdigen Netz liegt oder Software betrifft, die nicht läuft bzw. einen Kernel, der nicht gebootet ist; höher, wenn sie aktiv ausgenutzt wird',
'Ein Neustart kommt mit Zahlen: welche Schwachstellen des laufenden Kernels ein Boot in den bereits installierten neueren beseitigt',
'Exposition: welche weniger vertrauenswürdigen Netze ein Gerät auf welchen Ports erreichen, gelesen aus den Firewallregeln; was das Internet von den eigenen öffentlichen Adressen sieht und ob eine davon wegen Missbrauchs gemeldet ist',
'Tiefere Scans auf Abruf — ein Härtungs-Audit des Hosts und ein Web-Scan seiner Management-Oberfläche',
'Die Schwachstellendaten kommen aus der netOrk Knowledge Base und setzen eine Lizenz voraus; netOrk hält eine lokale Kopie und verrät ihr nie, was installiert ist',
],
},
{
title: 'Schwachstellenmanagement',
items: [
'Triage-Queue über alle Geräte: eine Zeile pro Schwachstelle, geordnet nach Behebungsfrist, bekannter Ausnutzung, Schweregrad, Wahrscheinlichkeit, Kritikalität und Verbreitung — jede Zeile sagt, warum sie dort steht',
'Filter für ausgenutzt, überfällig und Patch verfügbar; Härtungsbefunde ohne CVE haben eine eigene Liste',
'Entscheidungen: nicht zutreffend (mit den Standardgründen, die Audit-Werkzeuge lesen), Risiko akzeptieren bis, zurückstellen bis, behoben — pro Gerät, Standort, Geräteart oder global, die spezifischste gewinnt',
'Jede Entscheidung braucht eine Begründung in eigenen Worten; Risiko akzeptieren oder „nicht zutreffend" erfordert eine Berechtigung, die Operatoren nicht haben',
'Zurückgestelltes und Akzeptiertes kommt am Stichtag von selbst zurück, früher, wenn die Schwachstelle ausgenutzt wird; Liegengelassenes wird als überfällig markiert',
'Behebungen schließen sich selbst: eine tägliche Neubewertung schließt einen Fix, den zwei Bewertungen in Folge nicht mehr finden, und öffnet ihn wieder, wenn er zurückkommt',
],
},
{
title: 'Sicherheitsintegrationen',
items: [
@@ -712,7 +895,8 @@ const de: Translations = {
'RBAC mit vier integrierten Rollen und benutzerdefinierten Berechtigungssätzen — Zugangskontrollnachweis',
'Patch-Status und installierte Paketliste pro Gerät — Patch-Management-Baseline (Art. 21 (2e))',
'EOL-Tracking-Plugin kennzeichnet Geräte mit nicht unterstützter Firmware/OS über endoflife.date — Supply-Chain-Sicherheits-Baseline (Art. 21 (2d))',
'Wazuh-CVE-Anzahl nach Schweregrad (kritisch / hoch / mittel) verknüpft mit jedem Gerätedatensatz',
'Schwachstellenbehandlung mit Nachweis: jede Triage-Entscheidung trägt Begründung, Datum und Entscheider und landet im Audit-Log (Art. 21 (2e))',
'Terminal-Sitzungen sind einer Person zuzuordnen — jeder meldet sich mit dem eigenen Schlüssel an, geöffnete und verweigerte Sitzungen werden protokolliert (Art. 21 (2i))',
'Git-basierte Konfigurationssnapshot-Historie mit Diff-Viewer und OPNsense-Restore — Backup- & Recovery-Nachweis auf Konfigurationsebene (Art. 21 (2c))',
'Konfigurationsdrift-Tracking: Sollzustand vs. abgefragter Zustand — nicht autorisierte Änderungen erkennen',
'Security-Agent-Abdeckungsbericht: welche Geräte haben Wazuh, Graylog, CrowdSec aktiv',
@@ -734,10 +918,16 @@ const de: Translations = {
},
drivers: {
heading: 'Unterstützte Geräte',
sub: 'netOrk liefert eigene NAPALM-Treiber für 11 Gerätetypen und unterstützt alle integrierten NAPALM-Treiber. Der Funktionsumfang variiert je nach Treiber.',
sub: 'netOrk liefert eigene NAPALM-Treiber für 15 Gerätetypen und kann die integrierten NAPALM-Treiber nutzen. Der Funktionsumfang variiert je nach Treiber.',
customHeading: 'Eigene Treiber',
napalmHeading: 'Integrierte NAPALM-Treiber',
napalmSub: 'Alle Standard-NAPALM-Treiber werden unterstützt. Der Funktionsumfang hängt von der jeweiligen NAPALM-Implementierung ab.',
napalmSub: 'Die Standard-NAPALM-Treiber sind installiert und lesen Fakten, Interfaces, LLDP und VLANs, soweit die jeweilige Implementierung das hergibt. Mit netOrk getestet sind sie nicht, und die treiberspezifischen Funktionen – Geräterollen, SNMP-Health-Metriken, Konfiguration schreiben – bekommen sie nicht.',
notes: [
'Health: SNMP-Metriken (CPU, Speicher, Interface-Zähler); setzt aktiviertes SNMP am Gerät und eine in netOrk hinterlegte Community voraus.',
'LLDP auf Linux, OpenMediaVault, QNAP und OPNsense setzt ein installiertes lldpd (os-lldpd) auf dem Host voraus.',
'Config push: netOrk schreibt Konfiguration aufs Gerät, etwa VLANs und Ports auf Switches, UCI-Profile auf OpenWrt, DHCP, DNS und Firewallregeln auf OPNsense, SDN-VNets auf Proxmox.',
'Reboot: Ein Neustart aus netOrk startet OpenWrt- und Proxmox-Geräte tatsächlich neu. OPNsense und Linux-Hosts können beim Einspielen von Updates neu starten.',
],
},
gettingStarted: {
label: 'demnächst verfügbar',
+18 -11
View File
@@ -19,17 +19,21 @@ interface Driver {
}
const DRIVERS: Driver[] = [
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'procurve', deviceType: 'HP ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'netgear', deviceType: 'Netgear 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 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: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ 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: 'zyxel', deviceType: 'Zyxel switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'beta' },
{ 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']
@@ -103,6 +107,9 @@ export default function Drivers() {
</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>
+11 -7
View File
@@ -30,17 +30,21 @@ const FEATURES: Record<'en' | 'de', Item[]> = {
{ 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: '11 built-in roles (base, docker, adguard, wireguard, fail2ban, and more) with automatic dependency resolution. Write your own roles too, with full run history per device.' },
{ 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 13 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
{ 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: '11 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban und mehr) mit automatischer Abhängigkeitsauflösung. Eigene Rollen schreiben inklusive vollständiger Lauf-Historie pro Gerät.' },
{ 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 13 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
{ 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.' },
],
}
@@ -58,7 +62,7 @@ export default function ForItDepartment() {
<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-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItDepartment.heading}</h1>
<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>
@@ -96,8 +100,8 @@ export default function ForItDepartment() {
</h2>
<p className="text-sm text-slate-400 leading-relaxed mb-4">
{lang === 'en'
? 'Custom NAPALM drivers for 11 device types, plus every built-in NAPALM driver.'
: 'Eigene NAPALM-Treiber für 11 Gerätetypen, plus alle integrierten NAPALM-Treiber.'}
? '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 →'}
+5 -1
View File
@@ -33,6 +33,8 @@ const FEATURES: Record<'en' | 'de', Item[]> = {
{ 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.' },
@@ -41,6 +43,8 @@ const FEATURES: Record<'en' | 'de', Item[]> = {
{ 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.' },
],
}
@@ -58,7 +62,7 @@ export default function ForItSupport() {
<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-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItSupport.heading}</h1>
<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>
+5 -1
View File
@@ -33,6 +33,8 @@ const FEATURES: Record<'en' | 'de', Item[]> = {
{ 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.' },
@@ -41,6 +43,8 @@ const FEATURES: Record<'en' | 'de', Item[]> = {
{ 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.' },
],
}
@@ -58,7 +62,7 @@ export default function ForMsp() {
<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-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forMsp.heading}</h1>
<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>
+1 -1
View File
@@ -9,7 +9,7 @@ export default function GettingStarted() {
<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-4xl md:text-5xl font-bold text-slate-100 leading-tight mb-6 whitespace-pre-line">
<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">
+21 -303
View File
@@ -10,12 +10,12 @@ import { useLang } from '../context/LangContext'
import { linkify } from '../lib/glossary'
const DRIVERS = [
'Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Fritz!Box',
'HP ProCurve / Aruba', 'Juniper JunOS', 'Linux', 'Netgear', 'OpenMediaVault',
'OpenWRT', 'OPNsense', 'Proxmox VE', 'Sonos', 'TP-Link Jetstream', 'Zyxel',
'Fritz!Box', 'HPE OfficeConnect', 'HPE ProCurve / Aruba', 'Linux', 'Netgear',
'OpenMediaVault', 'OpenWrt', 'OPNsense', 'Proxmox VE', 'QNAP QTS', 'Sonos',
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
]
function BrowserFrame({ label, children }: { label: string; children: React.ReactNode }) {
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">
@@ -24,282 +24,7 @@ function BrowserFrame({ label, children }: { label: string; children: React.Reac
<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>
{children}
</div>
)
}
function StatusBadge({ status }: { status: 'active' | 'warning' | 'offline' }) {
const cls =
status === 'active' ? 'bg-green-500/20 text-green-400' :
status === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
return (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cls}`}>
{status}
</span>
)
}
function MockDeviceList() {
const rows = [
{ name: 'fw-001.lan', ip: '10.0.0.1', driver: 'OPNsense', status: 'active' as const },
{ name: 'sw-core.lan', ip: '10.0.0.2', driver: 'HP ProCurve', status: 'warning' as const },
{ name: 'ap-001.lan', ip: '10.0.0.10', driver: 'OpenWRT', status: 'active' as const },
{ name: 'ap-002.lan', ip: '10.0.0.11', driver: 'OpenWRT', status: 'warning' as const },
{ name: 'pve-01.lan', ip: '10.0.0.20', driver: 'Proxmox VE', status: 'active' as const },
{ name: 'nas-01.lan', ip: '10.0.0.30', driver: 'OpenMediaVault', status: 'active' as const },
]
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">Devices</span>
<span className="text-xs px-2.5 py-1 rounded-md border border-slate-700 text-slate-400">Discovery</span>
</div>
<div className="rounded-lg border border-slate-800 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-800 bg-slate-900">
<th className="px-3 py-2 text-left font-medium text-slate-500">Hostname</th>
<th className="px-3 py-2 text-left font-medium text-slate-500">IP</th>
<th className="px-3 py-2 text-left font-medium text-slate-500 hidden sm:table-cell">Driver</th>
<th className="px-3 py-2 text-left font-medium text-slate-500">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40">
<td className="px-3 py-2 font-mono text-sky-400">{r.name}</td>
<td className="px-3 py-2 font-mono text-slate-400">{r.ip}</td>
<td className="px-3 py-2 text-slate-400 hidden sm:table-cell">{r.driver}</td>
<td className="px-3 py-2"><StatusBadge status={r.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function MockDeviceDetail() {
return (
<div className="bg-slate-950 p-4">
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4 mb-3">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-xs font-semibold text-slate-100">ap-001.lan</p>
<p className="text-xs text-slate-500 font-mono">10.0.0.10</p>
</div>
<StatusBadge status="active" />
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-slate-500">Vendor</span><p className="text-slate-300">GL.iNet</p></div>
<div><span className="text-slate-500">OS</span><p className="text-slate-300">OpenWRT 23.05</p></div>
<div><span className="text-slate-500">Driver</span><p className="text-slate-300">openwrt</p></div>
<div><span className="text-slate-500">Last poll</span><p className="text-slate-300">2 min ago</p></div>
</div>
</div>
<div className="flex gap-2 text-xs mb-3">
{['Interfaces', 'LLDP', 'ARP', 'VLANs', 'Services'].map((t) => (
<span key={t} className={`px-2 py-1 rounded text-xs ${t === 'Interfaces' ? 'bg-sky-600/20 text-sky-400' : 'text-slate-500 hover:text-slate-300'}`}>{t}</span>
))}
</div>
<div className="rounded-lg border border-slate-800 overflow-hidden">
<table className="w-full text-xs">
<thead><tr className="bg-slate-900 border-b border-slate-800">
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">Interface</th>
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">IP</th>
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">State</th>
</tr></thead>
<tbody>
{[['br-lan', '10.0.0.10/24', 'up'], ['wlan0', '—', 'up'], ['eth0', '—', 'up']].map(([iface, ip, state]) => (
<tr key={iface} className="border-b border-slate-800 last:border-0">
<td className="px-3 py-1.5 font-mono text-slate-300">{iface}</td>
<td className="px-3 py-1.5 font-mono text-slate-400">{ip}</td>
<td className="px-3 py-1.5"><span className="text-green-400">{state}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function MockVlans() {
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">VLANs</span>
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add VLAN</span>
</div>
<div className="space-y-2">
{[
{ id: 10, name: 'management', devices: 6, drift: false },
{ id: 20, name: 'iot', devices: 4, drift: true },
{ id: 30, name: 'servers', devices: 3, drift: false },
{ id: 40, name: 'guest', devices: 2, drift: false },
].map((v) => (
<div key={v.id} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-slate-500 w-8">#{v.id}</span>
<span className="text-xs text-slate-300">{v.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">{v.devices} devices</span>
{v.drift
? <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">drift</span>
: <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">clean</span>
}
</div>
</div>
))}
</div>
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
<p className="text-xs text-yellow-400 font-medium mb-1">Drift detected — VLAN 20 (iot)</p>
<p className="text-xs text-slate-400">ap-002.lan: VLAN name mismatch. Expected "iot", got "IoT-devices".</p>
<button className="mt-2 text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Fix now</button>
</div>
</div>
)
}
function MockConfigDiff() {
const snapshots = [
{ id: 'a3f9c1', when: '2 min ago', label: 'current' },
{ id: '7e2b04', when: '1 h ago' },
{ id: 'd819e6', when: '6 h ago' },
]
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">Config — fw-001.lan</span>
<button className="text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Restore</button>
</div>
<div className="flex gap-2 mb-3">
{snapshots.map((s) => (
<span key={s.id} className={`text-xs font-mono px-2 py-1 rounded border ${s.label ? 'border-sky-500/40 bg-sky-500/10 text-sky-400' : 'border-slate-800 text-slate-500'}`}>
{s.id} <span className="text-slate-600">· {s.when}</span>
</span>
))}
</div>
<div className="rounded-lg border border-slate-800 overflow-hidden font-mono text-xs">
<div className="px-3 py-1.5 bg-slate-900 text-slate-500 border-b border-slate-800">7e2b04 → a3f9c1</div>
<div className="px-3 py-1 bg-red-500/10 text-red-400">- set firewall.rule_42.destination_port='22'</div>
<div className="px-3 py-1 bg-green-500/10 text-green-400">+ set firewall.rule_42.destination_port='2222'</div>
<div className="px-3 py-1 text-slate-500"> commit</div>
</div>
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
<p className="text-xs text-yellow-400 font-medium">Unauthorized change detected — fw-001.lan</p>
<p className="text-xs text-slate-400 mt-1">Configuration changed outside netOrk between the last two polls.</p>
</div>
</div>
)
}
function MockDashboard() {
const widgets = [
{ label: 'Stats', span: 'col-span-2' },
{ label: 'Device Warnings', span: 'col-span-1' },
{ label: 'Network Topology', span: 'col-span-2' },
{ label: 'EOL Status', span: 'col-span-1' },
]
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">My Dashboard</span>
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">shared</span>
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add Widget</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
{widgets.map((w) => (
<div key={w.label} className={`${w.span} rounded-lg border border-dashed border-slate-700 bg-slate-900 p-3 h-16 flex items-center justify-center`}>
<span className="text-xs text-slate-500">{w.label}</span>
</div>
))}
</div>
</div>
)
}
function MockCompliance() {
const checks = [
{ label: 'Asset inventory', detail: '18 / 18 devices tracked', ok: true },
{ label: 'Patch status', detail: 'All devices polled < 60 min', ok: true },
{ label: 'Wazuh agents', detail: '15 / 18 agents active', ok: false },
{ label: 'Syslog forwarding', detail: '14 / 18 forwarding to Graylog', ok: false },
{ label: 'Config drift', detail: '0 drifted devices', ok: true },
{ label: 'Audit log', detail: '23 actions logged (24 h)', ok: true },
]
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">Compliance overview — HQ</span>
<span className="text-xs font-mono text-sky-500">Art. 21 NIS2</span>
</div>
<div className="space-y-1.5">
{checks.map((c) => (
<div key={c.label} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
<div className="flex items-center gap-2.5">
<span className={`text-xs font-semibold w-3 ${c.ok ? 'text-green-400' : 'text-yellow-400'}`}>
{c.ok ? '✓' : '⚠'}
</span>
<span className="text-xs text-slate-300">{c.label}</span>
</div>
<span className="text-xs text-slate-500">{c.detail}</span>
</div>
))}
</div>
</div>
)
}
function MockSecurity() {
return (
<div className="bg-slate-950 p-4">
<div className="mb-3">
<span className="text-xs font-semibold text-slate-100">Security — ap-001.lan</span>
</div>
<div className="space-y-2">
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">Wazuh Agent</span>
<StatusBadge status="active" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center rounded bg-red-500/10 border border-red-500/20 py-1.5">
<p className="text-red-400 font-semibold">2</p>
<p className="text-slate-500">Critical</p>
</div>
<div className="text-center rounded bg-yellow-500/10 border border-yellow-500/20 py-1.5">
<p className="text-yellow-400 font-semibold">7</p>
<p className="text-slate-500">High</p>
</div>
<div className="text-center rounded bg-blue-500/10 border border-blue-500/20 py-1.5">
<p className="text-blue-400 font-semibold">14</p>
<p className="text-slate-500">Medium</p>
</div>
</div>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">Graylog syslog</span>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">forwarding</span>
</div>
<p className="text-xs text-slate-500 mt-1">10.0.0.50:514 (UDP)</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">CrowdSec</span>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">active</span>
</div>
<p className="text-xs text-slate-500 mt-1">3 decisions · 0 bans (24 h)</p>
</div>
</div>
<img src={src} alt={alt} loading="lazy" className="block w-full" width={1600} height={1000} />
</div>
)
}
@@ -336,9 +61,7 @@ export default function Home() {
</div>
</div>
<div className="max-w-7xl mx-auto px-6 mt-16">
<BrowserFrame label="netork.local / devices">
<MockDeviceList />
</BrowserFrame>
<Screenshot src="/screenshots/devices.webp" label="netork / devices" alt={h.shotAlt.devices} />
</div>
</section>
@@ -414,45 +137,42 @@ export default function Home() {
<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>
<BrowserFrame label="netork.local / devices / ap-001">
<MockDeviceDetail />
</BrowserFrame>
<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">
<BrowserFrame label="netork.local / vlans">
<MockVlans />
</BrowserFrame>
<div>
<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>
<BrowserFrame label="netork.local / devices / ap-001 / security">
<MockSecurity />
</BrowserFrame>
<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">
<BrowserFrame label="netork.local / devices / fw-001 / config">
<MockConfigDiff />
</BrowserFrame>
<div>
<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>
<BrowserFrame label="netork.local / dashboards / my-dashboard">
<MockDashboard />
</BrowserFrame>
<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>
@@ -485,9 +205,7 @@ export default function Home() {
))}
</div>
</div>
<BrowserFrame label="netork.local / compliance / HQ">
<MockCompliance />
</BrowserFrame>
<Screenshot src="/screenshots/audit-log.webp" label="netork / audit-log" alt={h.shotAlt.auditLog} />
</div>
</div>
</section>
+4 -6
View File
@@ -14,11 +14,11 @@ const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
{ 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: 'Per-device update status and installed package list tracked on every poll. Wazuh CVE counts by severity (critical / high / medium) linked directly to each device record. CVE cross-reference against NVD/OSV (without Wazuh) is on the roadmap.' },
{ 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.' },
{ 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: [
@@ -26,11 +26,11 @@ const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
{ 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: 'Update-Status und installierte Paketliste pro Gerät werden bei jedem Poll erfasst. Wazuh-CVE-Anzahl nach Schweregrad (kritisch / hoch / mittel) direkt mit jedem Gerätedatensatz verknüpft. CVE-Abgleich gegen NVD/OSV (ohne Wazuh) ist auf der Roadmap.' },
{ 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.' },
{ 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.' },
],
}
@@ -108,12 +108,10 @@ const EVIDENCE: Record<'en' | 'de', EvidenceBlock[]> = {
const COMING: Record<'en' | 'de', ComingItem[]> = {
en: [
{ title: 'CVE tracking per device', detail: 'Automatic cross-reference of installed packages against NVD / OSV — no Wazuh agent required.' },
{ 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: 'CVE-Tracking pro Gerät', detail: 'Automatischer Abgleich installierter Pakete gegen NVD / OSV — kein Wazuh-Agent erforderlich.' },
{ 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.' },
],
+46 -12
View File
@@ -7,13 +7,30 @@ type Group = { label: string; items: Item[] }
const GROUPS: Record<'en' | 'de', Group[]> = {
en: [
{
label: 'Planned',
label: 'Next release',
items: [
{
title: 'CVE tracking per device',
detail: 'Cross-reference installed packages and OS versions against NVD / OSV. Surfaces "this device has 3 unpatched CVEs (CVSS ≥ 7)" without leaving netOrk.',
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.',
@@ -37,8 +54,8 @@ const GROUPS: Record<'en' | 'de', Group[]> = {
nis2: true,
},
{
title: 'Firewall profile management — rework',
detail: 'Push reusable firewall rule templates to OPNsense and OpenWRT devices. The existing implementation is being re-scoped from scratch — profile types, rule sets, and the push mechanism are all under review before further work lands.',
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.',
},
],
},
@@ -71,13 +88,30 @@ const GROUPS: Record<'en' | 'de', Group[]> = {
],
de: [
{
label: 'Geplant',
label: 'Nächstes Release',
items: [
{
title: 'CVE-Tracking pro Gerät',
detail: 'CVE-Abgleich mit installierten Paketen und OS-Versionen über NVD / OSV. Zeigt „Dieses Gerät hat 3 ungepatchte CVEs (CVSS ≥ 7)" direkt in netOrk an.',
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.',
@@ -101,8 +135,8 @@ const GROUPS: Record<'en' | 'de', Group[]> = {
nis2: true,
},
{
title: 'Firewall-Profile — Überarbeitung',
detail: 'Wiederverwendbare Firewall-Regel-Templates auf OPNsense- und OpenWRT-Geräte pushen. Die bestehende Implementierung wird von Grund auf neu bewertet — Profiltypen, Regelsätze und der Push-Mechanismus stehen vor der Weiterentwicklung auf dem Prüfstand.',
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.',
},
],
},
@@ -153,9 +187,9 @@ export default function Roadmap() {
<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.split('NIS2')[0]}
{t.roadmap.sub.slice(0, t.roadmap.sub.indexOf('NIS2'))}
<Nis2Badge />
{t.roadmap.sub.split('NIS2')[1]}
{t.roadmap.sub.slice(t.roadmap.sub.indexOf('NIS2') + 'NIS2'.length)}
</p>
</div>