37 Commits
Author SHA1 Message Date
christianmanivong 992df0e6b1 Merge pull request 'Redesign: control instead of drift' (#3) from feature/redesign into main
CI / TypeScript — type-check (push) Successful in 19s
CI / Publish — build & push image (push) Successful in 27s
2026-09-30 05:44:34 +00:00
Christian ManivongandClaude Opus 5.5 f70fec496a feat: redesign — one message, one audience, light pages
CI / TypeScript — type-check (push) Successful in 18s
CI / Publish — build & push image (push) Skipped
CI / TypeScript — type-check (pull_request) Successful in 17s
CI / Publish — build & push image (pull_request) Skipped
The site felt old, unfocused and bloated: 11 pages, a features page of 171
bullets, a homepage of 830 words in card grids around seven mockup-style
screenshots, and no single thing it wanted a visitor to understand.

- Message: control instead of drift. Audience: IT departments in small and
  mid-sized companies. Goal: buy a licence — netOrk itself is free, the
  licence adds vulnerability data and image updates.
- Home is under 400 words: the drift comparison of a real access point, three
  steps, the hardware it runs on, vulnerabilities with a licence, what else is
  in the box, one closing band. Features, Drivers and Roadmap are gone; their
  URLs redirect (router and nginx 301).
- New Pricing page: the free core, Starter / Pro / Enterprise on request with
  the plan differences from the licence server, four questions; buttons go to
  the licence portal. The unit-less KB request limit is left out.
- Persona pages are one template; NIS2 and Plugins are cut to half or less.
  Impressum and Datenschutz exist as marked placeholders; the unsupported
  "MIT licence" claim is gone from the footer.
- Look: light paper and ink, the dark product on a stage, Inter self-hosted,
  split sections and ruled lists instead of cards. Four real, cropped
  screenshots replace eight full-window ones.
- Language follows the browser until someone chooses; <html lang> is set.
  Scroll-to-top on navigation, a catch-all route, no dead /docs/architecture.
- CLAUDE.md, DESIGN.md, PAGES.md and PRODUCT.md describe the new rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:24:49 +02:00
Christian ManivongandClaude Opus 5.5 369f66afdc feat(screenshots): cropped shots, sizes for the site, a site check
- shots.py crops to a region (clip) or to what one or more elements cover
  (element, pad), per-shot viewport; capture.py writes the published sizes to
  src/data/screenshots.json so the page reserves the right space.
- anonymize.py no longer empties secrets that netOrk compares with each other
  (Wi-Fi keys on an SSID against the key read from the access point). Emptying
  them invented passphrase "drift" that never existed; a keyed hash keeps equal
  equal, reverses nothing, and its key lives for one run.
- scripts/check/site.py checks the built site in both languages at four widths:
  sideways overflow, one h1, images with alt and size, console errors, requests
  to other origins, links to unknown routes, old-URL redirects, language
  detection, and word counts against the budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:24:49 +02:00
christianmanivong d585f9272f Merge pull request 'Website: netOrk v0.28.0 and real screenshots' (#2) from feature/v0.28-real-screenshots into main
CI / TypeScript — type-check (push) Successful in 17s
CI / Publish — build & push image (push) Successful in 30s
2026-09-26 08:58:00 +00:00
Christian ManivongandClaude Opus 5.5 8a1cf7b2b6 fix: usable on phones — menu, no sideways scrolling, whole roadmap intro
CI / TypeScript — type-check (push) Successful in 19s
CI / Publish — build & push image (push) Skipped
CI / TypeScript — type-check (pull_request) Successful in 17s
CI / Publish — build & push image (pull_request) Skipped
- The navigation had no mobile layout: every page was 413px wider than a
  phone. Below lg the links now sit behind a menu button, grouped like the
  desktop dropdowns; the menu closes on navigation.
- Glossary tooltips were invisible but still laid out, so a term near the
  right edge widened the page. Hidden tooltips no longer take up space;
  they show on hover and on keyboard focus as before.
- Long German words in page headings ("produktionstauglich",
  "Konfigurationstiefe") overflowed at 360px; headings start a size smaller
  on phones.
- The roadmap intro dropped everything after its second "NIS2" ("…the
  baseline requirements of"); it is split at the first one only now.

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

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

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

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

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

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

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

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

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

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

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

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

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

The deploy job is removed rather than migrated, because it had quietly stopped
being correct. It ran `docker run` against whatever runner picked the job up,
which worked while exactly one runner existed. There are now several --
netork-runner-12 on .12, netork-runner-13 on .13, plus the original
netork-runner -- and none of them is on 10.7.224.11, where this site runs and
where the proxy-net it attaches to lives. The next push would have started a
second website container on the wrong host and reported success while netork.io
went on serving the old one. Nothing had failed yet; the last deploy was
2026-07-17, back when the pool was one runner.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Required Gitea secrets: REGISTRY_USER, REGISTRY_PASSWORD

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 10:19:50 +02:00
55 changed files with 7045 additions and 610 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.md
+91
View File
@@ -0,0 +1,91 @@
name: CI
on:
push:
branches: ['**']
tags: ['v*']
pull_request:
branches: ['**']
jobs:
typecheck:
name: TypeScript — type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install
run: npm ci
- name: Type-check (tsc)
run: npx tsc --noEmit
publish:
name: Publish — build & push image
runs-on: ubuntu-latest
needs: [typecheck]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
# Gitea's registry, not registry.netork.io.
#
# registry.netork.io is plain registry:2 with htpasswd auth, which knows
# nothing about repositories: every account that can log in reads and
# writes everything on it, including the accounts issued to customer
# instances. It keeps the images those instances are meant to pull
# (netork/engine, netork/ui, netork/satellite); the marketing site is not
# one of them. Gitea scopes packages to their owning account, and no
# customer has one. netOrk issue #172.
#
# REGISTRY_TOKEN is a Gitea access token with write:package — the token
# Actions injects per run is scoped to the repository API and the package
# registry rejects it outright.
- name: Login to registry
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::error::REGISTRY_TOKEN is not set (Gitea token with write:package)."
exit 1
fi
LOGIN_USER="${{ secrets.REGISTRY_USER }}"
[ -n "$LOGIN_USER" ] || LOGIN_USER="${{ github.actor }}"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.netork.io -u "$LOGIN_USER" --password-stdin
- name: Build & push
run: |
set -euo pipefail
SHA=$(git rev-parse --short HEAD)
docker build \
-t git.netork.io/netork/website:latest \
-t git.netork.io/netork/website:main-${SHA} \
.
docker push git.netork.io/netork/website:latest
docker push git.netork.io/netork/website:main-${SHA}
- name: Logout
if: always()
run: docker logout git.netork.io
# The deploy job that used to live here has been removed, deliberately.
#
# It ran `docker run` against whatever runner picked the job up, which worked
# while exactly one runner existed. There are now several (netork-runner-12 on
# .12, netork-runner-13 on .13, and the original netork-runner) and none of them
# is on 10.7.224.11, where this site actually runs and where the proxy-net it
# attaches to lives. The job would therefore have started a second website
# container on the wrong host and reported success, while netork.io went on
# serving the old one.
#
# Deployment is now an explicit step: scripts/deploy.sh, run from a workstation,
# which targets .11 by name and verifies afterwards that the container really is
# on the image that was pulled.
#
# To get push-to-deploy back, either register a runner on .11 with a label of its
# own and pin `runs-on:` to it, or give CI an ssh key for .11. Both are choices
# about where a credential lives, so neither was made here.
+17 -1
View File
@@ -1 +1,17 @@
node_modules/\ndist/\n.env\n.env.local\n*.local\n.DS_Store
node_modules/
dist/
memory/
.env
.env.local
*.local
.DS_Store
# Deploy target + registry token
deploy.env
# Screenshot tooling: bytecode, and database dumps that hold production data
__pycache__/
*.dump
# Output of scripts/check/site.py
.check/
+45 -13
View File
@@ -10,14 +10,38 @@ get started.
## Project Purpose
Potential users land here and need to answer three questions in under 10 seconds:
1. What is this?
2. Is it for me?
3. How do I try it?
1. What is this? — Control instead of drift: define the desired state once,
netOrk finds every deviation and puts it back.
2. Is it for me? — IT departments in small and mid-sized companies with mixed
hardware (service providers and IT support are secondary).
3. What does it cost? — netOrk is free; a licence adds vulnerability data and
updates (`/pricing`).
Everything on the site should serve those three questions.
---
## Infrastruktur ändert man woanders — und vorher
Diese Anwendung läuft auf einer Infrastruktur, die sie sich mit anderen Projekten teilt:
Netz, Datenbankcluster, öffentlicher Eingang und Backup gehören keinem Projekt allein.
Dokumentiert ist sie in **`git.netork.io/christianmanivong/infrastructure`**, und dort
steht in `CLAUDE.md` auch die verbindliche Regel.
**Kurz: erst dort dokumentieren, ausdrücklich genehmigen lassen, dann ändern.** Nicht
umgekehrt, und „ja mach mal" zu einer früheren Frage deckt die nächste Änderung nicht mit
ab.
Betroffen ist alles, was über dieses Repo hinausreicht — Hosts, Netze, Firewall-Regeln,
der Patroni-Cluster samt `pg_hba` und DCS-Parametern, pgBackRest, BunkerWeb-Hosts, DNS,
CI-Runner, alles, was eine Anwendung auf den geteilten Datenbankcluster umzieht.
**Nicht** betroffen: Anwendungscode, Abhängigkeiten und Migrationen innerhalb der eigenen
Datenbank.
Der Grund für die Reihenfolge ist nicht Bürokratie. Die meisten Zwischenfälle dort waren
nicht falsche Werte, sondern richtige Werte in der falschen Reihenfolge — und das fällt
beim Aufschreiben auf, nicht beim Tippen. Im Zweifel dorthin.
## Content & Design Source of Truth
All product content (features, copy, page structure) is in `docs/`:
@@ -53,13 +77,19 @@ exactly as netOrk's `ui/src/components/ui.tsx` does.
## Design Rules (summary — full detail in docs/DESIGN.md)
- **Dark theme only.** Background `bg-slate-950`. Cards `bg-slate-900`.
- **Accent color:** `sky-500` / `sky-600` for CTAs, links, highlights.
- **No light mode toggle.** Ever.
- Font stack: system default (Tailwind sans). No Google Fonts.
- All interactive elements use `transition-colors` — no layout shifts.
- Screenshots/mockups of the actual app use a `border border-slate-700 rounded-xl
overflow-hidden` wrapper to frame them against the dark background.
- **Light pages, dark product.** Pages are `paper` with `ink`; the only dark
surfaces are real netOrk screenshots and code, on the `night` stage.
- One accent, `accent` (sky-700), for links, eyebrows and focus. Buttons are ink.
- Font: Inter Variable, self-hosted via `@fontsource-variable/inter`. No Google
Fonts, no request to any other origin.
- Layout from `src/components/ui.tsx`: split sections, ruled lists, numbered
steps, bands. No card grids, no icon tiles, no centred text blocks.
- Motion: `transition-colors` only — no layout shifts.
- **Screenshots are real.** Taken from the anonymised demo copy with
`scripts/screenshots/capture.py`, cropped to what the text talks about, at most
four on the site. No mockups, no edited data, no clicks that fake a state.
- **Less text.** Word budgets per page are in docs/PAGES.md and checked by
`scripts/check/site.py`.
---
@@ -87,7 +117,9 @@ netork-website/
## Tone of Voice
- Direct and technical — audience is engineers, not executives.
- Direct and technical — audience is the admins of an IT department, not executives.
- One message: control instead of drift. Everything else supports it.
- No marketing fluff ("revolutionize", "empower", "seamless").
- Show, don't tell — a screenshot or code block beats three sentences of prose.
- German is fine for internal docs; the website copy is in **English**.
- Show, don't tell — a real screenshot beats three sentences of prose.
- Website copy exists in **English and German** (German addresses readers with
"ihr"); the language follows the browser until the visitor chooses.
+14
View File
@@ -0,0 +1,14 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+15
View File
@@ -0,0 +1,15 @@
services:
netork-website:
image: registry.netork.io/netork/website:latest
container_name: netork-website
restart: unless-stopped
expose:
- "80"
networks:
- proxy-net
# Zoraxy proxies to netork-website:80 via proxy-net.
# No host port binding needed — SSL terminated by Zoraxy.
networks:
proxy-net:
external: true
+59 -242
View File
@@ -1,261 +1,78 @@
# netOrk — Visual Identity & Design System
# netOrk website — design system
This document defines the visual identity of netOrk and must be followed
exactly when building the website. The goal is zero visual discontinuity
between the product UI and the marketing site.
**Light pages, dark product.** The site is calm paper with ink; the only dark
surfaces are real screenshots of netOrk and code, set on a "stage". References:
Linear/Vercel for precision, Tailscale/Netbird for friendliness.
---
Tokens live in `tailwind.config.js`, building blocks in `src/components/ui.tsx`.
If a page needs something that is not there, it probably needs less instead.
## Core Principle
## Colour
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
No gradients, no floating orbs, no animated hero blobs. The aesthetic is
deliberate, minimal, and technical — consistent with the product itself.
---
## Color Palette
All colors are Tailwind CSS v3 classes. Do not use hex values directly —
always use Tailwind class names to stay consistent.
### Backgrounds
| Layer | Class | Usage |
| Token | Value | Use |
|---|---|---|
| Page / outermost | `bg-slate-950` | Body, full-bleed sections |
| Card / panel | `bg-slate-900` | Content cards, code blocks, feature boxes |
| Elevated element | `bg-slate-800` | Hover states, dropdowns, table rows on hover |
| Border | `border-slate-700` | Between sections, card outlines |
| Subtle border | `border-slate-800` | Inside cards, dividers |
| `paper` / `paper-2` | `#FAFAF9` / `#F3F3F0` | page / band |
| `line` / `line-strong` | `#E6E6E3` / `#D4D4D0` | hairlines |
| `ink` / `ink-soft` / `ink-muted` / `ink-faint` | `#0E1116` … `#9AA0A8` | headings / body / secondary / quiet |
| `night` | `#020617` | screenshot and code stage — the netOrk UI's own background |
| `accent` (`hover`, `soft`) | `#0369A1` | links, eyebrows, numbers, focus ring (5.7:1 on paper) |
| `drift` / `sync` | `#B45309` / `#15803D` | status: deviates / matches; never the only signal |
### Text
No gradients, no glow, no second accent. Buttons are ink, not accent.
| Role | Class |
## Type
Inter Variable, self-hosted through `@fontsource-variable/inter` (bundled by
Vite; the site makes no request to anyone else). Scale:
| Class | Use |
|---|---|
| Primary | `text-slate-100` |
| Secondary / muted | `text-slate-400` |
| Tertiary / placeholder | `text-slate-500` |
| Accent (interactive) | `text-sky-400` |
| Danger | `text-red-400` |
| `text-display` | the home headline only |
| `text-h1` | one per page, in `PageHeader` |
| `text-h2` | section headings |
| `text-h3` | row terms, plan names |
| `text-lead` | the paragraph under a heading |
| `text-eyebrow` + `uppercase text-accent` | the small line above a heading |
### Accent (Interactive / CTA)
Everything is left-aligned. Headings balance and hyphenate (`<html lang>` is
set per language). Text columns stay within `max-w-measure` (38rem).
| State | Class |
|---|---|
| Default button | `bg-sky-600 text-white` |
| Hover | `hover:bg-sky-500` |
| Link / inline | `text-sky-400 hover:text-sky-300` |
| Active indicator | `text-sky-400` |
| Focus ring | `focus:ring-sky-500` |
## Layout
### Semantic Colors
- Container `max-w-page` (72rem), `px-5 sm:px-8`. Sections `py-20 md:py-28`.
- **Split**: heading on the left five columns, content on the right. The
default section.
- **RuleList**: rows divided by hairlines, term and body; one or two columns.
This replaces every card grid.
- **Steps**: numbered rows (`01`, `02`, `03`) in mono accent.
- **Band**: `bg-paper-2` with hairlines, for the hardware strip and the closing
call to action (`CtaBand`).
- **Stage**: `bg-night`, `rounded-2xl`, `shadow-stage` — screenshots (`Shot`)
and code (`CodeBlock`).
| Meaning | Color |
|---|---|
| Success / active | `text-green-400`, `bg-green-500/20` |
| Warning / caution | `text-yellow-400`, `bg-yellow-500/20` |
| Danger / error | `text-red-400`, `bg-red-500/20` |
| Info / neutral | `text-blue-400`, `bg-blue-500/20` |
Not used: icon tiles, pills for names, cards, centred text blocks, fake browser
windows, emoji. Motion is `transition-colors` only.
### Status Badge Pattern
## Screenshots
```tsx
// Active / online
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
active
</span>
Only real screenshots of netOrk, from the anonymised demo copy
(`scripts/demo`), taken by `scripts/screenshots/capture.py` and published as
WebP in `public/screenshots/`. Their sizes are written to
`src/data/screenshots.json`, which `Shot` reads.
// Offline
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
offline
</span>
- At most four different images on the site. Each is cropped to the one thing
the text next to it talks about (`clip` or `element` in `shots.py`).
- Cropping shows less of a real screen; it never changes what is on it. No
edited data, no clicks that fake a state, no mockups.
- A crop that does not read on a phone gets a `-narrow` variant (`Shot narrow=`).
- Every image has an alt text and a caption that says what is true in it.
// Warning
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
warning
</span>
```
## Wordmark
---
Text only: `net<span class="text-accent">Ork</span>` in ink. `public/logo.png`
is the favicon and OG image; it does not sit well on a light background.
## Typography
## Glossary marks
Font stack: Tailwind default sans-serif (`font-sans`). **No Google Fonts.**
The product uses system fonts; the website must match.
| Element | Classes |
|---|---|
| Hero heading | `text-4xl md:text-6xl font-bold text-slate-100 leading-tight` |
| Section heading | `text-2xl md:text-3xl font-bold text-slate-100` |
| Subsection heading | `text-xl font-semibold text-slate-200` |
| Body text | `text-base text-slate-400 leading-relaxed` |
| Small / label | `text-sm text-slate-400` |
| Tiny / tag | `text-xs font-medium text-slate-500` |
| Code / monospace | `font-mono text-sky-400` |
| Accent text | `text-sky-400` |
---
## Spacing & Layout
- Max content width: `max-w-7xl mx-auto px-6`
- Section padding: `py-24` (desktop), `py-16` (mobile)
- Card padding: `p-6`
- Gap between grid items: `gap-6` or `gap-8`
- All layouts are mobile-first; use `md:` and `lg:` breakpoints.
---
## Components
### Primary CTA Button
```tsx
<a
href="/docs/getting-started"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
bg-sky-600 hover:bg-sky-500 text-white font-medium
transition-colors"
>
Get started
</a>
```
### Secondary / Ghost Button
```tsx
<a
href="/features"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
border border-slate-700 hover:border-slate-500
text-slate-300 hover:text-slate-100
transition-colors"
>
See all features
</a>
```
### Feature Card
```tsx
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center
rounded-lg bg-sky-600/10">
{/* Heroicon SVG, className="h-5 w-5 text-sky-400" */}
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-100">Feature name</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Description of the feature in one to three sentences.
</p>
</div>
```
### Screenshot Frame
App screenshots must be wrapped in this frame to integrate naturally
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" />
</div>
```
Optionally add a browser chrome header above the image:
```tsx
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
<span className="ml-4 text-xs text-slate-500 font-mono">netork.local</span>
</div>
```
### Code Block
```tsx
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6
font-mono text-sm text-slate-300 overflow-x-auto">
<code>{`bash scripts/deploy.sh 192.168.1.1`}</code>
</pre>
```
### Driver / Integration Badge
```tsx
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full
border border-slate-700 bg-slate-900
text-xs font-medium text-slate-300">
OpenWRT
</span>
```
### Section Divider
```tsx
<div className="border-t border-slate-800" />
```
---
## Navigation
- Sticky top nav: `sticky top-0 z-10 bg-slate-900/80 backdrop-blur
border-b border-slate-800`
- Logo: left-aligned. Product name in `font-semibold text-slate-100`,
optionally prefixed with a small icon.
- Nav links: `text-sm text-slate-400 hover:text-slate-100 transition-colors`
- Active link: `text-slate-100`
- CTA in nav: small primary button `px-4 py-1.5 text-sm`
---
## Animations & Transitions
- **Hover states:** always `transition-colors` (not `transition-all`).
- **No JavaScript animations** on initial page load — no entrance animations,
no scroll-triggered reveals via IntersectionObserver.
- Scroll behavior: `scroll-smooth` on `<html>` for anchor links.
- No parallax, no floating elements, no auto-playing videos.
---
## Logo / Wordmark
The netOrk wordmark uses the following convention in the product:
- Lowercase `n`, uppercase `O`: **netOrk**
- Monospace context: `font-mono text-sky-400`
- Heading context: `font-bold text-slate-100` with `Ork` potentially in
`text-sky-400` if desired for emphasis
---
## Iconography
Use Heroicons (inline SVG). Sizes:
- Feature card icons: `h-5 w-5`
- Nav / button icons: `h-4 w-4`
- Hero / large decorative: `h-8 w-8` or `h-10 w-10`
All icons: `text-sky-400` in feature contexts, `text-slate-400` in
secondary/muted contexts.
---
## tailwind.config.js
No custom theme extensions needed. The default Tailwind v3 slate + sky
palette covers everything. The config only needs content paths:
```js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
```
Terms from `src/glossary/terms.ts` get a dotted underline and a dark tooltip
(`linkify`). Not on the homepage — short copy there stays unmarked.
+78 -334
View File
@@ -1,339 +1,83 @@
# netOrk Website — Page Structure & Content Plan
# netOrk website — pages
This document defines every page of the website: its purpose, section
structure, and draft copy. Use this as the brief for implementation.
**Audience:** IT departments in small and mid-sized companies — a small team,
mixed hardware (OPNsense, HPE ProCurve/Aruba, TP-Link JetStream, OpenWrt,
Proxmox, Linux), NIS2 on the agenda. Service providers and IT support are
secondary and get their own page each.
---
**One message:** control instead of drift — define the desired state once;
netOrk notices every change, shows what deviates and puts it back.
## Page Overview
**One goal:** buy a licence. netOrk itself is free; the licence adds
vulnerability data and image updates.
| Route | Page | Priority |
All copy is in `src/i18n/translations.ts`, English and German (German uses
"ihr"). The language follows the browser until someone chooses. Every claim
must be backed by `docs/PRODUCT.md`; automatic fixing exists for access point
profiles, so the site says "every deviation", never "every device fixes itself".
## Word budgets (English, text in `<main>`)
`scripts/check/site.py` counts them; Home over budget fails the check.
| Page | Route | Budget |
|---|---|---|
| `/` | Landing (Home) | P0 — build first |
| `/features` | Full feature list | P1 |
| `/drivers` | Supported devices | P1 |
| `/docs/getting-started` | Installation guide | P1 |
| `/docs/architecture` | Technical overview | P2 |
| `/plugins` | Plugin system | P2 |
---
## `/` — Landing Page
### Section 1 — Hero
**Purpose:** Answer "what is this?" in 5 seconds.
**Layout:** Full-width, centered. Heading + subheading + two CTAs + hero
screenshot below.
**Heading:**
```
Network orchestration
for heterogeneous infrastructure.
```
(`text-slate-100` for first line, second line in `text-sky-400` or keep
both `text-slate-100` — designer decides.)
**Subheading:**
```
netOrk discovers, monitors, and manages your routers, switches, access
points, firewalls, and servers from a single UI — regardless of vendor.
No SaaS dependency. Runs on your infrastructure.
```
**CTAs:**
- Primary: `Get started →` → `/docs/getting-started`
- Secondary: `View features` → `/features`
**Hero visual:** Full-width screenshot of the device inventory page
(dark UI visible, framed with the browser chrome component from DESIGN.md).
---
### Section 2 — Problem Statement
**Purpose:** Make the pain relatable.
**Layout:** Single centered paragraph or short 3-column stat row.
**Copy:**
```
Managing a mixed network means juggling a different admin UI for every
vendor — one for OPNsense, one for HP ProCurve, one for OpenWRT, one for
Proxmox. Config changes happen directly on devices with no audit trail.
You find out something drifted when it breaks.
```
---
### Section 3 — Core Capabilities (3-up)
**Purpose:** Communicate the three main things netOrk does.
**Layout:** 3 columns, each with icon + heading + 2–3 sentences.
**Card 1 — Discover & Inventory**
- Icon: `MagnifyingGlassIcon`
- Heading: `Discover everything on your network`
- Copy: `ICMP sweep, SNMP scan, and HTTP probing find devices before you
add them. Fingerprinting identifies vendor and platform automatically.
Adopt results into your inventory with a single click.`
**Card 2 — Monitor & Alert**
- Icon: `ChartBarIcon` or `SignalIcon`
- Heading: `Poll device state continuously`
- Copy: `Every device is polled on a configurable interval via NAPALM.
Interface status, ARP tables, DHCP leases, VLAN membership, Docker
containers, and SNMP health metrics — all in one place.`
**Card 3 — Configure & Enforce**
- Icon: `WrenchScrewdriverIcon`
- Heading: `Detect drift. Fix it.`
- Copy: `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.`
---
### Section 4 — Driver Grid
**Purpose:** Show breadth of vendor support.
**Layout:** Centered heading + wrapping badge grid.
**Heading:** `Works with your hardware`
**Subheading:**
```
netOrk ships with custom NAPALM drivers for 11 device types, plus all
built-in NAPALM drivers. New drivers follow a documented registration
pattern.
```
**Badge list** (see `docs/PRODUCT.md` — driver table):
OpenWRT, OPNsense, Proxmox VE, Linux, HP ProCurve / Aruba, TP-Link Jetstream,
Netgear, Fritz!Box, Zyxel, OpenMediaVault, Sonos,
Cisco IOS, Arista EOS, Juniper JunOS
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.
**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 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
---
### Section 6 — Plugin System (brief)
**Purpose:** Signal extensibility without going deep.
**Layout:** Dark card, left-aligned.
**Heading:** `Built to extend`
**Copy:**
```
Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng) 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.
```
**CTA:** `Plugin system docs →` → `/plugins`
---
### Section 7 — Deployment (quick)
**Purpose:** Answer "how do I run this?" without going into detail.
**Layout:** Code block + short description.
**Heading:** `Self-hosted. One command.`
**Copy:**
```
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.
```
**Code block:**
```bash
# Clone + configure
git clone https://gitea.example.com/netork/netork.git
cp .env.example .env
# edit .env (DB URL, Redis password, secret key)
# Deploy
bash scripts/deploy.sh 192.168.1.10
```
---
### Section 8 — CTA Footer
**Layout:** Centered, full-width dark section.
**Heading:** `Start managing your network.`
**CTA:** `Read the docs →` → `/docs/getting-started`
---
## `/features` — Full Feature List
**Purpose:** Comprehensive reference for people who want to evaluate in depth.
**Layout:** Vertical list of expandable sections (or just long-scroll with
sticky section nav). One section per capability area.
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
1. Device Management
2. Discovery
3. Supported Drivers (full table)
4. Networking & Inventory
5. Configuration Management & Drift
6. Scheduled Operations
7. Monitoring & Health
8. Security Integrations
9. DNS Management
10. Access Control (RBAC)
11. NetBox Sync
12. Developer Experience
Each section: `text-xl font-semibold text-slate-200` heading +
feature items as a clean list with `text-slate-400` body.
---
## `/drivers` — Supported Devices
**Purpose:** One-page reference for "does netOrk support my device?"
**Layout:** Full table + short description per driver.
**Table columns:** Driver name | Device type | Capabilities | Status
**Capabilities** — checkmarks or tags for:
- `get_facts` `get_interfaces` `get_lldp` `get_vlans` `get_ssids`
`get_health_metrics` `get_docker` `scheduled_reboot` `config_push`
**Status:** `stable` / `beta` / `community` as a badge.
---
## `/docs/getting-started` — Installation
**Purpose:** Get someone from zero to a running instance.
**Sections:**
1. **Prerequisites**
- Docker + Docker Compose
- A PostgreSQL instance (or use the bundled profile)
- Redis
- A Linux host reachable by SSH from the server
2. **Quick start**
```bash
git clone ...
cp .env.example .env
# Edit .env
bash scripts/deploy.sh <server-ip>
```
3. **First run**
- Navigate to `http://<server-ip>`
- Complete the setup wizard (creates admin user)
- Add your first device
4. **Adding a device**
- Fill in hostname/IP, driver, and credentials
- Click Poll to verify connectivity
- Set a poll interval for continuous monitoring
5. **Next steps**
- Configure NetBox sync
- Set up Wazuh integration
- Enable scheduled reboots for OpenWRT APs
---
## `/docs/architecture` — Technical Overview
**Purpose:** Give engineers the mental model before they look at code.
**Content:** Essentially the one-paragraph summary from `docs/PRODUCT.md`
expanded into a readable overview with the architecture diagram (ASCII or SVG).
**Sections:**
1. Overview (request → FastAPI → DB / Celery worker)
2. Driver system (NAPALM + custom drivers + registry)
3. Task queues (which queue does what)
4. Plugin system (register → hook bus → router mount)
5. Data model (UUID PKs, JSONB snapshots, intent-vs-state)
---
## `/plugins` — Plugin System
**Purpose:** Explain extensibility to potential contributors.
**Sections:**
1. What is a plugin? (metadata, router, tasks, hooks)
2. Built-in plugins (Wazuh, Graylog, CrowdSec, apt-cacher)
3. Writing a plugin (step-by-step with code snippets)
4. Hook bus (fire / call / transform)
5. Plugin registry and enable/disable
---
## Global Layout
### Navigation (all pages)
```
[ netOrk ] Features Drivers Docs ▾ Plugins [ Get started ]
```
`Docs` is a dropdown: Getting Started / Architecture
### Footer
```
netOrk — self-hosted network orchestration
Links: Resources: Legal:
Features Getting Started MIT License
Drivers Architecture Privacy (none collected)
Plugins Changelog
```
Footer background: `bg-slate-900 border-t border-slate-800`
Footer text: `text-sm text-slate-500`
| Home | `/` | 400 |
| Pricing | `/pricing` | 300 |
| NIS2 | `/nis2` | 550 |
| Plugins | `/plugins` | 450 |
| Persona ×3 | `/for/it-department`, `/for/it-support`, `/for/msp` | 320 |
| Getting started | `/docs/getting-started` | 80 |
| Glossary, Impressum, Datenschutz | `/glossary`, `/impressum`, `/datenschutz` | — |
Old routes redirect (in `App.tsx` and as 301 in `nginx.conf`): `/features` →
`/#included`, `/drivers` → `/#hardware`, `/roadmap` and `/docs/architecture` → `/`.
## Home
1. **Hero** — "Control instead of drift." The drift comparison of an access
point (`drift`, `drift-narrow` on phones) with a caption saying what is in it.
2. **How it works** (`#how`) — Define → Detect → Fix, three steps.
3. **Hardware** (`#hardware`) — two lines of names: "desired state and fixes"
and "inventory and monitoring"; a note on the untested NAPALM drivers.
4. **Vulnerabilities, with a licence** — the triage queue (`vulnerabilities`),
link to Pricing.
5. **Also in the box** (`#included`) — eight terms, one short line each; a NIS2
row with a link.
6. **Closing band** — "netOrk is free. The licence adds the data."
## Pricing
The free core as one row, then Starter / Pro / Enterprise, each "on request"
with a "Buy a licence" button to the licence portal (`src/data/plans.ts`). Plan
differences come from the licence server's plan defaults; the KB request limit
stays off the page until it has a unit. Four questions below, and the note that
there is no public installer yet.
## Persona pages
One template (`Persona.tsx`): header, three problems, five ways netOrk helps
(desired state and drift first), the shared closing band. The IT-department
page shows the drift screenshot. The service-provider page says where it
stops today.
## NIS2
Article by article (eight rows plus one "out of scope"), a dot and a word for
coverage, what netOrk records along the way, the audit log screenshot. States
plainly that netOrk does not make anyone compliant.
## Plugins, Glossary, Getting started, Impressum, Datenschutz
Plugins: the five included ones with the hosts they talk to, how to write one,
one code example. Glossary: every term from `src/glossary/terms.ts` with a
category index. Getting started: "no public installer yet", write to us.
Impressum and Datenschutz: **placeholders** — the final text must replace the
yellow box before netork.io goes live.
## Navigation and footer
Nav: wordmark · Who it's for ▾ · NIS2 · Plugins · Pricing · DE/EN · "Buy a
licence". Below `lg` a menu button. Footer: product links, persona links,
contact, "no cookies, no tracking, no requests to anyone else", © line with
Impressum and Datenschutz.
+234 -20
View File
@@ -1,5 +1,22 @@
# netOrk — Product Description
## Positioning (since 2026-09)
- **Message:** control instead of drift. Define once how the network should be
set up; netOrk notices every change on access points, switches and firewalls,
shows what deviates and puts it back. Automatic fixing exists for access point
profiles; firewall profiles are compared and applied on demand; switch VLANs
are provisioned centrally; every configuration change is versioned in Git and
flagged when netOrk did not make it.
- **Primary audience:** IT departments in small and mid-sized companies.
- **Licence model:** netOrk itself is free. A licence (Starter / Pro /
Enterprise, price on request, sold through the licence portal) adds
vulnerability data from the netOrk Knowledge Base and image updates. One key
per netOrk instance; no limits on devices, sites or users. Plan differences:
vulnerability history 90 days / 1 year / 10 years, match evidence and CWE
details from Pro, the edge update channel for Enterprise. Source:
license-server plan defaults, mirrored in `src/data/plans.ts`.
## One-liner
**netOrk is a self-hosted network orchestration platform that discovers,
@@ -65,6 +82,25 @@ hardware and want operational visibility beyond what consumer dashboards offer.
8. **Self-hosted, no SaaS** — Runs in Docker Compose. Your data stays on your
infrastructure. No telemetry, no cloud dependency.
9. **NIS2 evidence foundation** — NIS2 Art. 21 mandates asset inventory, patch
management, access control, and audit trails. netOrk produces all of these as
day-to-day operational outputs: full device inventory, per-device update status,
Wazuh CVE tracking, EOL firmware/OS flagging, RBAC with MFA, Git-backed config
snapshots with diff/restore, config drift detection, and a complete audit log.
10. **Build your own view** — Configurable, shareable dashboards: pick from 13
widgets, arrange them on a WYSIWYG grid, and share the result with colleagues
who can subscribe to the live version or clone their own copy.
11. **From zero to managed in one flow** — Provision a Cloud-Init VM on a
Proxmox hypervisor, assign Ansible roles to configure it, and netOrk
auto-links it as a Device — no separate tools, no manual SSH-and-copy.
12. **Reach sites netOrk can't touch directly** — Deploy a lightweight
Satellite agent to poll devices locally at a disconnected or firewalled
site and sync results back over HTTPS; scheduled fixes route through it
the same way they do for directly reachable devices.
---
## Feature List
@@ -76,64 +112,223 @@ 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
manual template or VMID setup
- Multi-distro image catalog: Debian 12, Ubuntu 22.04/24.04/26.04,
Fedora 42/43/44, with Ubuntu and Fedora releases synced automatically as
new versions ship
- Pick a target VLAN and an IP from its subnet — netOrk creates the DHCP
reservation automatically
- Cloud-init provisions a real Linux user with an SSH key, plus configurable
bootstrap toggles (SNMP, QEMU guest agent)
- Reusable provisioning templates for repeatable bootstrap settings
- The new VM is auto-linked as a netOrk Device and its hostname assigned to
a DNS zone once bootstrap finishes
- Deploy progress shown as a live step checklist in the UI
- Delete a VM and its linked netOrk Device together, gated behind a
name-confirmation prompt
### Supported Device Drivers
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
- SSID management with push to OpenWRT APs via UCI
- Per-SSID MAC access control lists (whitelist / blacklist) pushed to every
AP broadcasting the SSID, quick-add straight from the Connected Clients list
- MAC ACL state is a first-class drift item — covered by the same drift
detection, scheduled auto-fix, and warning aggregation as any other config
drift
- Dedicated Access Control Lists tab on the Wireless page listing every SSID
with its ACL editor inline
### Configuration Management
- Config drift detection: desired state (DB) vs device state (poll snapshot)
- One-click drift fix stream with live SSH output in the browser
- 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 captures a config snapshot into
a local Git repository, with full history and a side-by-side diff viewer
between any two points in time
- One-click config restore for OPNsense from any prior snapshot
- Unauthorised configuration changes are surfaced as a device warning
- netOrk's own config pushes (drift fixes, ACL provisioning) are recognized
and auto-accepted as the new baseline — never mistaken for an unauthorized
change
### Configuration Automation (Ansible)
- Reusable Ansible roles and playbooks stored and edited directly in
netOrk — no separate git checkout
- 16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
portainer, watchtower, uptime-kuma, vaultwarden, stalwart, bulwark, searxng,
postiz, listmonk, wireguard, fail2ban
- Roles state their resource needs; undersized hosts are refused with a reason
- Automatic dependency resolution — assigning `docker` pulls in `base`
automatically, no manual role ordering
- Built-in roles can't be deleted but are fully editable; customizations
survive upgrades, and only untouched files auto-heal on bugfixes
- `ansible-doc`-backed autocomplete while writing roles and playbooks
- Upload your own role as an archive
- Device-level role assignment with a dedicated Ansible tab on the device
detail page
- Run history per device, snapshotting the exact role/playbook content
that was executed
- Wired into VM provisioning: assign roles at VM-creation time and they
run automatically after boot
### Scheduled Operations
- Scheduled reboots for OpenWRT APs with per-site concurrency lock
- Failback cron script written to device for netOrk-unreachable scenarios
- Scheduled config drift fixes with time-window enforcement
- Package update scheduling and one-click apply
- Wake-on-LAN via a firewall's driver (OPNsense today) — saved WOL targets
with on-demand "Wake now" and recurring schedules; save a seen host as a
target directly from the DHCP/ARP tabs
### Satellite Deployments
- Lightweight Docker agent deployed at a site netOrk can't reach directly —
polls devices locally and syncs results back to Central over HTTPS
- Deployed in one flow via VM provisioning: pick a hypervisor and site,
netOrk provisions the VM and installs the satellite container automatically
- Central automatically skips direct polling for any device at a site with
an online, heartbeating satellite — no manual per-site toggling
- Scheduled/on-demand reboots and the SNMP auto-fix flow run through the
same command channel whether a device is directly reachable or behind a
satellite
- Discovery jobs at a satellite-covered site scan locally through the same
command channel, instead of failing to reach the subnet from Central
- Not yet satellite-covered: SNMP health-metric polling still runs from
Central, and WebSSH console access isn't available through a satellite
### Monitoring & Health
- SNMP health metrics (CPU, memory, interface counters) via `get_health_metrics()`
- Per-device warning system with severity levels (error / warning / info)
- Docker container and image status (Proxmox/Linux)
- One-click Ack on any warning — clears it immediately and writes an audit log
entry; for config-change warnings the current state is accepted as the new
baseline
- Docker container and image status (Linux, OpenMediaVault, QNAP)
- Service status and start/stop/restart (systemd)
- VM/container list with OS device cross-linking (Proxmox)
- Per-device availability windows — suppress OFFLINE status and poll-failure
warnings during expected downtime (e.g. a nightly power-off); opt-in,
unconfigured devices are unaffected
- OPNsense: BGP neighbor status polling and display, with a peer-down warning
- OPNsense: TLS certificate monitoring for the Trust store, with
expiring-soon / expired warnings
- OPNsense: Dynamic DNS service-down warning (os-ddclient)
- Service checks about once a minute (DNS, NTP, VPN tunnels, core daemons,
gateways), derived automatically; three failures before an alert; can run
from satellites, including a DHCP check
- Site reachability: polling pauses behind a dead tunnel, one warning names
it, everything is re-polled when it returns
### Dashboards
- Configurable, shareable dashboards — build your own from a widget picker
instead of a fixed layout
- WYSIWYG grid-layout editor: drag, resize, and arrange widgets on a canvas
- 18 widget types: stats, device warnings, recently updated devices, network
topology, EOL status, config drift summary, Wazuh security alerts, audit log
activity, discovery jobs status, upcoming scheduled actions, DNS zones
overview, site overview, config snapshot history, managed services,
certificate expiry, outdated Docker images, firewall profile deployment
status, service checks
- Multi-instance widgets with independent per-widget settings
- Share a dashboard with specific users; recipients can subscribe to the
owner's live version or clone it into their own editable copy
- Favorite dashboards for quick access from the main menu; set any dashboard
as your home view
### Notifications
- Signal messages for everything netOrk watches; each person registers their
own number, administrators pair netOrk once via QR code
- One message per site outage, daily summary for recurring items, hourly
bundling, quiet hours per number, mute per kind, full history with reasons
### DHCP
- DHCP reservations: import from the firewall, validated, diff, then apply
(adds and updates only)
- DHCP subnets (Kea on OPNsense) with options and search domains; settings
that break a network are refused
### Managed Services
- Every container-based service across devices with endpoints, TLS
certificates and access rules
- Compose editor with masked secrets and automatic backup snapshot; redeploy
is a separate confirmed step
- Zoraxy vhosts editable and written back; PostgreSQL databases listed
### Security Assessment
- Security tab per device: TLS/SSH grades A–F, installed software and
container images matched against known vulnerabilities, hardening benchmarks
- Ratings adjusted to the device (local access, trusted network, not running,
not booted kernel; raised when exploited in the wild)
- Kernel reboot recommendation with the vulnerabilities it would clear
- Exposure from firewall rules; internet-visible ports and abuse reports for
own public addresses; on-demand hardening audit and web scan
- Vulnerability data from the netOrk Knowledge Base (licence required)
### Vulnerability Management
- Triage queue across all devices, one row per vulnerability, ordered by
remediation deadline, exploitation, severity, likelihood, criticality, spread
- Decisions (not applicable / accept until / defer until / fixed) with a
mandatory reason; accept and not-applicable need an elevated permission
- Deferred and accepted items return by themselves; ignored ones go overdue
- Daily reassessment verifies fixes and reopens regressions
### Security Integrations (plugins)
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
recent alert history, CIS benchmark scores, one-click agent install fix stream
- **Graylog** — rsyslog forwarding status per device, one-click fix to write rule
- **CrowdSec** — org-level decisions, remediation metrics, top attack scenarios
- **EOL Tracking** — flags devices running end-of-life or soon-to-be-end-of-life
firmware/OS via the endoflife.date API, checked daily
### DNS
- DNS zone management with authoritative device assignment
@@ -141,11 +336,26 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
- PTR record provisioning to reverse zones
- Pending job queue for zone changes when device is unreachable
### RADIUS Management
- Global FreeRADIUS server, NAS client, and user management — no site
scoping, usable from any site's SSIDs/APs
- Changes push to the device via the driver and are only stored after the
device confirms — avoids drift between netOrk's view and the actual
FreeRADIUS config
- Dedicated server list and detail page (NAS clients / users tabs) under
the Wireless section
- SSID 802.1X integration (auto-provisioning a NAS client from an SSID's
RADIUS server) is a deliberate follow-up, not included yet
### Access Control
- JWT authentication with remember-me (localStorage) or session-only (sessionStorage)
- Two-factor authentication (MFA/TOTP) — authenticator app at login, backup
codes for emergencies, session invalidation on TOTP changes, enforceable
per role
- RBAC with four built-in roles: viewer / operator / engineer / administrator
- Custom roles with any permission combination
- Full audit log of all orchestration actions
- Full audit log of all orchestration actions, filterable by date range,
user, action, or resource — export to CSV or PDF
### NetBox Sync
- Pushes vendor, model, OS version, status to NetBox dcim.devices
@@ -163,11 +373,15 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
## Architecture in One Paragraph
netOrk runs as five Docker containers: a FastAPI API server, two Celery worker
pools (general + poll), a Celery Beat scheduler, and an nginx UI server. Redis
is the broker. PostgreSQL stores all state. Device communication is always
blocking I/O executed in Celery workers — FastAPI request handlers are
async-only for DB and quick operations. Custom NAPALM drivers live in `vendor/`
as editable packages and self-register via `@register_driver`. The plugin system
(`netork/plugins/`) provides a hook bus, a plugin registry with enable/disable
state in the DB, and a documented pattern for adding integrations.
netOrk runs as a set of Docker containers: a FastAPI API server, three Celery
worker pools (general, poll, and Ansible), a Celery Beat scheduler, and an
nginx UI server. Redis is the broker. PostgreSQL stores all state. Device
communication is always blocking I/O executed in Celery workers — FastAPI
request handlers are async-only for DB and quick operations. Custom NAPALM
drivers live in `vendor/` as editable packages and self-register via
`@register_driver`. The plugin system (`netork/plugins/`) provides a hook bus,
a plugin registry with enable/disable state in the DB, and a documented
pattern for adding integrations. For sites Central can't reach directly, a
separate Satellite container polls devices locally and syncs results back
over HTTPS; Central dispatches actions (reboots, SNMP fixes) to it through a
generic command channel, transparently to the UI.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="de" class="motion-safe:scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FAFAF9" />
<meta name="description" content="netOrk: define once how your network should be set up. It notices every change on access points, switches and firewalls, shows what deviates and puts it back. Self-hosted, across vendors." />
<title>netOrk — Control instead of drift</title>
<link rel="icon" href="/logo.png" />
<meta property="og:image" content="/logo.png" />
<meta property="og:title" content="netOrk — Control instead of drift" />
<meta property="og:description" content="Self-hosted network automation for IT teams: one desired state, every deviation found and fixed." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
# Relative Location headers: TLS ends at Zoraxy, so an absolute redirect
# built here would point at http://.
absolute_redirect off;
# Pages folded into the homepage (see App.tsx for the same list client-side).
location = /features { return 301 /#included; }
location = /drivers { return 301 /#hardware; }
location = /roadmap { return 301 /; }
location = /docs/architecture { return 301 /; }
# SPA fallback — all routes resolve to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Immutable cache for hashed assets (Vite adds content hashes)
location ~* \.(js|css|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 30d;
add_header Cache-Control "public";
}
}
+2742
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "netork-website",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@heroicons/react": "^2.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Check the built site the way a visitor meets it.
npm run build && npx vite preview --port 4173 &
scripts/check/site.py [--base http://127.0.0.1:4173] [--out .check]
Every route in both languages at 360, 390, 768 and 1440 px:
- no sideways scrolling, exactly one h1, every image loaded with alt and size
- no console errors, no request to any other origin
- internal links only to routes that exist
Plus: the old URLs redirect, the language follows the browser until someone
chooses, and the word count of each page (Home EN fails above its budget).
Full-page PNGs land in --out for looking at.
"""
import argparse
import sys
from pathlib import Path
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
ROUTES = ["/", "/pricing", "/nis2", "/plugins", "/glossary", "/docs/getting-started",
"/for/it-department", "/for/it-support", "/for/msp", "/impressum", "/datenschutz"]
REDIRECTS = {"/features": "/#included", "/drivers": "/#hardware", "/roadmap": "/", "/nope": "/"}
WIDTHS = [360, 390, 768, 1440]
# EN word budgets from docs/PAGES.md; only Home is a hard failure.
BUDGETS = {"/": 400, "/pricing": 300, "/nis2": 550, "/plugins": 450, "/docs/getting-started": 80,
"/for/it-department": 320, "/for/it-support": 320, "/for/msp": 320}
EXTERNAL_OK = ("https://license.netork.io/", "mailto:")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--base", default="http://127.0.0.1:4173")
ap.add_argument("--out", type=Path, default=Path(".check"))
args = ap.parse_args()
base = args.base.rstrip("/")
origin = urlparse(base).netloc
args.out.mkdir(parents=True, exist_ok=True)
problems: list[str] = []
words: dict[tuple[str, str], int] = {}
with sync_playwright() as p:
browser = p.chromium.launch()
for lang in ("en", "de"):
for width in WIDTHS:
ctx = browser.new_context(viewport={"width": width, "height": 900})
ctx.add_init_script(f"localStorage.setItem('lang', '{lang}')")
page = ctx.new_page()
errors: list[str] = []
foreign: set[str] = set()
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
page.on("request", lambda r: foreign.add(r.url) if urlparse(r.url).netloc not in (origin, "") and not r.url.startswith("data:") else None)
for route in ROUTES:
where = f"{route} [{lang} {width}px]"
errors.clear()
page.goto(base + route)
page.wait_for_load_state("networkidle")
# Scroll through so lazy images load.
height = page.evaluate("document.documentElement.scrollHeight")
for y in range(0, height, 600):
page.evaluate(f"window.scrollTo(0, {y})")
page.wait_for_timeout(40)
page.wait_for_timeout(300)
page.evaluate("window.scrollTo(0, 0)")
overflow = page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth")
if overflow > 0:
problems.append(f"{where}: {overflow}px sideways overflow")
h1 = page.locator("h1").count()
if h1 != 1:
problems.append(f"{where}: {h1} h1 elements")
bad_imgs = page.evaluate("""[...document.images].filter(i =>
!i.alt || !i.getAttribute('width') || !i.getAttribute('height') || !i.complete || i.naturalWidth === 0
).map(i => i.currentSrc || i.src)""")
for src in bad_imgs:
problems.append(f"{where}: image missing alt/size or not loaded: {src}")
for e in errors:
problems.append(f"{where}: console: {e[:160]}")
hrefs = page.evaluate("[...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href'))")
for href in hrefs:
if href.startswith(EXTERNAL_OK):
continue
if href.startswith(("http:", "https:")):
problems.append(f"{where}: unexpected external link {href}")
continue
path = href.split("#")[0] or "/"
if path.startswith("/") and path not in ROUTES:
problems.append(f"{where}: link to unknown route {href}")
if width == 1440:
words[(route, lang)] = page.evaluate("document.querySelector('main').innerText.split(/\\s+/).filter(Boolean).length")
name = route.strip("/").replace("/", "-") or "home"
page.screenshot(path=str(args.out / f"{name}-{lang}.png"), full_page=True)
if width == 390:
name = route.strip("/").replace("/", "-") or "home"
page.screenshot(path=str(args.out / f"{name}-{lang}-390.png"), full_page=True)
for url in sorted(foreign):
problems.append(f"[{lang} {width}px]: request to another origin: {url}")
ctx.close()
# Old URLs.
ctx = browser.new_context()
page = ctx.new_page()
for old, target in REDIRECTS.items():
page.goto(base + old)
page.wait_for_load_state("networkidle")
got = urlparse(page.url)
landed = got.path + (f"#{got.fragment}" if got.fragment else "")
if landed != target:
problems.append(f"redirect {old}: landed on {landed}, expected {target}")
ctx.close()
# Language: the browser decides until someone chooses.
for locale, stored, expected in [("de-DE", None, "de"), ("en-US", None, "en"), ("fr-FR", None, "en"),
("de-DE", "en", "en")]:
ctx = browser.new_context(locale=locale)
if stored:
ctx.add_init_script(f"localStorage.setItem('lang', '{stored}')")
page = ctx.new_page()
page.goto(base + "/")
page.wait_for_load_state("networkidle")
got = page.evaluate("document.documentElement.lang")
stored_after = page.evaluate("localStorage.getItem('lang')")
if got != expected:
problems.append(f"language: locale {locale}, stored {stored}: got {got}, expected {expected}")
if not stored and stored_after is not None:
problems.append(f"language: locale {locale}: a choice was stored without anyone choosing")
ctx.close()
browser.close()
print("words in <main> (EN / DE, budget):")
for route in ROUTES:
budget = BUDGETS.get(route)
en, de = words.get((route, "en"), 0), words.get((route, "de"), 0)
flag = " OVER" if budget and en > budget else ""
print(f" {route:24} {en:5} / {de:5} {budget or '-'}{flag}")
if words.get(("/", "en"), 0) > BUDGETS["/"]:
problems.append(f"Home EN has {words[('/', 'en')]} words, budget {BUDGETS['/']}")
if problems:
print(f"\n{len(problems)} problems:")
for pr in problems:
print(f" {pr}")
return 1
print("\nno problems")
return 0
if __name__ == "__main__":
sys.exit(main())
+29
View File
@@ -0,0 +1,29 @@
# 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.
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
keys on an SSID against the key read from the access point). Those become a
keyed hash, so equal stays equal and the drift view shows the real state
instead of invented deviations. The key exists only for one run.
- 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.
+470
View File
@@ -0,0 +1,470 @@
#!/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 secrets
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 -------------------------------------------------------
@staticmethod
def _secret(m: re.Match) -> str:
name, sep = m.group(1), m.group(2)
if name in SECRET_JSON_KEEP:
return m.group(0)
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
if not raw or not COMPARED_SECRET.match(name):
return f'"{name}"{sep}""'
value = json.loads(f'"{raw}"') # the value as the column would hold it
return f'"{name}"{sep}"{secret_token(value)}"'
def text(self, s: str) -> str:
s = SECRET_JSON.sub(self._secret, 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"}
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
# the key read from the access point). Emptying them would invent drift that never
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
# reversed, and the key lives only for this run.
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
RUN_KEY = secrets.token_hex(32)
def secret_token(value: str) -> str:
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
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
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
if not n:
continue
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
print(f" {t}.{c}: {n} replaced by keyed hash")
if not dry:
await con.execute(
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
continue
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
if dt == "bytea" and nullable != "YES":
value = "''::bytea"
print(f" {t}.{c}: {n} emptied")
if not dry:
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
# Settings flagged secret keep their key, lose their value.
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
print(f" settings: {n} secret values emptied")
if not dry:
await con.execute("UPDATE settings SET value = '' WHERE is_secret")
for t in SECRET_TABLES:
if await con.fetchval("SELECT to_regclass($1) IS NOT NULL", f"public.{t}"):
n = await con.fetchval(f'SELECT count(*) FROM "{t}"')
print(f" {t}: {n} rows deleted")
if not dry:
await con.execute(f'DELETE FROM "{t}"')
async def rewrite(con, mapper: Mapper, cfg: dict, dry: bool) -> None:
pat = prefilter(cfg)
by_table: dict[str, list[tuple[str, str]]] = {}
for t, c, dt in await columns(con):
by_table.setdefault(t, []).append((c, dt))
for table, cols in by_table.items():
for col, dt in cols:
q = f'SELECT ctid, "{col}"::text AS v FROM "{table}" WHERE "{col}"::text ~ $1'
rows = await con.fetch(q, pat)
updates = []
for r in rows:
new = mapper.text(r["v"])
if new != r["v"]:
updates.append((new, r["ctid"]))
if not updates:
continue
print(f" {table}.{col}: {len(updates)} rows")
if dry:
continue
cast = {"jsonb": "::jsonb", "json": "::json", "inet": "::inet", "cidr": "::cidr",
"macaddr": "::macaddr"}.get(dt, "")
if dt == "ARRAY":
udt = await con.fetchval(
"SELECT udt_name FROM information_schema.columns "
"WHERE table_name = $1 AND column_name = $2", table, col)
cast = f"::{udt.lstrip('_')}[]"
await con.executemany(
f'UPDATE "{table}" SET "{col}" = $1{cast} WHERE ctid = $2', updates)
async def reset_users(con, cfg: dict, dry: bool) -> None:
sys.path.insert(0, str(Path(cfg["netork_src"]).expanduser()))
from netork.core.security import hash_password # noqa: E402
admin = cfg.get("admin_from", "chris")
password = cfg.get("admin_password", "netork-demo")
users = await con.fetch("SELECT id, username FROM users ORDER BY username")
print(f" users: {[u['username'] for u in users]}")
if dry:
return
n = 0
for u in users:
if u["username"] == admin:
await con.execute(
"UPDATE users SET username = 'netork', email = $2, hashed_password = $3, "
"totp_secret = NULL, totp_enabled = false, token_version = token_version + 1 "
"WHERE id = $1", u["id"], f"netork@{DEMO_DOMAIN}", hash_password(password))
else:
n += 1
await con.execute(
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
# TOTP secrets are gone, so a role that demands MFA would lock everyone out.
await con.execute("UPDATE roles SET require_mfa = false")
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
if role:
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
print(f" admin '{admin}' is now 'netork' / '{password}'")
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
# leak_terms and domains in any case.
names = [n for n in [*cfg.get("hostnames", {}), *cfg.get("terms", {}), *cfg.get("substrings", {})]
if len(n) >= 4]
loose = [n for n in [*cfg.get("domains", {}), *cfg.get("leak_terms", [])] if len(n) >= 4]
# Postgres has no inline (?i:...), so spell case-insensitivity out: [mM][aA]...
def anycase(t: str) -> str:
return "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else re.escape(c) for c in t)
parts = [re.escape(n) for n in names] + [anycase(n) for n in loose]
if not parts:
return 0
pat = "|".join(parts)
found = 0
for t, c, _ in await columns(con):
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
if n:
found += n
sample = await con.fetchval(
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
return found
async def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dsn", default=os.environ.get("DEMO_DSN", DEFAULT_DSN))
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--report-only", action="store_true", help="only run the leak report")
args = ap.parse_args()
host = re.search(r"@([^:/]+)", args.dsn)
if not host or host.group(1) not in ("127.0.0.1", "localhost", "::1"):
sys.exit("Refusing: this only runs against a local copy.")
cfg = json.loads(args.map.read_text())
mapper = Mapper(cfg)
originals = [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
*cfg.get("leak_terms", [])]
con = await asyncpg.connect(args.dsn)
try:
if not args.report_only:
async with con.transaction():
print("secrets:")
await scrub_secrets(con, args.dry_run)
print("users:")
await reset_users(con, cfg, args.dry_run)
await collect_public(con, mapper)
print(f"public addresses seen as addresses: {len(mapper.known_public)}")
print("rewriting:")
await rewrite(con, mapper, cfg, args.dry_run)
print("ipv4 /16 mapping:", json.dumps(mapper.prefix16))
print("public addresses mapped:", len(mapper.public))
if mapper.unmapped_public:
top = sorted(mapper.unmapped_public.items(), key=lambda x: -x[1])[:40]
print("left as is (versions? add real ones to public_ips in the map):")
print(" " + ", ".join(f"{ip} ({n}x)" for ip, n in top))
print("leak report:")
n = await leak_report(con, cfg, originals)
if not args.report_only and mapper.unmapped_public:
print(f" review: {len(mapper.unmapped_public)} public-looking dotted quads left as is (listed above)")
print(" clean" if n == 0 else f" {n} rows still match")
finally:
await con.close()
if __name__ == "__main__":
asyncio.run(main())
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Local netOrk demo instance for website screenshots.
#
# up.sh restore <dump> fresh demo DB from a pg_dump -Fc file, then anonymize
# up.sh start API on :8000 and UI on :5173 (foreground, Ctrl-C stops)
# up.sh stop stop the demo database container
#
# Only the API and the UI run: no Celery worker, no beat, no Redis. Nothing
# polls, nothing reboots, nothing reaches a device. Stored credentials are
# emptied by anonymize.py and the encryption key is a fresh random one, so
# even a leftover value could not be decrypted.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DEMO="${NETORK_DEMO_DIR:-$HOME/.cache/netork-demo}"
SRC="$DEMO/src"
VENV="${NETORK_VENV:-$HOME/dev/NetOrk/.venv}"
VERSION="${NETORK_DEMO_VERSION:-v0.28.0}"
NETORK_REPO="${NETORK_REPO:-$HOME/dev/NetOrk}"
DB=netork-demo-db
PORT=55432
ensure_src() {
if [ ! -d "$SRC/netork" ]; then
mkdir -p "$SRC"
git -C "$NETORK_REPO" archive "$VERSION" | tar -x -C "$SRC"
fi
}
ensure_db() {
if ! docker ps --format '{{.Names}}' | grep -qx "$DB"; then
docker start "$DB" 2>/dev/null || docker run -d --name "$DB" \
-p 127.0.0.1:$PORT:5432 -e POSTGRES_DB=netork -e POSTGRES_USER=netork \
-e POSTGRES_PASSWORD=demo -v netork-demo-pg:/var/lib/postgresql/data postgres:16-alpine
until docker exec "$DB" pg_isready -U netork -q; do sleep 1; done
fi
}
case "${1:-}" in
restore)
dump="${2:?usage: up.sh restore <dump file>}"
ensure_src; ensure_db
docker exec "$DB" psql -U netork -d postgres -q \
-c "DROP DATABASE IF EXISTS netork WITH (FORCE)" -c "CREATE DATABASE netork"
docker exec -i "$DB" pg_restore -U netork -d netork --no-owner --no-privileges < "$dump" \
|| echo "pg_restore reported errors (often only missing roles/extensions); checking ..."
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
echo "dump schema: $got $VERSION head: $want"
# Anonymize first: it empties every secret, so a downgrade that would
# have to decrypt something (with a key we do not have) finds nothing.
"$VENV/bin/python" "$HERE/anonymize.py"
if [ "$got" != "$want" ]; then
# The production instance runs a newer build. Walk the copy back to the
# release with the newer code's own downgrade migrations.
NEWER="${NETORK_NEWER_REF:-origin/main}"
echo "migrating the copy from $got back to $want with $NEWER's migrations"
rm -rf "$DEMO/src-newer"; mkdir -p "$DEMO/src-newer"
git -C "$NETORK_REPO" archive "$NEWER" | tar -x -C "$DEMO/src-newer"
# Rows the older schema cannot hold: CrowdSec blocklist alerts whose scope
# is a list name, longer than the column they go back into.
docker exec "$DB" psql -U netork -q -c \
"DELETE FROM crowdsec_alerts WHERE length(source_scope) > 32" 2>/dev/null || true
(cd "$DEMO/src-newer" && PATH="$VENV/bin:$PATH" \
DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork" alembic downgrade "$want")
"$VENV/bin/python" "$HERE/anonymize.py" --report-only
fi
;;
start)
ensure_src; ensure_db
[ -d "$SRC/ui/node_modules" ] || (cd "$SRC/ui" && npm ci --no-audit --no-fund)
export DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork"
export ENVIRONMENT=development
export SECRET_KEY="$(openssl rand -hex 32)"
export CREDENTIAL_ENCRYPTION_KEY="$("$VENV/bin/python" -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
# Nothing listens on port 1: no task can be queued, so no worker could act.
export REDIS_URL=redis://127.0.0.1:1/0 CELERY_BROKER_URL=redis://127.0.0.1:1/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:1/1
cd "$SRC"
"$VENV/bin/uvicorn" netork.api.main:app --host 127.0.0.1 --port 8000 &
api=$!
trap 'kill $api 2>/dev/null' EXIT
cd ui && npx vite --host 127.0.0.1 --port 5173 --strictPort
;;
stop)
docker stop "$DB"
;;
*)
sed -n '2,12p' "$0"; exit 1 ;;
esac
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Deploy the marketing site to the host that actually serves it.
#
# ./scripts/deploy.sh [--version=<tag>] [server]
#
# This used to be a CI job. It ran `docker run` on whichever runner picked the
# job up, which was correct while exactly one runner existed — there are now
# several, none of them on the host this site runs on, so the job would have
# started a second container in the wrong place and reported success. Naming the
# target is the whole point of this script.
set -euo pipefail
SERVER="${DEPLOY_SERVER:-10.7.224.11}"
REGISTRY="${REGISTRY:-git.netork.io/netork}"
VERSION="${VERSION:-latest}"
NAME="${CONTAINER_NAME:-netork-website}"
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/deploy.env"
# shellcheck source=/dev/null
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
for arg in "$@"; do
case "$arg" in
--version=*) VERSION="${arg#--version=}" ;;
*) SERVER="$arg" ;;
esac
done
IMAGE="${REGISTRY}/website:${VERSION}"
echo "[${SERVER}] Deploying ${IMAGE}"
if [[ -n "${REGISTRY_TOKEN:-}" ]]; then
ssh -n "$SERVER" "echo '${REGISTRY_TOKEN}' | docker login git.netork.io -u '${REGISTRY_USER:-christianmanivong}' --password-stdin" \
| sed "s/^/[${SERVER}] /"
fi
# Pull first, and let a failure stop the script here: the container is only
# removed once there is something to replace it with.
echo "[${SERVER}] Pulling..."
ssh -n "$SERVER" "docker pull '${IMAGE}'" | tail -2 | sed "s/^/[${SERVER}] /"
echo "[${SERVER}] Recreating..."
ssh -n "$SERVER" "docker rm -f '${NAME}' >/dev/null 2>&1 || true; \
docker run -d --name '${NAME}' --restart unless-stopped --network proxy-net '${IMAGE}' >/dev/null && echo started" \
| sed "s/^/[${SERVER}] /"
# `docker run` cannot silently reuse an old container the way `compose up -d`
# can, but the tag it resolved might still not be the one that was just pulled.
# Compare, rather than trust.
echo "[${SERVER}] Verifying..."
WANT=$(ssh -n "$SERVER" "docker image inspect --format '{{.Id}}' '${IMAGE}'")
GOT=$(ssh -n "$SERVER" "docker inspect --format '{{.Image}}' '${NAME}'")
if [[ "$WANT" != "$GOT" ]]; then
echo "[${SERVER}] ERROR: container runs ${GOT}, expected ${WANT}" >&2
exit 1
fi
echo "[${SERVER}] Verified: ${NAME} runs ${IMAGE}."
echo "[${SERVER}] Checking the site answers..."
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://netork.io/ || echo 000)
echo "[${SERVER}] https://netork.io -> ${CODE}"
[[ "$CODE" == "200" ]] || { echo "[${SERVER}] ERROR: site is not answering 200" >&2; exit 1; }
+244
View File
@@ -0,0 +1,244 @@
#!/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> [--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"))
SITE = Path(__file__).resolve().parents[2]
OUT = SITE / "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) -> dict[str, int]:
"""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")
return {"width": img.width, "height": img.height}
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] = []
sizes_file = SITE / "src" / "data" / "screenshots.json"
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
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}")
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
page.set_viewport_size({"width": vw, "height": vh})
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.clip:
x, y, w, h = shot.clip
clip = {"x": x, "y": y, "width": w, "height": h}
elif shot.element:
boxes = []
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
el = page.locator(sel).first
el.scroll_into_view_if_needed()
b = el.bounding_box()
if not b:
sys.exit(f"{shot.name}: element not found: {sel}")
boxes.append(b)
left = min(b["x"] for b in boxes)
top = min(b["y"] for b in boxes)
right = max(b["x"] + b["width"] for b in boxes)
bottom = max(b["y"] + b["height"] for b in boxes)
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
clip = {"x": x0, "y": y0,
"width": min(vw - x0, box["width"] + 2 * shot.pad),
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
mask_color="#334155", animations="disabled")
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
browser.close()
# The site reads these to reserve the right space for each image.
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
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()
+62
View File
@@ -0,0 +1,62 @@
"""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 to a region (x, y, width, height in CSS px of the viewport) ...
clip: tuple[int, int, int, int] | None = None
# ... or to one element, plus `pad` px around it. Cropping shows less of a
# real screen; it never changes what is on it.
element: str | list[str] | None = None # several: crop to what they cover together
pad: int = 16
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
viewport: tuple[int, int] | None = None
# Width of the published WebP in pixels (captures are taken at 2x).
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)
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
# the end of the table. The card around it stretches to the window height.
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
SHOTS: list[Shot] = [
# Home hero: an access point checked against its profile, wide and short.
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
pad=28, viewport=(1440, 900), width=2400),
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
"xpath=//tbody//span[contains(., 'Incomplete')]",
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
pad=16, viewport=(1180, 900), width=1200),
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
# 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", clip=(256, 40, 1344, 560), width=2400, clicks=[
"tbody td >> text=scheduler",
"button[title='Click to toggle include/exclude']",
]),
]
+45
View File
@@ -0,0 +1,45 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Nav from './components/Nav'
import Footer from './components/Footer'
import ScrollManager from './components/ScrollManager'
import Home from './pages/Home'
import Pricing from './pages/Pricing'
import Persona from './pages/Persona'
import Nis2 from './pages/Nis2'
import Plugins from './pages/Plugins'
import Glossary from './pages/Glossary'
import GettingStarted from './pages/GettingStarted'
import Legal from './pages/Legal'
export default function App() {
return (
<BrowserRouter>
<ScrollManager />
<div className="flex min-h-screen flex-col">
<Nav />
<main className="flex-1">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/for/it-department" element={<Persona id="itDepartment" />} />
<Route path="/for/it-support" element={<Persona id="itSupport" />} />
<Route path="/for/msp" element={<Persona id="msp" />} />
<Route path="/nis2" element={<Nis2 />} />
<Route path="/plugins" element={<Plugins />} />
<Route path="/glossary" element={<Glossary />} />
<Route path="/docs/getting-started" element={<GettingStarted />} />
<Route path="/impressum" element={<Legal page="imprint" />} />
<Route path="/datenschutz" element={<Legal page="privacy" />} />
{/* Pages that were folded into the homepage. nginx.conf answers the same paths with a 301. */}
<Route path="/features" element={<Navigate to="/#included" replace />} />
<Route path="/drivers" element={<Navigate to="/#hardware" replace />} />
<Route path="/roadmap" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<Footer />
</div>
</BrowserRouter>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { Link } from 'react-router-dom'
import { useLang } from '../context/LangContext'
import { CONTACT_EMAIL } from '../i18n/translations'
import { Container } from './ui'
import { Wordmark } from './Nav'
const linkCls = 'text-ink-muted transition-colors hover:text-ink'
export default function Footer() {
const { t } = useLang()
const f = t.footer
const columns = [
{
title: f.product,
links: [
{ to: '/pricing', label: t.nav.pricing },
{ to: '/nis2', label: t.nav.nis2 },
{ to: '/plugins', label: t.nav.plugins },
{ to: '/glossary', label: f.glossary },
{ to: '/docs/getting-started', label: f.gettingStarted },
],
},
{
title: f.forWhom,
links: [
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
{ to: '/for/msp', label: t.nav.forItems.msp },
],
},
]
return (
<footer className="mt-auto border-t border-line">
<Container className="py-14">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1.5fr]">
<div>
<Link to="/">
<Wordmark />
</Link>
<p className="mt-3 text-sm text-ink-muted">{f.tagline}</p>
</div>
{columns.map((col) => (
<div key={col.title} className="text-sm">
<p className="font-medium text-ink">{col.title}</p>
<ul className="mt-3 space-y-2">
{col.links.map((l) => (
<li key={l.to}>
<Link to={l.to} className={linkCls}>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
<div className="text-sm">
<p className="font-medium text-ink">{f.contact}</p>
<p className="mt-3">
<a href={`mailto:${CONTACT_EMAIL}`} className={linkCls}>
{CONTACT_EMAIL}
</a>
</p>
<p className="mt-3 text-ink-muted">{f.noTracking}</p>
</div>
</div>
<div className="mt-12 flex flex-wrap gap-x-6 gap-y-2 border-t border-line pt-6 text-sm text-ink-faint">
<span>© 2026 netOrk</span>
<Link to="/impressum" className="transition-colors hover:text-ink">
{f.imprint}
</Link>
<Link to="/datenschutz" className="transition-colors hover:text-ink">
{f.privacy}
</Link>
</div>
</Container>
</footer>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useLang } from '../context/LangContext'
import { GLOSSARY } from '../glossary/terms'
const BY_ID = new Map(GLOSSARY.map((entry) => [entry.id, entry]))
const TOOLTIP_WIDTH = 256 // px, matches w-64
const VIEWPORT_MARGIN = 16 // px, keep clear of the screen edge
type Placement = 'start' | 'center' | 'end'
const PLACEMENT_CLASS: Record<Placement, string> = {
start: 'left-0',
center: 'left-1/2 -translate-x-1/2',
end: 'right-0',
}
export default function GlossaryMark({ id, children }: { id: string; children: ReactNode }) {
const { lang } = useLang()
const entry = BY_ID.get(id)
const anchorRef = useRef<HTMLAnchorElement>(null)
const [placement, setPlacement] = useState<Placement>('center')
if (!entry) return <>{children}</>
const name = entry.fullName ? entry.fullName[lang] : entry.display
function handleEnter() {
const rect = anchorRef.current?.getBoundingClientRect()
if (!rect) return
const centerX = rect.left + rect.width / 2
const half = TOOLTIP_WIDTH / 2
if (centerX - half < VIEWPORT_MARGIN) {
setPlacement('start')
} else if (centerX + half > window.innerWidth - VIEWPORT_MARGIN) {
setPlacement('end')
} else {
setPlacement('center')
}
}
return (
<Link
ref={anchorRef}
to={`/glossary#${entry.id}`}
onMouseEnter={handleEnter}
className="group relative underline decoration-ink-faint decoration-dotted underline-offset-4 transition-colors hover:text-accent hover:decoration-accent"
>
{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 hidden w-64 rounded-lg
bg-ink p-3 text-left text-xs font-normal normal-case leading-relaxed tracking-normal
text-paper/75 no-underline shadow-lg group-hover:block group-focus-visible:block
${PLACEMENT_CLASS[placement]}`}
>
<span className="mb-1 block font-semibold text-paper">{name}</span>
{entry.definition[lang]}
</span>
</Link>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import type { Lang } from '../i18n/translations'
import { ButtonLink, Container } from './ui'
export function Wordmark() {
return (
<span className="text-[1.0625rem] font-semibold tracking-tight text-ink">
net<span className="text-accent">Ork</span>
</span>
)
}
const linkCls = (active: boolean) =>
`text-sm transition-colors ${active ? 'text-ink' : 'text-ink-muted hover:text-ink'}`
function NavDropdown({ label, active, children }: { label: string; active: boolean; children: ReactNode }) {
const [open, setOpen] = useState(false)
const location = useLocation()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
setOpen(false)
}, [location.pathname])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className={`flex items-center gap-1 ${linkCls(active)}`}
>
{label}
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute left-0 top-full mt-3 w-56 rounded-lg border border-line bg-paper py-1.5 shadow-lg">
{children}
</div>
)}
</div>
)
}
const dropdownItemCls = 'block px-4 py-2 text-sm text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
const mobileItemCls = 'block rounded-md px-3 py-2 text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
const mobileHeadingCls = 'px-3 pb-1 pt-4 text-eyebrow font-semibold uppercase text-ink-faint'
export default function Nav() {
const { pathname } = useLocation()
const { lang, setLang, t } = useLang()
const [menuOpen, setMenuOpen] = useState(false)
useEffect(() => {
setMenuOpen(false)
}, [pathname])
const personas = [
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
{ to: '/for/msp', label: t.nav.forItems.msp },
]
const links = [
{ to: '/nis2', label: t.nav.nis2 },
{ to: '/plugins', label: t.nav.plugins },
{ to: '/pricing', label: t.nav.pricing },
]
return (
<nav className="sticky top-0 z-30 border-b border-line bg-paper/85 backdrop-blur">
<Container className="flex h-14 items-center gap-8">
<Link to="/" aria-label="netOrk" className="mr-2">
<Wordmark />
</Link>
<div className="hidden items-center gap-7 lg:flex">
<NavDropdown label={t.nav.forWhom} active={pathname.startsWith('/for/')}>
{personas.map((p) => (
<Link key={p.to} to={p.to} className={dropdownItemCls}>
{p.label}
</Link>
))}
</NavDropdown>
{links.map((l) => (
<Link key={l.to} to={l.to} className={linkCls(pathname === l.to)}>
{l.label}
</Link>
))}
</div>
<div className="ml-auto flex items-center gap-4">
<LangSwitch lang={lang} setLang={setLang} />
<ButtonLink to="/pricing" className="hidden h-9 px-4 sm:inline-flex">
{t.nav.buy}
</ButtonLink>
<button
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-label={t.nav.menu}
className="-mr-1 p-1 text-ink-muted transition-colors hover:text-ink lg:hidden"
>
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
</button>
</div>
</Container>
{menuOpen && (
<div className="max-h-[calc(100vh-3.5rem)] overflow-y-auto border-t border-line bg-paper lg:hidden">
<Container className="py-3">
{links.map((l) => (
<Link key={l.to} to={l.to} className={mobileItemCls}>
{l.label}
</Link>
))}
<p className={mobileHeadingCls}>{t.nav.forWhom}</p>
{personas.map((p) => (
<Link key={p.to} to={p.to} className={mobileItemCls}>
{p.label}
</Link>
))}
<ButtonLink to="/pricing" className="mt-4 w-full sm:hidden">
{t.nav.buy}
</ButtonLink>
</Container>
</div>
)}
</nav>
)
}
function LangSwitch({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
return (
<div className="flex items-center gap-1 text-xs font-medium">
{(['de', 'en'] as const).map((l, i) => (
<span key={l} className="flex items-center gap-1">
{i > 0 && <span className="text-line-strong">/</span>}
<button
onClick={() => setLang(l)}
aria-pressed={lang === l}
className={`uppercase transition-colors ${lang === l ? 'text-ink' : 'text-ink-faint hover:text-ink-soft'}`}
>
{l}
</button>
</span>
))}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
/** A new page starts at the top; a link with a #hash lands on that section. */
export default function ScrollManager() {
const { pathname, hash } = useLocation()
useEffect(() => {
if (hash) {
// The target may render in the same commit; wait a frame so it exists.
requestAnimationFrame(() => {
document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({ block: 'start' })
})
return
}
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}, [pathname, hash])
return null
}
+44
View File
@@ -0,0 +1,44 @@
import sizes from '../data/screenshots.json'
type Name = keyof typeof sizes
/**
* A real screenshot of netOrk on the dark stage. Images come from
* scripts/screenshots/capture.py, which also writes their sizes to
* src/data/screenshots.json so the page reserves the right space.
*/
export default function Shot({
name,
narrow,
alt,
caption,
eager = false,
}: {
name: Name
/** A crop that reads on phones; shown below md instead of `name`. */
narrow?: Name
alt: string
caption?: string
eager?: boolean
}) {
const { width, height } = sizes[name]
return (
<figure>
<div className="rounded-2xl bg-night p-1 shadow-stage ring-1 ring-ink/10 sm:p-1.5">
<picture>
{narrow && <source media="(max-width: 767px)" srcSet={`/screenshots/${narrow}.webp`} />}
<img
src={`/screenshots/${name}.webp`}
alt={alt}
width={width}
height={height}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
className="block h-auto w-full rounded-xl"
/>
</picture>
</div>
{caption && <figcaption className="mt-3 text-sm text-ink-muted">{caption}</figcaption>}
</figure>
)
}
+195
View File
@@ -0,0 +1,195 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
// The site's whole layout vocabulary. docs/DESIGN.md explains when to use which;
// if a page needs something that is not here, it probably needs less instead.
export function Container({ children, className = '' }: { children: ReactNode; className?: string }) {
return <div className={`mx-auto w-full max-w-page px-5 sm:px-8 ${className}`}>{children}</div>
}
/** A page section. `band` sets it on the slightly darker paper with hairlines above and below. */
export function Section({
id,
band = false,
className = '',
children,
}: {
id?: string
band?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={`scroll-mt-20 py-20 md:py-28 ${band ? 'border-y border-line bg-paper-2' : ''} ${className}`}
>
<Container>{children}</Container>
</section>
)
}
export function Eyebrow({ children }: { children: ReactNode }) {
return <p className="mb-4 text-eyebrow font-semibold uppercase text-accent">{children}</p>
}
/** The top of every page except Home: eyebrow, one h1, a lead paragraph, optional actions. */
export function PageHeader({
eyebrow,
title,
lead,
children,
}: {
eyebrow?: ReactNode
title: ReactNode
lead?: ReactNode
children?: ReactNode
}) {
return (
<header className="pt-16 pb-12 md:pt-24 md:pb-16">
<Container>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h1 className="max-w-3xl text-h1">{title}</h1>
{lead && <p className="mt-6 max-w-measure text-lead text-ink-soft">{lead}</p>}
{children && <div className="mt-8 flex flex-wrap gap-3">{children}</div>}
</Container>
</header>
)
}
const buttonBase =
'inline-flex h-11 items-center justify-center gap-2 rounded-lg px-5 text-sm font-medium transition-colors'
const buttonVariant = {
primary: 'bg-ink text-paper hover:bg-ink-soft',
secondary: 'border border-line-strong bg-paper text-ink hover:bg-paper-2',
}
/** Internal paths go through the router; anything with a scheme is a plain link. */
export function ButtonLink({
to,
variant = 'primary',
className = '',
children,
}: {
to: string
variant?: keyof typeof buttonVariant
className?: string
children: ReactNode
}) {
const cls = `${buttonBase} ${buttonVariant[variant]} ${className}`
if (/^[a-z]+:/.test(to)) {
return (
<a href={to} className={cls}>
{children}
</a>
)
}
return (
<Link to={to} className={cls}>
{children}
</Link>
)
}
export function TextLink({ to, children }: { to: string; children: ReactNode }) {
const cls = 'font-medium text-accent transition-colors hover:text-accent-hover'
if (/^[a-z]+:/.test(to)) {
return (
<a href={to} className={cls}>
{children} <span aria-hidden="true">→</span>
</a>
)
}
return (
<Link to={to} className={cls}>
{children} <span aria-hidden="true">→</span>
</Link>
)
}
/** Heading on the left five columns, content on the right: the default section layout. */
export function Split({ aside, children }: { aside: ReactNode; children: ReactNode }) {
return (
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
<div className="lg:col-span-5">{aside}</div>
<div className="lg:col-span-7 lg:col-start-6 xl:col-span-6 xl:col-start-7">{children}</div>
</div>
)
}
export function H2({ children }: { children: ReactNode }) {
return <h2 className="text-h2">{children}</h2>
}
export type RuleItem = { term: ReactNode; body: ReactNode; aside?: ReactNode }
/** Rows divided by hairlines — what card grids used to be. Two columns from md up when `columns={2}`. */
export function RuleList({ items, columns = 1 }: { items: RuleItem[]; columns?: 1 | 2 }) {
return (
<dl className={columns === 2 ? 'grid gap-x-12 md:grid-cols-2' : ''}>
{items.map((item, i) => (
<div key={i} className="border-t border-line py-5">
<dt className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 text-h3 text-ink">
<span>{item.term}</span>
{item.aside}
</dt>
<dd className="mt-1 text-ink-soft">{item.body}</dd>
</div>
))}
</dl>
)
}
export function Steps({ items }: { items: { title: ReactNode; body: ReactNode }[] }) {
return (
<ol>
{items.map((item, i) => (
<li key={i} className="grid grid-cols-[3rem_1fr] border-t border-line py-6">
<span className="font-mono text-sm text-accent">{String(i + 1).padStart(2, '0')}</span>
<div>
<p className="text-h3 text-ink">{item.title}</p>
<p className="mt-1 text-ink-soft">{item.body}</p>
</div>
</li>
))}
</ol>
)
}
/** Closing call to action, on the band. */
export function CtaBand({ title, body, action }: { title: ReactNode; body?: ReactNode; action: ReactNode }) {
return (
<Section band>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div className="max-w-2xl">
<H2>{title}</H2>
{body && <p className="mt-4 text-lead text-ink-soft">{body}</p>}
</div>
<div className="shrink-0">{action}</div>
</div>
</Section>
)
}
/** Code sits on the same dark stage as the product. */
export function CodeBlock({ code }: { code: string }) {
return (
<pre className="overflow-x-auto rounded-2xl bg-night p-6 font-mono text-sm leading-relaxed text-slate-200 shadow-stage">
<code>{code}</code>
</pre>
)
}
export type Coverage = 'covered' | 'partial' | 'na'
/** A dot and a word; colour alone never carries the meaning. */
export function Status({ coverage, label }: { coverage: Coverage; label: string }) {
const dot = { covered: 'bg-sync', partial: 'bg-drift', na: 'bg-ink-faint' }[coverage]
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium text-ink-soft">
<span className={`h-2 w-2 rounded-full ${dot}`} aria-hidden="true" />
{label}
</span>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { translations, type Lang } from '../i18n/translations'
interface LangContextValue {
lang: Lang
setLang: (l: Lang) => void
t: typeof translations.en
}
const LangContext = createContext<LangContextValue | null>(null)
// A stored choice wins; otherwise the browser decides: German for de-*, English for everything else.
function initialLang(): Lang {
try {
const stored = localStorage.getItem('lang')
if (stored === 'de' || stored === 'en') return stored
} catch {
// storage blocked: fall through to the browser language
}
const preferred = navigator.languages?.[0] ?? navigator.language ?? 'en'
return preferred.toLowerCase().startsWith('de') ? 'de' : 'en'
}
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(initialLang)
// <html lang> drives hyphenation and screen readers.
useEffect(() => {
document.documentElement.lang = lang
}, [lang])
// Only an explicit switch is remembered, so the browser keeps deciding until someone chooses.
function setLang(l: Lang) {
setLangState(l)
try {
localStorage.setItem('lang', l)
} catch {
// storage blocked: the choice lasts for this visit
}
}
return (
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</LangContext.Provider>
)
}
export function useLang() {
const ctx = useContext(LangContext)
if (!ctx) throw new Error('useLang must be used inside LangProvider')
return ctx
}
+26
View File
@@ -0,0 +1,26 @@
// Licence plans as the licence server defines them. Keep in step with
// license-server/netork_license/modules/{kb,registry}/__init__.py (plan defaults)
// and docs/PRODUCT.md. Prices are not published: every plan is "on request".
//
// The KB request limit (60 / 600 / 6000) is deliberately missing: its unit is
// not defined yet and nothing enforces it.
export const PORTAL_URL = 'https://license.netork.io/portal'
export type PlanId = 'starter' | 'pro' | 'enterprise'
export type Plan = {
id: PlanId
name: string
vulnData: boolean
evidence: boolean
cwe: boolean
historyDays: 90 | 365 | 3650
edge: boolean
}
export const PLANS: Plan[] = [
{ id: 'starter', name: 'Starter', vulnData: true, evidence: false, cwe: false, historyDays: 90, edge: false },
{ id: 'pro', name: 'Pro', vulnData: true, evidence: true, cwe: true, historyDays: 365, edge: false },
{ id: 'enterprise', name: 'Enterprise', vulnData: true, evidence: true, cwe: true, historyDays: 3650, edge: true },
]
+18
View File
@@ -0,0 +1,18 @@
{
"audit-log": {
"width": 2400,
"height": 1000
},
"drift": {
"width": 2284,
"height": 400
},
"drift-narrow": {
"width": 1200,
"height": 183
},
"vulnerabilities": {
"width": 2400,
"height": 1107
}
}
+612
View File
@@ -0,0 +1,612 @@
export type GlossaryCategory = 'networking' | 'security' | 'compliance' | 'architecture' | 'general'
export interface GlossaryEntry {
id: string
category: GlossaryCategory
display: string
fullName?: { en: string; de: string }
definition: { en: string; de: string }
match: string[]
}
export const CATEGORY_ORDER: GlossaryCategory[] = ['networking', 'security', 'compliance', 'architecture', 'general']
export const CATEGORY_LABELS: Record<GlossaryCategory, { en: string; de: string }> = {
networking: { en: 'Networking & Protocols', de: 'Netzwerk & Protokolle' },
security: { en: 'Security & Access', de: 'Sicherheit & Zugriff' },
compliance: { en: 'Compliance', de: 'Compliance' },
architecture: { en: 'netOrk Architecture', de: 'netOrk-Architektur' },
general: { en: 'General Tech', de: 'Allgemeine Technik' },
}
export const GLOSSARY: GlossaryEntry[] = [
// ── Networking & Protocols ──────────────────────────────────────────────
{
id: 'ap',
category: 'networking',
display: 'AP',
fullName: { en: 'Access Point', de: 'Access Point' },
definition: {
en: 'A device that lets wireless clients join the network — in netOrk, typically an OpenWRT-managed radio.',
de: 'Ein Gerät, über das sich WLAN-Clients mit dem Netzwerk verbinden — in netOrk in der Regel ein von OpenWRT verwalteter Access Point.',
},
match: ['APs', 'AP'],
},
{
id: 'arp',
category: 'networking',
display: 'ARP',
fullName: { en: 'Address Resolution Protocol', de: 'Address Resolution Protocol' },
definition: {
en: 'The protocol that maps an IP address to the physical MAC address of a device on the same network segment.',
de: 'Das Protokoll, das eine IP-Adresse auf die physische MAC-Adresse eines Geräts im selben Netzwerksegment abbildet.',
},
match: ['ARP'],
},
{
id: 'dhcp',
category: 'networking',
display: 'DHCP',
fullName: { en: 'Dynamic Host Configuration Protocol', de: 'Dynamic Host Configuration Protocol' },
definition: {
en: 'The protocol that automatically hands out IP addresses and network settings to devices as they join.',
de: 'Das Protokoll, das Geräten beim Verbinden automatisch IP-Adressen und Netzwerkeinstellungen zuweist.',
},
match: ['DHCP'],
},
{
id: 'dns',
category: 'networking',
display: 'DNS',
fullName: { en: 'Domain Name System', de: 'Domain Name System' },
definition: {
en: 'Translates human-readable hostnames into IP addresses.',
de: 'Übersetzt menschenlesbare Hostnamen in IP-Adressen.',
},
match: ['DNS'],
},
{
id: 'fqdn',
category: 'networking',
display: 'FQDN',
fullName: { en: 'Fully Qualified Domain Name', de: 'Fully Qualified Domain Name' },
definition: {
en: 'The complete domain name that uniquely identifies a host, e.g. router.example.com.',
de: 'Der vollständige Domainname, der einen Host eindeutig identifiziert, z. B. router.example.com.',
},
match: ['FQDN'],
},
{
id: 'http-https',
category: 'networking',
display: 'HTTP/HTTPS',
fullName: { en: 'Hypertext Transfer Protocol (Secure)', de: 'Hypertext Transfer Protocol (Secure)' },
definition: {
en: 'The protocol web browsers and APIs use to exchange data; HTTPS adds TLS encryption on top.',
de: 'Das Protokoll, über das Webbrowser und APIs Daten austauschen; HTTPS ergänzt es um TLS-Verschlüsselung.',
},
match: ['HTTP/HTTPS', 'HTTPS', 'HTTP'],
},
{
id: 'icmp',
category: 'networking',
display: 'ICMP',
fullName: { en: 'Internet Control Message Protocol', de: 'Internet Control Message Protocol' },
definition: {
en: 'The protocol behind network diagnostics like ping — used by netOrk\'s discovery sweep to find live hosts.',
de: 'Das Protokoll hinter Netzwerkdiagnosen wie Ping — netOrk nutzt es beim Discovery-Sweep, um aktive Hosts zu finden.',
},
match: ['ICMP'],
},
{
id: 'ip',
category: 'networking',
display: 'IP',
fullName: { en: 'Internet Protocol', de: 'Internet Protocol' },
definition: {
en: 'The addressing scheme (IPv4/IPv6) that lets devices find and reach each other on a network.',
de: 'Das Adressierungsschema (IPv4/IPv6), über das Geräte sich im Netzwerk finden und erreichen.',
},
match: ['IP'],
},
{
id: 'lldp',
category: 'networking',
display: 'LLDP',
fullName: { en: 'Link Layer Discovery Protocol', de: 'Link Layer Discovery Protocol' },
definition: {
en: 'Lets neighboring devices advertise their identity and capabilities, which netOrk uses to build the topology graph.',
de: 'Ermöglicht benachbarten Geräten, Identität und Fähigkeiten bekanntzugeben — netOrk nutzt das für den Topologie-Graphen.',
},
match: ['LLDP'],
},
{
id: 'mac',
category: 'networking',
display: 'MAC',
fullName: { en: 'Media Access Control (address)', de: 'Media Access Control (Adresse)' },
definition: {
en: 'The hardware address burned into a network interface, unique per device.',
de: 'Die in eine Netzwerkschnittstelle eingebrannte Hardware-Adresse, eindeutig pro Gerät.',
},
match: ['MAC'],
},
{
id: 'mtu',
category: 'networking',
display: 'MTU',
fullName: { en: 'Maximum Transmission Unit', de: 'Maximum Transmission Unit' },
definition: {
en: 'The largest packet size an interface will forward without fragmenting it.',
de: 'Die größte Paketgröße, die eine Schnittstelle weiterleitet, ohne sie zu fragmentieren.',
},
match: ['MTU'],
},
{
id: 'ntp',
category: 'networking',
display: 'NTP',
fullName: { en: 'Network Time Protocol', de: 'Network Time Protocol' },
definition: {
en: 'Keeps device clocks synchronized — netOrk pushes NTP settings as part of AP profiles.',
de: 'Synchronisiert die Uhrzeit von Geräten — netOrk setzt NTP-Einstellungen als Teil von AP-Profilen.',
},
match: ['NTP'],
},
{
id: 'ptr-record',
category: 'networking',
display: 'PTR record',
definition: {
en: 'A reverse-DNS record that maps an IP address back to a hostname.',
de: 'Ein Reverse-DNS-Eintrag, der eine IP-Adresse auf einen Hostnamen zurückführt.',
},
match: ['PTR record', 'PTR'],
},
{
id: 'snmp',
category: 'networking',
display: 'SNMP',
fullName: { en: 'Simple Network Management Protocol', de: 'Simple Network Management Protocol' },
definition: {
en: 'The protocol netOrk uses to pull health metrics — CPU, memory, interface counters — straight from devices.',
de: 'Das Protokoll, über das netOrk Gesundheitsmetriken — CPU, Speicher, Schnittstellenzähler — direkt von Geräten abfragt.',
},
match: ['SNMP'],
},
{
id: 'ssh',
category: 'networking',
display: 'SSH',
fullName: { en: 'Secure Shell', de: 'Secure Shell' },
definition: {
en: 'An encrypted remote-access protocol — netOrk uses it to run commands, push config, and stream the browser-based console.',
de: 'Ein verschlüsseltes Protokoll für den Fernzugriff — netOrk nutzt es, um Befehle auszuführen, Konfigurationen zu pushen und die browserbasierte Konsole zu streamen.',
},
match: ['SSH'],
},
{
id: 'ssid',
category: 'networking',
display: 'SSID',
fullName: { en: 'Service Set Identifier', de: 'Service Set Identifier' },
definition: {
en: 'The public name of a Wi-Fi network, e.g. what shows up in a phone\'s Wi-Fi list.',
de: 'Der öffentliche Name eines WLANs — das, was z. B. in der WLAN-Liste eines Smartphones erscheint.',
},
match: ['SSIDs', 'SSID'],
},
{
id: 'vlan',
category: 'networking',
display: 'VLAN',
fullName: { en: 'Virtual Local Area Network', de: 'Virtual Local Area Network' },
definition: {
en: 'A logically segmented broadcast domain that runs on shared switching hardware.',
de: 'Eine logisch abgegrenzte Broadcast-Domäne, die auf gemeinsam genutzter Switching-Hardware läuft.',
},
match: ['VLANs', 'VLAN'],
},
// ── Security & Access ────────────────────────────────────────────────────
{
id: 'cis-benchmark',
category: 'security',
display: 'CIS',
fullName: { en: 'Center for Internet Security', de: 'Center for Internet Security' },
definition: {
en: 'A vendor-neutral hardening standard — netOrk surfaces a device\'s CIS benchmark score via the Wazuh integration.',
de: 'Ein herstellerneutraler Hardening-Standard — netOrk zeigt den CIS-Benchmark-Score eines Geräts über die Wazuh-Integration.',
},
match: ['CIS'],
},
{
id: 'cve',
category: 'security',
display: 'CVE',
fullName: { en: 'Common Vulnerabilities and Exposures', de: 'Common Vulnerabilities and Exposures' },
definition: {
en: 'A public identifier for a known security vulnerability, e.g. CVE-2026-44405.',
de: 'Eine öffentliche Kennung für eine bekannte Sicherheitslücke, z. B. CVE-2026-44405.',
},
match: ['CVEs', 'CVE'],
},
{
id: 'cvss',
category: 'security',
display: 'CVSS',
fullName: { en: 'Common Vulnerability Scoring System', de: 'Common Vulnerability Scoring System' },
definition: {
en: 'A standardized 0–10 score for how severe a vulnerability is.',
de: 'Ein standardisierter Score von 0–10 für den Schweregrad einer Sicherheitslücke.',
},
match: ['CVSS'],
},
{
id: 'eol',
category: 'security',
display: 'EOL',
fullName: { en: 'End of Life', de: 'End of Life' },
definition: {
en: 'Software or firmware that no longer receives vendor security updates.',
de: 'Software oder Firmware, die keine Sicherheitsupdates vom Hersteller mehr erhält.',
},
match: ['EOL'],
},
{
id: 'hmac-sha256',
category: 'security',
display: 'HMAC-SHA256',
fullName: { en: 'Hash-based Message Authentication Code (SHA-256)', de: 'Hash-based Message Authentication Code (SHA-256)' },
definition: {
en: 'A cryptographic signature that proves a webhook payload wasn\'t tampered with in transit.',
de: 'Eine kryptografische Signatur, die belegt, dass ein Webhook-Payload unterwegs nicht manipuliert wurde.',
},
match: ['HMAC-SHA256', 'HMAC'],
},
{
id: 'jwt',
category: 'security',
display: 'JWT',
fullName: { en: 'JSON Web Token', de: 'JSON Web Token' },
definition: {
en: 'A signed token that proves a logged-in user\'s identity on every API request.',
de: 'Ein signiertes Token, das die Identität eines angemeldeten Benutzers bei jeder API-Anfrage belegt.',
},
match: ['JWT'],
},
{
id: 'mfa',
category: 'security',
display: 'MFA',
fullName: { en: 'Multi-Factor Authentication', de: 'Multi-Faktor-Authentifizierung' },
definition: {
en: 'Requiring a second proof of identity — like a TOTP code — in addition to a password.',
de: 'Verlangt neben dem Passwort einen zweiten Identitätsnachweis — etwa einen TOTP-Code.',
},
match: ['MFA'],
},
{
id: 'kea',
category: 'networking',
display: 'Kea',
fullName: { en: 'ISC Kea DHCP', de: 'ISC Kea DHCP' },
definition: {
en: 'The DHCP server from ISC that OPNsense uses to hand out addresses; netOrk manages its subnets and reservations.',
de: 'Der DHCP-Server von ISC, mit dem OPNsense Adressen vergibt; netOrk verwaltet seine Subnetze und Reservierungen.',
},
match: ['Kea'],
},
{
id: 'winrm',
category: 'networking',
display: 'WinRM',
fullName: { en: 'Windows Remote Management', de: 'Windows Remote Management' },
definition: {
en: 'Microsoft\'s remote management protocol for Windows hosts, the Windows counterpart to SSH for automation.',
de: 'Microsofts Protokoll zur Fernverwaltung von Windows-Hosts, das Windows-Gegenstück zu SSH für Automatisierung.',
},
match: ['WinRM'],
},
{
id: 'lapi',
category: 'security',
display: 'LAPI',
fullName: { en: 'CrowdSec Local API', de: 'CrowdSec Local API' },
definition: {
en: 'The CrowdSec service that collects what its agents detect and holds the resulting ban decisions for one site or host.',
de: 'Der CrowdSec-Dienst, der sammelt, was seine Agenten erkennen, und die daraus folgenden Sperrentscheidungen für einen Standort oder Host hält.',
},
match: ['LAPI'],
},
{
id: 'nvd',
category: 'security',
display: 'NVD',
fullName: { en: 'National Vulnerability Database', de: 'National Vulnerability Database' },
definition: {
en: 'The U.S. government\'s public feed of known CVEs.',
de: 'Die öffentliche CVE-Datenbank der US-Regierung.',
},
match: ['NVD'],
},
{
id: 'osv',
category: 'security',
display: 'OSV',
fullName: { en: 'Open Source Vulnerabilities', de: 'Open Source Vulnerabilities' },
definition: {
en: 'A community-run vulnerability database focused on open-source packages.',
de: 'Eine community-betriebene Schwachstellendatenbank mit Fokus auf Open-Source-Pakete.',
},
match: ['OSV'],
},
{
id: 'rbac',
category: 'security',
display: 'RBAC',
fullName: { en: 'Role-Based Access Control', de: 'Rollenbasierte Zugriffskontrolle' },
definition: {
en: 'Restricting what a user can see or do based on the role they\'ve been assigned.',
de: 'Beschränkt, was ein Benutzer sehen oder tun darf, basierend auf der zugewiesenen Rolle.',
},
match: ['RBAC'],
},
{
id: 'tls',
category: 'security',
display: 'TLS',
fullName: { en: 'Transport Layer Security', de: 'Transport Layer Security' },
definition: {
en: 'The encryption protocol behind HTTPS — also used to terminate traffic at an ingress in a Kubernetes deployment.',
de: 'Das Verschlüsselungsprotokoll hinter HTTPS — wird z. B. auch zur TLS-Terminierung am Ingress eines Kubernetes-Deployments genutzt.',
},
match: ['TLS'],
},
{
id: 'totp',
category: 'security',
display: 'TOTP',
fullName: { en: 'Time-based One-Time Password', de: 'Time-based One-Time Password' },
definition: {
en: 'The rotating 6-digit code generated by an authenticator app, used as a second login factor.',
de: 'Der rotierende 6-stellige Code aus einer Authenticator-App, genutzt als zweiter Anmeldefaktor.',
},
match: ['TOTP'],
},
// ── Compliance ────────────────────────────────────────────────────────────
{
id: 'nis2',
category: 'compliance',
display: 'NIS2',
fullName: { en: 'Network and Information Security Directive 2', de: 'Network and Information Security Directive 2' },
definition: {
en: 'The EU\'s second cybersecurity directive — Article 21 sets baseline technical and organizational measures for essential and important entities.',
de: 'Die zweite EU-Richtlinie zur Netzwerk- und Informationssicherheit — Art. 21 legt technische und organisatorische Mindestmaßnahmen für wesentliche und wichtige Einrichtungen fest.',
},
match: ['NIS2'],
},
// ── netOrk Architecture ───────────────────────────────────────────────────
{
id: 'napalm',
category: 'architecture',
display: 'NAPALM',
fullName: {
en: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
de: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
},
definition: {
en: 'The open-source Python library netOrk uses to talk to network devices through one common interface, regardless of vendor.',
de: 'Die Open-Source-Python-Bibliothek, über die netOrk mit Netzwerkgeräten über eine gemeinsame Schnittstelle spricht — herstellerunabhängig.',
},
match: ['NAPALM'],
},
{
id: 'uci',
category: 'architecture',
display: 'UCI',
fullName: { en: 'Unified Configuration Interface', de: 'Unified Configuration Interface' },
definition: {
en: 'OpenWRT\'s own configuration system — netOrk pushes VLAN, SSID, and radio settings through it.',
de: 'OpenWRTs eigenes Konfigurationssystem — netOrk setzt VLAN-, SSID- und Radio-Einstellungen darüber.',
},
match: ['UCI'],
},
{
id: 'config-drift',
category: 'architecture',
display: 'Config drift',
definition: {
en: 'When a device\'s actual configuration silently diverges from the state netOrk expects — usually from a manual change made directly on the device.',
de: 'Wenn die tatsächliche Konfiguration eines Geräts stillschweigend vom erwarteten Sollzustand abweicht — meist durch eine manuelle Änderung direkt am Gerät.',
},
match: ['Config drift', 'config drift', 'Drifted', 'drifted', 'Drift', 'drift'],
},
{
id: 'hook-bus',
category: 'architecture',
display: 'Hook bus',
definition: {
en: 'netOrk\'s internal event system — plugins subscribe handlers to events like poll.complete instead of core code calling into them directly.',
de: 'netOrks internes Event-System — Plugins registrieren Handler für Events wie poll.complete, statt dass der Kern-Code sie direkt aufruft.',
},
match: ['Hook bus', 'hook bus'],
},
{
id: 'plugin-registry',
category: 'architecture',
display: 'Plugin registry',
definition: {
en: 'The database-backed record of which plugins exist and whether each is enabled, checked before a plugin\'s router or hooks run.',
de: 'Die datenbankgestützte Übersicht, welche Plugins existieren und ob sie aktiviert sind — wird geprüft, bevor Router oder Hooks eines Plugins laufen.',
},
match: ['Plugin registry', 'plugin registry'],
},
{
id: 'config-snapshot',
category: 'architecture',
display: 'Configuration snapshot',
definition: {
en: 'A full copy of a device\'s configuration captured at poll time and committed to Git, so any point in history can be diffed or restored.',
de: 'Eine vollständige Kopie der Gerätekonfiguration, die bei jedem Poll erfasst und in Git committet wird — jeder Zeitpunkt lässt sich so diffen oder wiederherstellen.',
},
match: ['Configuration snapshot', 'configuration snapshot', 'Config snapshot', 'config snapshot'],
},
// ── General Tech ──────────────────────────────────────────────────────────
{
id: 'api',
category: 'general',
display: 'API',
fullName: { en: 'Application Programming Interface', de: 'Application Programming Interface' },
definition: {
en: 'A defined set of endpoints other software — or netOrk\'s own UI — uses to talk to a system.',
de: 'Eine definierte Menge an Endpunkten, über die andere Software — oder netOrks eigene UI — mit einem System spricht.',
},
match: ['API'],
},
{
id: 'crud',
category: 'general',
display: 'CRUD',
fullName: { en: 'Create, Read, Update, Delete', de: 'Create, Read, Update, Delete' },
definition: {
en: 'The four basic operations for managing a record: creating, viewing, editing, and deleting it.',
de: 'Die vier Grundoperationen zur Verwaltung eines Datensatzes: anlegen, anzeigen, bearbeiten, löschen.',
},
match: ['CRUD'],
},
{
id: 'csv',
category: 'general',
display: 'CSV',
fullName: { en: 'Comma-Separated Values', de: 'Comma-Separated Values' },
definition: {
en: 'A plain-text spreadsheet format used for exporting tabular data like the audit log.',
de: 'Ein textbasiertes Tabellenformat für den Export tabellarischer Daten wie des Audit-Logs.',
},
match: ['CSV'],
},
{
id: 'cpu',
category: 'general',
display: 'CPU',
fullName: { en: 'Central Processing Unit', de: 'Central Processing Unit' },
definition: {
en: 'A device\'s processor — netOrk tracks its load as a health metric.',
de: 'Der Prozessor eines Geräts — netOrk erfasst dessen Auslastung als Gesundheitsmetrik.',
},
match: ['CPU'],
},
{
id: 'db',
category: 'general',
display: 'DB',
fullName: { en: 'Database', de: 'Datenbank' },
definition: {
en: 'Shorthand for the database — where netOrk stores desired device state and configuration.',
de: 'Kurzform für die Datenbank — dort speichert netOrk den Sollzustand und die Konfiguration der Geräte.',
},
match: ['DB'],
},
{
id: 'fk',
category: 'general',
display: 'FK',
fullName: { en: 'Foreign Key', de: 'Foreign Key' },
definition: {
en: 'A database reference from one record to another — e.g. linking a device to its Site record.',
de: 'Ein Datenbank-Verweis von einem Datensatz auf einen anderen — z. B. die Verknüpfung eines Geräts mit seinem Standort-Datensatz.',
},
match: ['FK'],
},
{
id: 'nas',
category: 'general',
display: 'NAS',
fullName: { en: 'Network-Attached Storage', de: 'Network-Attached Storage' },
definition: {
en: 'A dedicated file-storage device that other machines on the network read and write to.',
de: 'Ein dediziertes Speichergerät im Netzwerk, auf das andere Rechner lesend und schreibend zugreifen.',
},
match: ['NAS'],
},
{
id: 'os',
category: 'general',
display: 'OS',
fullName: { en: 'Operating System', de: 'Betriebssystem' },
definition: {
en: 'The system software running on a device — firmware, a Linux distribution, etc. — netOrk tracks its version per device.',
de: 'Die auf einem Gerät laufende Systemsoftware — Firmware, eine Linux-Distribution usw. — netOrk erfasst die Version pro Gerät.',
},
match: ['OS'],
},
{
id: 'pdf',
category: 'general',
display: 'PDF',
fullName: { en: 'Portable Document Format', de: 'Portable Document Format' },
definition: {
en: 'A fixed-layout document format used for audit log exports meant for an auditor.',
de: 'Ein Dokumentformat mit festem Layout — genutzt für Audit-Log-Exporte, die an einen Prüfer gehen.',
},
match: ['PDF'],
},
{
id: 'saas',
category: 'general',
display: 'SaaS',
fullName: { en: 'Software as a Service', de: 'Software as a Service' },
definition: {
en: 'Hosted software you access over the internet and don\'t run yourself — the opposite of netOrk\'s self-hosted model.',
de: 'Gehostete Software, die über das Internet genutzt wird, statt sie selbst zu betreiben — das Gegenteil von netOrks Self-Hosted-Modell.',
},
match: ['SaaS'],
},
{
id: 'sse',
category: 'general',
display: 'SSE',
fullName: { en: 'Server-Sent Events', de: 'Server-Sent Events' },
definition: {
en: 'A one-way streaming connection the server uses to push live output — like fix-stream logs — to the browser.',
de: 'Eine unidirektionale Streaming-Verbindung, über die der Server Live-Output — etwa Fix-Stream-Logs — an den Browser sendet.',
},
match: ['SSE'],
},
{
id: 'ui',
category: 'general',
display: 'UI',
fullName: { en: 'User Interface', de: 'User Interface' },
definition: {
en: 'The interface you interact with — in netOrk\'s case, the web app itself.',
de: 'Die Oberfläche, mit der interagiert wird — bei netOrk die Web-App selbst.',
},
match: ['UI'],
},
{
id: 'vm',
category: 'general',
display: 'VM',
fullName: { en: 'Virtual Machine', de: 'Virtuelle Maschine' },
definition: {
en: 'An emulated computer running on a hypervisor like Proxmox, sharing physical hardware with other VMs.',
de: 'Ein emulierter Computer auf einem Hypervisor wie Proxmox, der sich die physische Hardware mit anderen VMs teilt.',
},
match: ['VMs', 'VM'],
},
{
id: 'websocket',
category: 'general',
display: 'WebSocket',
definition: {
en: 'A persistent two-way connection between browser and server — used for the interactive Web-SSH console.',
de: 'Eine dauerhafte, bidirektionale Verbindung zwischen Browser und Server — genutzt für die interaktive Web-SSH-Konsole.',
},
match: ['WebSocket'],
},
]
+563
View File
@@ -0,0 +1,563 @@
// All copy of the site, English and German. `de` is typed as `typeof en`, so a key
// missing in either language fails the type check.
//
// Rules (docs/PAGES.md): one message — control instead of drift. Every claim must be
// backed by docs/PRODUCT.md. Word budgets per page are checked by scripts/check/site.py.
export type Lang = 'en' | 'de'
type Item = { title: string; body: string }
const en = {
meta: {
home: 'netOrk — Control instead of drift',
pricing: 'Pricing · netOrk',
nis2: 'NIS2 · netOrk',
plugins: 'Plugins · netOrk',
glossary: 'Glossary · netOrk',
gettingStarted: 'Getting started · netOrk',
imprint: 'Legal notice · netOrk',
privacy: 'Privacy · netOrk',
},
nav: {
forWhom: "Who it's for",
forItems: { itDepartment: 'IT departments', itSupport: 'IT support', msp: 'Service providers' },
nis2: 'NIS2',
plugins: 'Plugins',
pricing: 'Pricing',
buy: 'Buy a licence',
menu: 'Menu',
},
footer: {
tagline: 'Control instead of drift. Self-hosted.',
product: 'Product',
forWhom: "Who it's for",
contact: 'Contact',
gettingStarted: 'Getting started',
glossary: 'Glossary',
noTracking: 'No cookies, no tracking, no requests to anyone else.',
imprint: 'Legal notice',
privacy: 'Privacy',
},
home: {
hero: {
eyebrow: 'Self-hosted network automation for IT teams',
title: 'Control instead of drift.',
lead: 'Define once how your network should be set up. netOrk notices every change on your access points, switches and firewalls, shows what no longer matches — and puts it back. Across vendors, on your own hardware.',
primary: 'See plans',
secondary: 'How it works',
caption: '42 values match the profile. The syslog server is not defined there — and this access point has it switched off.',
},
how: {
eyebrow: 'How it works',
title: 'Define it once. netOrk keeps it that way.',
intro: 'An SSH port changed by hand, NTP reset by an update. Small changes, until nobody knows what is really configured.',
steps: [
{ title: 'Define', body: 'Access point profiles for SSIDs, radio, VLANs, NTP, syslog and SSH. VLANs once for all switches. Firewall rules as profiles per site.' },
{ title: 'Detect', body: 'Every poll compares access points with their profile. Every configuration change is versioned in Git, and anything netOrk did not make is flagged.' },
{ title: 'Fix', body: 'One click, or overnight in your maintenance window, one access point at a time. Firewall rules are written step by step, never deleted automatically.' },
] as Item[],
},
hardware: {
eyebrow: 'Hardware',
title: 'Works with what you already run.',
groups: [
{ title: 'Desired state and fixes', body: 'OPNsense · HPE ProCurve / Aruba · TP-Link JetStream · OpenWrt · Proxmox VE' },
{ title: 'Inventory and monitoring', body: 'Linux · OpenMediaVault · QNAP · Fritz!Box · HPE OfficeConnect · Netgear · Zyxel · Sonos · Yealink' },
] as Item[],
note: 'Cisco, Arista and Juniper via the standard NAPALM drivers, untested.',
},
vulns: {
eyebrow: 'With a licence',
title: 'The vulnerabilities that matter on your devices.',
body: 'Installed software and container images, matched against the netOrk Knowledge Base. One queue: exploited and overdue first, every decision with a reason.',
link: 'What the licence includes',
caption: 'The triage queue: one row per vulnerability, and why it is where it is.',
},
included: {
title: 'Also in the box',
items: [
{ title: 'Inventory', body: 'Discovery, facts, interfaces, LLDP topology.' },
{ title: 'Config history', body: 'Every change versioned in Git.' },
{ title: 'Service checks', body: 'DNS, NTP, VPN and gateways, every minute.' },
{ title: 'Security assessment', body: 'TLS and SSH graded A to F.' },
{ title: 'Audit log', body: 'Who changed what, as CSV or PDF.' },
{ title: 'Roles and MFA', body: 'Four roles, custom sets, TOTP.' },
{ title: 'Notifications', body: 'Signal, one message per outage.' },
{ title: 'Plugins', body: 'Wazuh, Graylog, CrowdSec, end-of-life tracking.' },
] as Item[],
},
nis2: {
label: 'NIS2',
body: 'Inventory, patch status, access control and an audit log: what Art. 21 wants to see, kept by netOrk while it works.',
link: 'Art. 21 in detail',
},
cta: {
title: 'netOrk is free. The licence adds the data.',
body: 'Vulnerability data and image updates. No limits on devices, sites or users.',
button: 'See plans',
},
},
pricing: {
title: 'Pricing',
lead: 'netOrk itself is free. A licence adds vulnerability data from the netOrk Knowledge Base and image updates. One key per netOrk instance, no limits on devices, sites or users.',
core: {
name: 'netOrk',
price: 'Free',
body: 'Device management, desired state and drift fixes, config history, service checks, security assessment, audit log, roles and MFA, plugins.',
},
licence: 'Licence',
onRequest: 'On request',
buy: 'Buy a licence',
rows: {
vulnData: 'Vulnerability data',
evidence: 'Match evidence',
cwe: 'Weakness details (CWE)',
history: 'Vulnerability history',
channel: 'Update channel',
},
history: { 90: '90 days', 365: '1 year', 3650: '10 years' },
channel: { stable: 'stable', edge: 'stable + edge' },
allPlans: 'All plans: billed monthly or annually · one key per netOrk instance · unlimited devices, sites and users. Edge means early builds before a release.',
faqTitle: 'Questions',
faq: [
{ title: 'What works without a licence?', body: 'Everything except the vulnerability data and image updates. Paid services only add; they never switch anything off in netOrk.' },
{ title: 'Are there limits?', body: 'None on devices, sites or users. A key belongs to one netOrk instance; moving to another server releases it with one click.' },
{ title: 'What happens when a licence ends?', body: 'The local copy of the vulnerability data stays, it just stops updating. Nothing else changes.' },
{ title: 'What does the Knowledge Base learn about us?', body: 'Not what you run. netOrk asks in broad categories that each cover many products, keeps its own copy and matches locally.' },
] as Item[],
install: 'There is no public installer yet.',
installLink: 'Getting started',
},
personas: {
problems: 'Where it hurts today',
helps: 'How netOrk helps',
cta: {
title: 'Control instead of drift, on your own hardware.',
body: 'netOrk is free. The licence adds vulnerability data and updates.',
button: 'See plans',
},
itDepartment: {
title: 'IT departments · netOrk',
eyebrow: 'For IT departments',
heading: 'For the small team that runs the whole network.',
lead: 'Mixed hardware, no time for a console per vendor, NIS2 on the agenda. netOrk keeps the configuration where you defined it.',
problems: [
{ title: 'A console for every vendor', body: 'OPNsense here, ProCurve there, OpenWrt somewhere else — and nobody sees the whole network.' },
{ title: 'Changes nobody wrote down', body: 'A setting differs from last month. Who changed it, when and why is anyone’s guess.' },
{ title: 'Evidence on demand', body: 'The auditor asks for patch status and access control, and the answer is a weekend of screenshots.' },
] as Item[],
helps: [
{ title: 'One desired state', body: 'Access point profiles, central VLANs and firewall profiles per site define how things should be.' },
{ title: 'Drift found and fixed', body: 'Every deviation with expected and actual value; fixed with one click or overnight.' },
{ title: 'Every change on record', body: 'Configurations versioned in Git. Changes netOrk did not make are flagged.' },
{ title: 'Vulnerabilities in one queue', body: 'With a licence: exploited and overdue first, every decision with a reason.' },
{ title: 'Evidence as a by-product', body: 'Audit log, inventory and patch status, exportable as CSV or PDF.' },
] as Item[],
},
itSupport: {
title: 'IT support · netOrk',
eyebrow: 'For IT support',
heading: 'For the people who get the ticket.',
lead: 'See what is down, what changed and who did it — without admin rights to the whole network.',
problems: [
{ title: 'Is it actually down?', body: 'Checking means opening the vendor console for that one device, or pinging and hoping.' },
{ title: '“Who changed this?”', body: 'A setting is different from yesterday, and there is no record of who touched it.' },
{ title: 'Admin rights for one ticket', body: 'Fixing a single warning should not need the keys to the whole configuration.' },
] as Item[],
helps: [
{ title: 'Warnings across all sites', body: 'Every open problem in one list, by severity, without hunting device by device.' },
{ title: 'Service checks every minute', body: 'DNS, time servers, VPN tunnels and gateways; a dead resolver shows up in minutes.' },
{ title: 'Problems on Signal', body: 'One message per outage instead of one per device, with quiet hours per person.' },
{ title: 'Every change on record', body: 'Who changed what and when, in the audit log and in the Git history of the configuration.' },
{ title: 'Roles that fit the job', body: 'Viewer and operator roles give support what it needs, not engineer rights.' },
] as Item[],
},
msp: {
title: 'Service providers · netOrk',
eyebrow: 'For service providers',
heading: 'For service providers with many customer sites.',
lead: 'One netOrk for every customer site, including the ones you cannot reach directly.',
problems: [
{ title: 'Sites behind someone else’s firewall', body: 'A VPN per customer just to look at one device.' },
{ title: 'Proving what you did', body: 'After an incident the customer asks what changed, and there is no clean record to hand over.' },
{ title: 'Customer data in a vendor cloud', body: 'Cloud management tools put your customers’ network data on servers you do not control.' },
] as Item[],
helps: [
{ title: 'A satellite at the site', body: 'A small agent polls devices locally and syncs to netOrk over HTTPS.' },
{ title: 'A dropped site is one warning', body: 'When the tunnel goes down, polling pauses instead of every device turning red; it resumes the moment the site is back.' },
{ title: 'Checks from inside the network', body: 'Service checks run from the satellite, including a DHCP check that only works locally.' },
{ title: 'An audit trail to hand over', body: 'Who did what and when, exportable as CSV or PDF.' },
{ title: 'Where it stops today', body: 'SNMP health metrics and the web terminal still need a direct route to the device.' },
] as Item[],
},
},
nis2: {
eyebrow: 'NIS2 · Art. 21',
title: 'Evidence, not paperwork.',
lead: 'Art. 21 asks for inventory, patch management, access control and audit trails. netOrk keeps them as part of daily work. It does not make you compliant; it gives you the evidence for the technical part.',
tableTitle: 'Article by article',
coverage: { covered: 'Covered', partial: 'Partly', na: 'Out of scope' },
requirements: [
{ article: '(2a)', label: 'Risk analysis', coverage: 'partial', body: 'Drift detection, health metrics and security findings give a running baseline. The risk register stays outside netOrk.' },
{ article: '(2b)', label: 'Incident handling', coverage: 'partial', body: 'Wazuh alerts, CrowdSec decisions and syslog forwarding per device. No incident record with Art. 23 deadlines.' },
{ article: '(2c)', label: 'Business continuity', coverage: 'partial', body: 'Every configuration versioned in Git and comparable between any two points in time. Full device backups are out of scope.' },
{ article: '(2d)', label: 'Supply chain', coverage: 'covered', body: 'Vendor, model and firmware per device; end-of-life software flagged daily via endoflife.date.' },
{ article: '(2e)', label: 'Vulnerability handling', coverage: 'covered', body: 'Software and container images matched against known vulnerabilities; every decision with reason, date and owner. Needs a licence.' },
{ article: '(2f)', label: 'Effectiveness', coverage: 'partial', body: 'Fixes are verified by a daily reassessment; the audit log shows what was done. No compliance dashboard.' },
{ article: '(2h)', label: 'Access control and assets', coverage: 'covered', body: 'Automatic inventory, four built-in roles plus custom ones, a complete audit log as CSV or PDF.' },
{ article: '(2i)', label: 'Multi-factor authentication', coverage: 'covered', body: 'TOTP for netOrk accounts, enforceable per role. Terminal sessions use each user’s own SSH key.' },
{ article: '(2g) (2j)', label: 'Training, physical security', coverage: 'na', body: 'Organisational matters outside a network tool.' },
] as { article: string; label: string; coverage: 'covered' | 'partial' | 'na'; body: string }[],
evidenceTitle: 'What netOrk records along the way',
evidence: [
'Every poll: installed packages, pending updates, interfaces, VLANs and health metrics.',
'Every poll: a Git snapshot of the configuration, compared with the last one.',
'Every action: who, when, what, with the value before and after.',
'Every vulnerability: the decision, its reason, a deadline and the verification.',
'On demand: the audit log as CSV or PDF, filtered by date, user or action.',
],
auditCaption: 'The audit log, filtered to what people did.',
},
plugins: {
eyebrow: 'Plugins',
title: 'Integrations you switch on when you run them.',
lead: 'Wazuh, Graylog, CrowdSec, APT caching and end-of-life tracking are plugins. Enable what you use; the rest stays out of the way.',
builtinTitle: 'Included',
builtin: [
{ title: 'Wazuh', body: 'Agent enrollment, vulnerability counts, alerts and CIS benchmark scores per device.', hosts: 'wazuh-manager:55000 · wazuh-indexer:9200' },
{ title: 'Graylog', body: 'Checks that devices forward syslog to Graylog, and writes the forwarding rule if they do not.', hosts: '' },
{ title: 'CrowdSec', body: 'Decisions, blocked requests and top attack scenarios per device.', hosts: 'admin.api.crowdsec.net:443' },
{ title: 'apt-cacher-ng', body: 'Finds the APT cache at a site and points the devices there at it.', hosts: '' },
{ title: 'EOL tracking', body: 'Flags firmware and operating systems at or near end of life, checked daily.', hosts: 'endoflife.date' },
] as { title: string; body: string; hosts: string }[],
buildTitle: 'Writing your own',
build: [
{ title: 'Metadata', body: 'Name, version and the hosts and data a plugin declares it touches — for review.' },
{ title: 'Router', body: 'An optional FastAPI router, mounted under /api/v1 when the plugin is enabled.' },
{ title: 'Tasks', body: 'Celery tasks for everything that talks to another system.' },
{ title: 'Hooks', body: 'Handlers on the event bus, such as poll.complete. They queue work and never block.' },
] as Item[],
codeTitle: 'A hook',
enable: 'Each plugin is switched on or off as a setting, behind the same permissions as every other action. Routers mount at startup, so turning a plugin’s API off takes effect after the next restart.',
},
glossary: {
title: 'Glossary',
lead: 'Terms used on this site and in netOrk. Hover an underlined term anywhere on the site for the short version.',
},
gettingStarted: {
eyebrow: 'Coming soon',
title: 'No public installer yet.',
lead: 'We are preparing packages and a guide. For early access, write to us and we will set you up.',
cta: 'Write to us',
how: 'How it works',
pricing: 'Pricing',
},
legal: {
placeholder: 'Placeholder — the final text is added before launch.',
imprint: {
title: 'Legal notice',
body: 'The information required by § 5 DDG (provider, address, contact, register entry and VAT ID) will be published here before netork.io goes live.',
},
privacy: {
title: 'Privacy',
body: 'The privacy policy will be published here before netork.io goes live. This website sets no cookies, uses no tracking and loads nothing from other servers.',
},
},
shots: {
drift: 'Drift view of an access point in netOrk: settings from the profile next to the values on the device',
vulnerabilities: 'Vulnerability triage queue in netOrk with severity, reason and affected devices',
auditLog: 'netOrk audit log with user actions, exportable as CSV and PDF',
},
}
export type Translations = typeof en
const de: Translations = {
meta: {
home: 'netOrk — Kontrolle statt Drift',
pricing: 'Preise · netOrk',
nis2: 'NIS2 · netOrk',
plugins: 'Plugins · netOrk',
glossary: 'Glossar · netOrk',
gettingStarted: 'Erste Schritte · netOrk',
imprint: 'Impressum · netOrk',
privacy: 'Datenschutz · netOrk',
},
nav: {
forWhom: 'Für wen',
forItems: { itDepartment: 'IT-Abteilungen', itSupport: 'IT-Support', msp: 'Dienstleister' },
nis2: 'NIS2',
plugins: 'Plugins',
pricing: 'Preise',
buy: 'Lizenz kaufen',
menu: 'Menü',
},
footer: {
tagline: 'Kontrolle statt Drift. Self-hosted.',
product: 'Produkt',
forWhom: 'Für wen',
contact: 'Kontakt',
gettingStarted: 'Erste Schritte',
glossary: 'Glossar',
noTracking: 'Keine Cookies, kein Tracking, keine Anfragen an Dritte.',
imprint: 'Impressum',
privacy: 'Datenschutz',
},
home: {
hero: {
eyebrow: 'Self-hosted Netzwerk-Automatisierung für IT-Teams',
title: 'Kontrolle statt Drift.',
lead: 'Legt einmal fest, wie euer Netz eingerichtet sein soll. netOrk bemerkt jede Änderung an Access Points, Switches und Firewalls, zeigt, was nicht mehr passt – und stellt es wieder her. Herstellerübergreifend, auf eurer eigenen Hardware.',
primary: 'Pläne ansehen',
secondary: 'So funktioniert’s',
caption: '42 Werte passen zum Profil. Der Syslog-Server ist dort nicht festgelegt – und dieser Access Point hat ihn abgeschaltet.',
},
how: {
eyebrow: 'So funktioniert’s',
title: 'Einmal festlegen. netOrk hält es so.',
intro: 'Ein von Hand geänderter SSH-Port, NTP nach einem Update zurückgesetzt. Kleine Änderungen – bis niemand mehr weiß, was wirklich konfiguriert ist.',
steps: [
{ title: 'Festlegen', body: 'Access-Point-Profile für SSIDs, Funk, VLANs, NTP, Syslog und SSH. VLANs einmal für alle Switches. Firewall-Regeln als Profile pro Standort.' },
{ title: 'Erkennen', body: 'Jeder Poll vergleicht Access Points mit ihrem Profil. Jede Konfigurationsänderung wird in Git versioniert, und was nicht von netOrk kam, wird gemeldet.' },
{ title: 'Beheben', body: 'Ein Klick – oder nachts im Wartungsfenster, ein Access Point nach dem anderen. Firewall-Regeln werden Schritt für Schritt geschrieben, nie automatisch gelöscht.' },
],
},
hardware: {
eyebrow: 'Hardware',
title: 'Läuft mit dem, was ihr schon habt.',
groups: [
{ title: 'Soll-Zustand und Fixes', body: 'OPNsense · HPE ProCurve / Aruba · TP-Link JetStream · OpenWrt · Proxmox VE' },
{ title: 'Inventar und Monitoring', body: 'Linux · OpenMediaVault · QNAP · Fritz!Box · HPE OfficeConnect · Netgear · Zyxel · Sonos · Yealink' },
],
note: 'Cisco, Arista und Juniper über die Standard-NAPALM-Treiber, ungetestet.',
},
vulns: {
eyebrow: 'Mit Lizenz',
title: 'Die Schwachstellen, die auf euren Geräten zählen.',
body: 'Installierte Software und Container-Images, abgeglichen mit der netOrk Knowledge Base. Eine Liste: Ausgenutztes und Überfälliges zuerst, jede Entscheidung mit Begründung.',
link: 'Was die Lizenz enthält',
caption: 'Die Triage-Liste: eine Zeile pro Schwachstelle, und warum sie dort steht.',
},
included: {
title: 'Außerdem dabei',
items: [
{ title: 'Inventar', body: 'Discovery, Fakten, Interfaces, LLDP-Topologie.' },
{ title: 'Konfig-Historie', body: 'Jede Änderung in Git versioniert.' },
{ title: 'Service-Checks', body: 'DNS, NTP, VPN und Gateways, jede Minute.' },
{ title: 'Sicherheitsbewertung', body: 'TLS und SSH mit Note A bis F.' },
{ title: 'Audit-Log', body: 'Wer was geändert hat, als CSV oder PDF.' },
{ title: 'Rollen und MFA', body: 'Vier Rollen, eigene Sets, TOTP.' },
{ title: 'Benachrichtigungen', body: 'Signal, eine Nachricht pro Ausfall.' },
{ title: 'Plugins', body: 'Wazuh, Graylog, CrowdSec, End-of-Life-Tracking.' },
],
},
nis2: {
label: 'NIS2',
body: 'Inventar, Patch-Stand, Zugriffskontrolle und ein Audit-Log: was Art. 21 sehen will, von netOrk im Betrieb mitgeführt.',
link: 'Art. 21 im Detail',
},
cta: {
title: 'netOrk ist kostenlos. Die Lizenz bringt die Daten.',
body: 'Schwachstellendaten und Image-Updates. Keine Limits für Geräte, Standorte oder Benutzer.',
button: 'Pläne ansehen',
},
},
pricing: {
title: 'Preise',
lead: 'netOrk selbst ist kostenlos. Eine Lizenz ergänzt Schwachstellendaten aus der netOrk Knowledge Base und Image-Updates. Ein Schlüssel pro netOrk-Instanz, keine Limits für Geräte, Standorte oder Benutzer.',
core: {
name: 'netOrk',
price: 'Kostenlos',
body: 'Geräteverwaltung, Soll-Zustand und Drift-Fixes, Konfig-Historie, Service-Checks, Sicherheitsbewertung, Audit-Log, Rollen und MFA, Plugins.',
},
licence: 'Lizenz',
onRequest: 'Auf Anfrage',
buy: 'Lizenz kaufen',
rows: {
vulnData: 'Schwachstellendaten',
evidence: 'Match-Nachweis',
cwe: 'Details zur Schwachstellenart (CWE)',
history: 'Schwachstellen-Historie',
channel: 'Update-Kanal',
},
history: { 90: '90 Tage', 365: '1 Jahr', 3650: '10 Jahre' },
channel: { stable: 'stable', edge: 'stable + edge' },
allPlans: 'Alle Pläne: monatliche oder jährliche Abrechnung · ein Schlüssel pro netOrk-Instanz · unbegrenzt Geräte, Standorte und Benutzer. Edge heißt: frühe Builds vor einem Release.',
faqTitle: 'Fragen',
faq: [
{ title: 'Was geht ohne Lizenz?', body: 'Alles außer den Schwachstellendaten und Image-Updates. Bezahlte Dienste ergänzen nur; sie schalten in netOrk nie etwas ab.' },
{ title: 'Gibt es Limits?', body: 'Keine für Geräte, Standorte oder Benutzer. Ein Schlüssel gehört zu einer netOrk-Instanz; beim Umzug auf einen anderen Server gebt ihr ihn mit einem Klick frei.' },
{ title: 'Was passiert, wenn eine Lizenz endet?', body: 'Die lokale Kopie der Schwachstellendaten bleibt, sie wird nur nicht mehr aktualisiert. Sonst ändert sich nichts.' },
{ title: 'Was erfährt die Knowledge Base über uns?', body: 'Nicht, was bei euch läuft. netOrk fragt in groben Kategorien, die jeweils viele Produkte abdecken, hält eine eigene Kopie und gleicht lokal ab.' },
],
install: 'Einen öffentlichen Installer gibt es noch nicht.',
installLink: 'Erste Schritte',
},
personas: {
problems: 'Wo es heute hakt',
helps: 'Wo netOrk hilft',
cta: {
title: 'Kontrolle statt Drift, auf eurer eigenen Hardware.',
body: 'netOrk ist kostenlos. Die Lizenz bringt Schwachstellendaten und Updates.',
button: 'Pläne ansehen',
},
itDepartment: {
title: 'IT-Abteilungen · netOrk',
eyebrow: 'Für IT-Abteilungen',
heading: 'Für das kleine Team, das das ganze Netz betreibt.',
lead: 'Gemischte Hardware, keine Zeit für eine Oberfläche pro Hersteller, NIS2 auf der Agenda. netOrk hält die Konfiguration dort, wo ihr sie festgelegt habt.',
problems: [
{ title: 'Eine Oberfläche pro Hersteller', body: 'OPNsense hier, ProCurve dort, OpenWrt woanders – und niemand sieht das ganze Netz.' },
{ title: 'Änderungen, die niemand notiert hat', body: 'Eine Einstellung ist anders als letzten Monat. Wer sie wann und warum geändert hat, weiß niemand.' },
{ title: 'Nachweise auf Zuruf', body: 'Der Prüfer fragt nach Patch-Stand und Zugriffskontrolle, und die Antwort ist ein Wochenende voller Screenshots.' },
],
helps: [
{ title: 'Ein Soll-Zustand', body: 'Access-Point-Profile, zentrale VLANs und Firewall-Profile pro Standort legen fest, wie es sein soll.' },
{ title: 'Drift gefunden und behoben', body: 'Jede Abweichung mit Soll und Ist; behoben mit einem Klick oder nachts.' },
{ title: 'Jede Änderung dokumentiert', body: 'Konfigurationen in Git versioniert. Was nicht von netOrk kam, wird gemeldet.' },
{ title: 'Schwachstellen in einer Liste', body: 'Mit Lizenz: Ausgenutztes und Überfälliges zuerst, jede Entscheidung mit Begründung.' },
{ title: 'Nachweise nebenbei', body: 'Audit-Log, Inventar und Patch-Stand, exportierbar als CSV oder PDF.' },
],
},
itSupport: {
title: 'IT-Support · netOrk',
eyebrow: 'Für den IT-Support',
heading: 'Für die, bei denen das Ticket landet.',
lead: 'Sehen, was ausgefallen ist, was sich geändert hat und wer es war – ohne Adminrechte für das ganze Netz.',
problems: [
{ title: 'Ist es wirklich down?', body: 'Nachsehen heißt: die Herstelleroberfläche für genau dieses Gerät öffnen – oder pingen und hoffen.' },
{ title: '„Wer hat das geändert?“', body: 'Eine Einstellung ist anders als gestern, und niemand weiß, wer sie angefasst hat.' },
{ title: 'Adminrechte für ein Ticket', body: 'Eine einzelne Warnung zu beheben sollte nicht den Schlüssel zur ganzen Konfiguration brauchen.' },
],
helps: [
{ title: 'Warnungen über alle Standorte', body: 'Jedes offene Problem in einer Liste, nach Schwere sortiert, ohne Gerät für Gerät zu suchen.' },
{ title: 'Service-Checks jede Minute', body: 'DNS, Zeitserver, VPN-Tunnel und Gateways; ein toter Resolver fällt in Minuten auf.' },
{ title: 'Probleme per Signal', body: 'Eine Nachricht pro Ausfall statt einer pro Gerät, mit Ruhezeiten pro Person.' },
{ title: 'Jede Änderung dokumentiert', body: 'Wer wann was geändert hat, im Audit-Log und in der Git-Historie der Konfiguration.' },
{ title: 'Rollen passend zur Aufgabe', body: 'Betrachter- und Operator-Rollen geben dem Support, was er braucht – keine Engineer-Rechte.' },
],
},
msp: {
title: 'Dienstleister · netOrk',
eyebrow: 'Für Dienstleister',
heading: 'Für Dienstleister mit vielen Kundenstandorten.',
lead: 'Ein netOrk für alle Kundenstandorte, auch für die, die ihr nicht direkt erreicht.',
problems: [
{ title: 'Standorte hinter fremden Firewalls', body: 'Ein VPN pro Kunde, nur um ein Gerät anzusehen.' },
{ title: 'Belegen, was ihr getan habt', body: 'Nach einem Vorfall fragt der Kunde, was sich geändert hat – und es gibt kein sauberes Protokoll.' },
{ title: 'Kundendaten in fremder Cloud', body: 'Cloud-Werkzeuge legen die Netzdaten eurer Kunden auf Server, die ihr nicht kontrolliert.' },
],
helps: [
{ title: 'Ein Satellit am Standort', body: 'Ein kleiner Agent fragt die Geräte lokal ab und synchronisiert per HTTPS mit netOrk.' },
{ title: 'Ein ausgefallener Standort ist eine Warnung', body: 'Fällt der Tunnel, pausiert das Polling, statt jedes Gerät rot zu färben; sobald der Standort zurück ist, geht es weiter.' },
{ title: 'Checks aus dem Netz heraus', body: 'Service-Checks laufen vom Satelliten, inklusive eines DHCP-Checks, der nur lokal funktioniert.' },
{ title: 'Ein Audit-Trail zum Übergeben', body: 'Wer wann was getan hat, exportierbar als CSV oder PDF.' },
{ title: 'Wo es heute aufhört', body: 'SNMP-Metriken und das Web-Terminal brauchen noch eine direkte Verbindung zum Gerät.' },
],
},
},
nis2: {
eyebrow: 'NIS2 · Art. 21',
title: 'Nachweise statt Papierkram.',
lead: 'Art. 21 verlangt Inventar, Patch-Management, Zugriffskontrolle und Audit-Trails. netOrk führt sie im täglichen Betrieb mit. Es macht euch nicht compliant; es liefert die Nachweise für den technischen Teil.',
tableTitle: 'Artikel für Artikel',
coverage: { covered: 'Abgedeckt', partial: 'Teilweise', na: 'Außerhalb' },
requirements: [
{ article: '(2a)', label: 'Risikoanalyse', coverage: 'partial', body: 'Drift-Erkennung, Health-Metriken und Sicherheitsbefunde liefern eine laufende Grundlage. Das Risikoregister bleibt außerhalb von netOrk.' },
{ article: '(2b)', label: 'Bewältigung von Vorfällen', coverage: 'partial', body: 'Wazuh-Alerts, CrowdSec-Entscheidungen und Syslog-Weiterleitung pro Gerät. Kein Vorfallsdatensatz mit Art.-23-Fristen.' },
{ article: '(2c)', label: 'Geschäftskontinuität', coverage: 'partial', body: 'Jede Konfiguration in Git versioniert und zwischen zwei beliebigen Zeitpunkten vergleichbar. Vollständige Geräte-Backups gehören nicht dazu.' },
{ article: '(2d)', label: 'Lieferkette', coverage: 'covered', body: 'Hersteller, Modell und Firmware pro Gerät; End-of-Life-Software wird täglich über endoflife.date markiert.' },
{ article: '(2e)', label: 'Umgang mit Schwachstellen', coverage: 'covered', body: 'Software und Container-Images gegen bekannte Schwachstellen abgeglichen; jede Entscheidung mit Begründung, Datum und Verantwortlichem. Braucht eine Lizenz.' },
{ article: '(2f)', label: 'Wirksamkeit', coverage: 'partial', body: 'Behebungen werden durch eine tägliche Neubewertung bestätigt; das Audit-Log zeigt, was getan wurde. Kein Compliance-Dashboard.' },
{ article: '(2h)', label: 'Zugriffskontrolle und Assets', coverage: 'covered', body: 'Automatisches Inventar, vier eingebaute Rollen plus eigene, ein vollständiges Audit-Log als CSV oder PDF.' },
{ article: '(2i)', label: 'Multi-Faktor-Authentifizierung', coverage: 'covered', body: 'TOTP für netOrk-Konten, pro Rolle erzwingbar. Terminal-Sitzungen nutzen den eigenen SSH-Schlüssel des Benutzers.' },
{ article: '(2g) (2j)', label: 'Schulung, physische Sicherheit', coverage: 'na', body: 'Organisatorische Themen außerhalb eines Netzwerkwerkzeugs.' },
],
evidenceTitle: 'Was netOrk nebenbei festhält',
evidence: [
'Jeder Poll: installierte Pakete, ausstehende Updates, Interfaces, VLANs und Health-Metriken.',
'Jeder Poll: ein Git-Snapshot der Konfiguration, verglichen mit dem letzten.',
'Jede Aktion: wer, wann, was, mit dem Wert davor und danach.',
'Jede Schwachstelle: die Entscheidung, ihre Begründung, eine Frist und die Bestätigung.',
'Auf Abruf: das Audit-Log als CSV oder PDF, gefiltert nach Datum, Benutzer oder Aktion.',
],
auditCaption: 'Das Audit-Log, gefiltert auf das, was Menschen getan haben.',
},
plugins: {
eyebrow: 'Plugins',
title: 'Integrationen, die ihr einschaltet, wenn ihr sie nutzt.',
lead: 'Wazuh, Graylog, CrowdSec, APT-Caching und End-of-Life-Tracking sind Plugins. Aktiviert, was ihr einsetzt; der Rest bleibt aus dem Weg.',
builtinTitle: 'Enthalten',
builtin: [
{ title: 'Wazuh', body: 'Agent-Registrierung, Schwachstellenzahlen, Alerts und CIS-Benchmark-Werte pro Gerät.', hosts: 'wazuh-manager:55000 · wazuh-indexer:9200' },
{ title: 'Graylog', body: 'Prüft, ob Geräte Syslog an Graylog weiterleiten, und schreibt die Weiterleitungsregel, wenn nicht.', hosts: '' },
{ title: 'CrowdSec', body: 'Entscheidungen, geblockte Anfragen und die häufigsten Angriffsszenarien pro Gerät.', hosts: 'admin.api.crowdsec.net:443' },
{ title: 'apt-cacher-ng', body: 'Findet den APT-Cache an einem Standort und richtet die Geräte dort darauf aus.', hosts: '' },
{ title: 'EOL-Tracking', body: 'Markiert Firmware und Betriebssysteme am oder nahe dem Ende ihres Supports, täglich geprüft.', hosts: 'endoflife.date' },
],
buildTitle: 'Eigene schreiben',
build: [
{ title: 'Metadaten', body: 'Name, Version und die Hosts und Daten, die ein Plugin laut eigener Angabe berührt – zur Prüfung.' },
{ title: 'Router', body: 'Ein optionaler FastAPI-Router, unter /api/v1 eingebunden, wenn das Plugin aktiv ist.' },
{ title: 'Tasks', body: 'Celery-Tasks für alles, was mit einem anderen System spricht.' },
{ title: 'Hooks', body: 'Handler am Event-Bus, etwa poll.complete. Sie reihen Arbeit ein und blockieren nie.' },
],
codeTitle: 'Ein Hook',
enable: 'Jedes Plugin wird über eine Einstellung ein- oder ausgeschaltet, mit denselben Berechtigungen wie jede andere Aktion. Router werden beim Start eingebunden; das Abschalten der API eines Plugins wirkt deshalb nach dem nächsten Neustart.',
},
glossary: {
title: 'Glossar',
lead: 'Begriffe, die auf dieser Seite und in netOrk vorkommen. Fahrt über einen unterstrichenen Begriff, um die Kurzfassung zu sehen.',
},
gettingStarted: {
eyebrow: 'Demnächst',
title: 'Noch kein öffentlicher Installer.',
lead: 'Wir bereiten Pakete und eine Anleitung vor. Für einen frühen Zugang schreibt uns, wir richten euch ein.',
cta: 'Schreibt uns',
how: 'So funktioniert’s',
pricing: 'Preise',
},
legal: {
placeholder: 'Platzhalter – der endgültige Text folgt vor dem Start.',
imprint: {
title: 'Impressum',
body: 'Die Angaben nach § 5 DDG (Anbieter, Anschrift, Kontakt, Registereintrag und USt-IdNr.) werden hier veröffentlicht, bevor netork.io live geht.',
},
privacy: {
title: 'Datenschutz',
body: 'Die Datenschutzerklärung wird hier veröffentlicht, bevor netork.io live geht. Diese Website setzt keine Cookies, nutzt kein Tracking und lädt nichts von fremden Servern.',
},
},
shots: {
drift: 'Drift-Ansicht eines Access Points in netOrk: Werte aus dem Profil neben den Werten auf dem Gerät',
vulnerabilities: 'Schwachstellen-Triage in netOrk mit Schweregrad, Begründung und betroffenen Geräten',
auditLog: 'Audit-Log in netOrk mit Benutzeraktionen, exportierbar als CSV und PDF',
},
}
export const CONTACT_EMAIL = 'christian@manivong.de'
export const translations: Record<Lang, Translations> = { en, de }
+35
View File
@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply bg-paper text-ink antialiased;
font-feature-settings: 'cv11', 'ss01';
}
body {
@apply bg-paper text-ink;
}
::selection {
@apply bg-accent-soft text-ink;
}
:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-paper;
}
h1,
h2,
h3 {
text-wrap: balance;
hyphens: auto;
}
p,
li,
dd {
text-wrap: pretty;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Fragment } from 'react'
import GlossaryMark from '../components/GlossaryMark'
import { GLOSSARY } from '../glossary/terms'
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const MATCH_TO_ID = new Map<string, string>()
for (const entry of GLOSSARY) {
for (const surface of entry.match) {
MATCH_TO_ID.set(surface, entry.id)
}
}
const ALL_SURFACES = [...MATCH_TO_ID.keys()].sort((a, b) => b.length - a.length)
const GLOSSARY_REGEX = new RegExp(`\\b(${ALL_SURFACES.map(escapeRegExp).join('|')})\\b`, 'g')
/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked <GlossaryMark>.
* Always returns a single inline element — never a bare array — so callers can safely place the
* result inside a `flex` container (e.g. a bulleted `<li>`) without each fragment becoming its own
* flex item and breaking the reading order. */
export function linkify(text: string): React.ReactNode {
const parts = text.split(GLOSSARY_REGEX)
if (parts.length === 1) return text
return (
<span>
{parts.map((part, i) => {
const id = MATCH_TO_ID.get(part)
if (id === undefined) return <Fragment key={i}>{part}</Fragment>
return (
<GlossaryMark key={i} id={id}>
{part}
</GlossaryMark>
)
})}
</span>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { useEffect } from 'react'
/** Sets the browser tab title. Titles come complete from translations (`meta`). */
export function usePageTitle(title: string) {
useEffect(() => {
document.title = title
}, [title])
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/inter'
import './index.css'
import App from './App'
import { LangProvider } from './context/LangContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LangProvider>
<App />
</LangProvider>
</StrictMode>,
)
+24
View File
@@ -0,0 +1,24 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { CONTACT_EMAIL } from '../i18n/translations'
import { ButtonLink, PageHeader } from '../components/ui'
export default function GettingStarted() {
const { t } = useLang()
const g = t.gettingStarted
usePageTitle(t.meta.gettingStarted)
return (
<div className="pb-24">
<PageHeader eyebrow={g.eyebrow} title={g.title} lead={g.lead}>
<ButtonLink to={`mailto:${CONTACT_EMAIL}`}>{g.cta}</ButtonLink>
<ButtonLink to="/#how" variant="secondary">
{g.how}
</ButtonLink>
<ButtonLink to="/pricing" variant="secondary">
{g.pricing}
</ButtonLink>
</PageHeader>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { GLOSSARY, CATEGORY_ORDER, CATEGORY_LABELS } from '../glossary/terms'
import { Container, PageHeader } from '../components/ui'
export default function Glossary() {
const { lang, t } = useLang()
usePageTitle(t.meta.glossary)
const categories = CATEGORY_ORDER.map((category) => ({
category,
entries: GLOSSARY.filter((e) => e.category === category).sort((a, b) => a.display.localeCompare(b.display)),
})).filter((c) => c.entries.length > 0)
return (
<>
<PageHeader title={t.glossary.title} lead={t.glossary.lead} />
<Container className="pb-24">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
{/* Category index, sticky beside the list from lg up. */}
<nav className="lg:col-span-3">
<ul className="flex flex-wrap gap-x-5 gap-y-2 text-sm lg:sticky lg:top-24 lg:flex-col">
{categories.map(({ category }) => (
<li key={category}>
<a href={`#cat-${category}`} className="text-ink-muted transition-colors hover:text-ink">
{CATEGORY_LABELS[category][lang]}
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-16 lg:col-span-9">
{categories.map(({ category, entries }) => (
<section key={category} id={`cat-${category}`} className="scroll-mt-24">
<h2 className="text-h3 text-ink-muted">{CATEGORY_LABELS[category][lang]}</h2>
<dl className="mt-4">
{entries.map((entry) => (
<div
key={entry.id}
id={entry.id}
className="grid scroll-mt-24 gap-x-8 gap-y-1 border-t border-line py-4 sm:grid-cols-[12rem_1fr]"
>
<dt>
<span className="font-mono text-sm font-semibold text-ink">{entry.display}</span>
{entry.fullName && <span className="block text-sm text-ink-muted">{entry.fullName[lang]}</span>}
</dt>
<dd className="text-ink-soft">{entry.definition[lang]}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>
</Container>
</>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import Shot from '../components/Shot'
import {
ButtonLink,
Container,
CtaBand,
Eyebrow,
H2,
RuleList,
Section,
Split,
Steps,
TextLink,
} from '../components/ui'
export default function Home() {
const { t } = useLang()
const h = t.home
usePageTitle(t.meta.home)
return (
<>
{/* The message, and the drift itself as proof. */}
<header className="pt-16 md:pt-24">
<Container>
<Eyebrow>{h.hero.eyebrow}</Eyebrow>
<h1 className="max-w-4xl text-display">{h.hero.title}</h1>
<p className="mt-6 max-w-2xl text-lead text-ink-soft">{h.hero.lead}</p>
<div className="mt-8 flex flex-wrap gap-3">
<ButtonLink to="/pricing">{h.hero.primary}</ButtonLink>
<ButtonLink to="/#how" variant="secondary">
{h.hero.secondary}
</ButtonLink>
</div>
<div className="mt-14 md:mt-20">
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} caption={h.hero.caption} eager />
</div>
</Container>
</header>
<Section id="how">
<Split
aside={
<>
<Eyebrow>{h.how.eyebrow}</Eyebrow>
<H2>{h.how.title}</H2>
<p className="mt-6 text-ink-soft">{h.how.intro}</p>
</>
}
>
<Steps items={h.how.steps} />
</Split>
</Section>
<Section id="hardware" band>
<Split
aside={
<>
<Eyebrow>{h.hardware.eyebrow}</Eyebrow>
<H2>{h.hardware.title}</H2>
</>
}
>
<RuleList items={h.hardware.groups.map((g) => ({ term: g.title, body: g.body }))} />
<p className="mt-6 border-t border-line pt-5 text-sm text-ink-muted">{h.hardware.note}</p>
</Split>
</Section>
<Section>
<div className="max-w-2xl">
<Eyebrow>{h.vulns.eyebrow}</Eyebrow>
<H2>{h.vulns.title}</H2>
<p className="mt-6 text-lead text-ink-soft">{h.vulns.body}</p>
<p className="mt-6">
<TextLink to="/pricing">{h.vulns.link}</TextLink>
</p>
</div>
<div className="mt-12">
<Shot name="vulnerabilities" alt={t.shots.vulnerabilities} caption={h.vulns.caption} />
</div>
</Section>
<Section id="included" className="pt-0 md:pt-0">
<div className="border-t border-line-strong pt-16 md:pt-20">
<H2>{h.included.title}</H2>
<div className="mt-10">
<RuleList
columns={2}
items={h.included.items.map((i) => ({ term: i.title, body: i.body }))}
/>
</div>
<div className="mt-12 grid gap-4 border-t border-line pt-6 sm:grid-cols-[8rem_1fr]">
<p className="text-eyebrow font-semibold uppercase text-accent">{h.nis2.label}</p>
<div>
<p className="max-w-2xl text-ink-soft">{h.nis2.body}</p>
<p className="mt-3">
<TextLink to="/nis2">{h.nis2.link}</TextLink>
</p>
</div>
</div>
</div>
</Section>
<CtaBand
title={h.cta.title}
body={h.cta.body}
action={<ButtonLink to="/pricing">{h.cta.button}</ButtonLink>}
/>
</>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { Container, PageHeader } from '../components/ui'
/**
* Impressum and Datenschutz. Placeholders until the owner supplies the text;
* netork.io must not go live with the placeholder box still showing.
*/
export default function Legal({ page }: { page: 'imprint' | 'privacy' }) {
const { t } = useLang()
const l = t.legal[page]
usePageTitle(t.meta[page])
return (
<div className="pb-24">
<PageHeader title={l.title} />
<Container>
<div className="max-w-measure rounded-lg border border-drift/40 bg-drift-soft p-5">
<p className="text-sm font-semibold text-drift">{t.legal.placeholder}</p>
<p className="mt-2 text-ink-soft">{l.body}</p>
</div>
</Container>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
import Shot from '../components/Shot'
import { ButtonLink, CtaBand, H2, PageHeader, Section, Split, Status } from '../components/ui'
export default function Nis2() {
const { t } = useLang()
const n = t.nis2
usePageTitle(t.meta.nis2)
return (
<>
<PageHeader eyebrow={n.eyebrow} title={n.title} lead={linkify(n.lead)} />
<Section className="pt-0 md:pt-0">
<H2>{n.tableTitle}</H2>
<dl className="mt-10">
{n.requirements.map((r) => (
<div
key={r.article}
className="grid gap-x-8 gap-y-2 border-t border-line py-5 md:grid-cols-[6rem_16rem_1fr_8rem]"
>
<dt className="font-mono text-sm text-accent">Art. 21 {r.article}</dt>
<dt className="text-h3 text-ink">{r.label}</dt>
<dd className="text-ink-soft">{linkify(r.body)}</dd>
<dd className="md:text-right">
<Status coverage={r.coverage} label={n.coverage[r.coverage]} />
</dd>
</div>
))}
</dl>
</Section>
<Section band>
<Split aside={<H2>{n.evidenceTitle}</H2>}>
<ul>
{n.evidence.map((e) => (
<li key={e} className="border-t border-line py-4 text-ink-soft first:border-t-0 first:pt-0">
{linkify(e)}
</li>
))}
</ul>
</Split>
<div className="mt-14">
<Shot name="audit-log" alt={t.shots.auditLog} caption={n.auditCaption} />
</div>
</Section>
<CtaBand
title={t.personas.cta.title}
body={t.personas.cta.body}
action={<ButtonLink to="/pricing">{t.personas.cta.button}</ButtonLink>}
/>
</>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
import Shot from '../components/Shot'
import { ButtonLink, Container, CtaBand, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
export type PersonaId = 'itDepartment' | 'itSupport' | 'msp'
/** One template for the three "who it's for" pages; only the copy differs. */
export default function Persona({ id }: { id: PersonaId }) {
const { t } = useLang()
const shared = t.personas
const p = shared[id]
usePageTitle(p.title)
return (
<>
<PageHeader eyebrow={p.eyebrow} title={p.heading} lead={p.lead} />
{/* The primary audience sees the drift itself; the others read about it. */}
{id === 'itDepartment' && (
<Container className="pb-8">
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} />
</Container>
)}
<Section>
<Split aside={<H2>{shared.problems}</H2>}>
<RuleList items={p.problems.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
</Split>
</Section>
<Section className="pt-0 md:pt-0">
<Split aside={<H2>{shared.helps}</H2>}>
<RuleList items={p.helps.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
</Split>
</Section>
<CtaBand
title={shared.cta.title}
body={shared.cta.body}
action={<ButtonLink to="/pricing">{shared.cta.button}</ButtonLink>}
/>
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
import { CodeBlock, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
const HOOK_CODE = `# netork/plugins/myplugin/hooks.py
from netork.plugins.hooks import hook
@hook("poll.complete", priority=100)
async def on_poll_complete(device_id: str, **kwargs) -> None:
# queue a Celery task — never block the event loop here
from netork.plugins.myplugin.tasks import sync_device
sync_device.apply_async(args=[device_id], queue="default")`
export default function Plugins() {
const { t } = useLang()
const p = t.plugins
usePageTitle(t.meta.plugins)
return (
<>
<PageHeader eyebrow={p.eyebrow} title={p.title} lead={linkify(p.lead)} />
<Section className="pt-0 md:pt-0">
<Split aside={<H2>{p.builtinTitle}</H2>}>
<RuleList
items={p.builtin.map((b) => ({
term: b.title,
aside: b.hosts ? <span className="font-mono text-xs font-normal text-ink-muted">{b.hosts}</span> : undefined,
body: linkify(b.body),
}))}
/>
</Split>
</Section>
<Section band>
<Split aside={<H2>{p.buildTitle}</H2>}>
<RuleList items={p.build.map((b) => ({ term: b.title, body: linkify(b.body) }))} />
</Split>
<div className="mt-14 grid gap-8 lg:grid-cols-12">
<div className="lg:col-span-5">
<p className="text-h3">{p.codeTitle}</p>
<p className="mt-2 text-ink-soft">{linkify(p.enable)}</p>
</div>
<div className="min-w-0 lg:col-span-7">
<CodeBlock code={HOOK_CODE} />
</div>
</div>
</Section>
</>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { PLANS, PORTAL_URL, type Plan } from '../data/plans'
import { ButtonLink, Container, H2, PageHeader, RuleList, Section, TextLink } from '../components/ui'
function Check({ on }: { on: boolean }) {
return on ? (
<span className="text-sync" aria-label="yes">✓</span>
) : (
<span className="text-ink-faint" aria-label="no">—</span>
)
}
export default function Pricing() {
const { t } = useLang()
const p = t.pricing
usePageTitle(t.meta.pricing)
const rows: { label: string; value: (plan: Plan) => React.ReactNode }[] = [
{ label: p.rows.vulnData, value: (plan) => <Check on={plan.vulnData} /> },
{ label: p.rows.evidence, value: (plan) => <Check on={plan.evidence} /> },
{ label: p.rows.cwe, value: (plan) => <Check on={plan.cwe} /> },
{ label: p.rows.history, value: (plan) => p.history[plan.historyDays] },
{ label: p.rows.channel, value: (plan) => (plan.edge ? p.channel.edge : p.channel.stable) },
]
return (
<>
<PageHeader title={p.title} lead={p.lead} />
<Container>
{/* The free core, as a plain row above the licence plans. */}
<div className="grid gap-2 border-t border-line-strong py-6 sm:grid-cols-[12rem_1fr] sm:gap-8">
<p className="text-h3">
{p.core.name} <span className="ml-2 font-normal text-sync">{p.core.price}</span>
</p>
<p className="text-ink-soft">{p.core.body}</p>
</div>
<h2 className="mt-12 text-h3 text-ink-muted">{p.licence}</h2>
{/* md and up: one comparison table. */}
<table className="mt-4 hidden w-full border-collapse text-left md:table">
<thead>
<tr className="border-b border-line-strong align-bottom">
<th className="w-1/3 py-4" />
{PLANS.map((plan) => (
<th key={plan.id} className="px-4 py-4">
<p className="text-h3">{plan.name}</p>
<p className="mt-1 text-sm font-normal text-ink-muted">{p.onRequest}</p>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label} className="border-b border-line">
<th scope="row" className="py-4 pr-4 font-normal text-ink-soft">
{row.label}
</th>
{PLANS.map((plan) => (
<td key={plan.id} className="px-4 py-4 text-ink">
{row.value(plan)}
</td>
))}
</tr>
))}
<tr>
<td />
{PLANS.map((plan) => (
<td key={plan.id} className="px-4 pt-6">
<ButtonLink to={PORTAL_URL}>
{p.buy}
</ButtonLink>
</td>
))}
</tr>
</tbody>
</table>
{/* Below md: one block per plan. */}
<div className="mt-4 md:hidden">
{PLANS.map((plan) => (
<div key={plan.id} className="border-t border-line-strong py-6">
<p className="text-h3">
{plan.name} <span className="ml-2 text-sm font-normal text-ink-muted">{p.onRequest}</span>
</p>
<dl className="mt-4 space-y-2 text-sm">
{rows.map((row) => (
<div key={row.label} className="flex justify-between gap-4">
<dt className="text-ink-soft">{row.label}</dt>
<dd className="text-ink">{row.value(plan)}</dd>
</div>
))}
</dl>
<ButtonLink to={PORTAL_URL} className="mt-5 w-full">
{p.buy}
</ButtonLink>
</div>
))}
</div>
<p className="mt-8 max-w-3xl text-sm text-ink-muted">{p.allPlans}</p>
</Container>
<Section>
<H2>{p.faqTitle}</H2>
<div className="mt-10">
<RuleList columns={2} items={p.faq.map((f) => ({ term: f.title, body: f.body }))} />
</div>
<p className="mt-10 text-ink-soft">
{p.install} <TextLink to="/docs/getting-started">{p.installLink}</TextLink>
</p>
</Section>
</>
)
}
+40
View File
@@ -0,0 +1,40 @@
import defaultTheme from 'tailwindcss/defaultTheme'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
// Light pages, dark product. See docs/DESIGN.md before adding a colour.
colors: {
paper: { DEFAULT: '#FAFAF9', 2: '#F3F3F0' },
line: { DEFAULT: '#E6E6E3', strong: '#D4D4D0' },
ink: { DEFAULT: '#0E1116', soft: '#3D434D', muted: '#6B717B', faint: '#9AA0A8' },
// The netOrk UI's own background, so screenshot edges disappear into the stage.
night: { DEFAULT: '#020617', line: '#1E293B' },
accent: { DEFAULT: '#0369A1', hover: '#075985', soft: '#E0F2FE' },
drift: { DEFAULT: '#B45309', soft: '#FEF3C7' },
sync: { DEFAULT: '#15803D', soft: '#DCFCE7' },
},
fontFamily: {
sans: ['"Inter Variable"', ...defaultTheme.fontFamily.sans],
},
fontSize: {
display: ['clamp(2.5rem, 1.5rem + 4vw, 4.75rem)', { lineHeight: '1.02', letterSpacing: '-0.04em', fontWeight: '600' }],
h1: ['clamp(2rem, 1.5rem + 2.2vw, 3.25rem)', { lineHeight: '1.08', letterSpacing: '-0.03em', fontWeight: '600' }],
h2: ['clamp(1.625rem, 1.3rem + 1.4vw, 2.5rem)', { lineHeight: '1.12', letterSpacing: '-0.025em', fontWeight: '600' }],
h3: ['1.125rem', { lineHeight: '1.45', letterSpacing: '-0.01em', fontWeight: '600' }],
lead: ['clamp(1.0625rem, 1rem + 0.35vw, 1.25rem)', { lineHeight: '1.6' }],
eyebrow: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.08em' }],
},
maxWidth: {
page: '72rem',
measure: '38rem',
},
boxShadow: {
stage: '0 30px 60px -20px rgba(2, 6, 23, 0.35)',
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})