Compare commits
37
Commits
00c483c2a2
...
main
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript — type-check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check (tsc)
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish — build & push image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [typecheck]
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Gitea's registry, not registry.netork.io.
|
||||||
|
#
|
||||||
|
# registry.netork.io is plain registry:2 with htpasswd auth, which knows
|
||||||
|
# nothing about repositories: every account that can log in reads and
|
||||||
|
# writes everything on it, including the accounts issued to customer
|
||||||
|
# instances. It keeps the images those instances are meant to pull
|
||||||
|
# (netork/engine, netork/ui, netork/satellite); the marketing site is not
|
||||||
|
# one of them. Gitea scopes packages to their owning account, and no
|
||||||
|
# customer has one. netOrk issue #172.
|
||||||
|
#
|
||||||
|
# REGISTRY_TOKEN is a Gitea access token with write:package — the token
|
||||||
|
# Actions injects per run is scoped to the repository API and the package
|
||||||
|
# registry rejects it outright.
|
||||||
|
- name: Login to registry
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "::error::REGISTRY_TOKEN is not set (Gitea token with write:package)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
LOGIN_USER="${{ secrets.REGISTRY_USER }}"
|
||||||
|
[ -n "$LOGIN_USER" ] || LOGIN_USER="${{ github.actor }}"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.netork.io -u "$LOGIN_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build & push
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t git.netork.io/netork/website:latest \
|
||||||
|
-t git.netork.io/netork/website:main-${SHA} \
|
||||||
|
.
|
||||||
|
docker push git.netork.io/netork/website:latest
|
||||||
|
docker push git.netork.io/netork/website:main-${SHA}
|
||||||
|
|
||||||
|
- name: Logout
|
||||||
|
if: always()
|
||||||
|
run: docker logout git.netork.io
|
||||||
|
|
||||||
|
# The deploy job that used to live here has been removed, deliberately.
|
||||||
|
#
|
||||||
|
# It ran `docker run` against whatever runner picked the job up, which worked
|
||||||
|
# while exactly one runner existed. There are now several (netork-runner-12 on
|
||||||
|
# .12, netork-runner-13 on .13, and the original netork-runner) and none of them
|
||||||
|
# is on 10.7.224.11, where this site actually runs and where the proxy-net it
|
||||||
|
# attaches to lives. The job would therefore have started a second website
|
||||||
|
# container on the wrong host and reported success, while netork.io went on
|
||||||
|
# serving the old one.
|
||||||
|
#
|
||||||
|
# Deployment is now an explicit step: scripts/deploy.sh, run from a workstation,
|
||||||
|
# which targets .11 by name and verifies afterwards that the container really is
|
||||||
|
# on the image that was pulled.
|
||||||
|
#
|
||||||
|
# To get push-to-deploy back, either register a runner on .11 with a label of its
|
||||||
|
# own and pin `runs-on:` to it, or give CI an ssh key for .11. Both are choices
|
||||||
|
# about where a credential lives, so neither was made here.
|
||||||
+17
-1
@@ -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/
|
||||||
|
|||||||
@@ -10,14 +10,38 @@ get started.
|
|||||||
## Project Purpose
|
## Project Purpose
|
||||||
|
|
||||||
Potential users land here and need to answer three questions in under 10 seconds:
|
Potential users land here and need to answer three questions in under 10 seconds:
|
||||||
1. What is this?
|
1. What is this? — Control instead of drift: define the desired state once,
|
||||||
2. Is it for me?
|
netOrk finds every deviation and puts it back.
|
||||||
3. How do I try it?
|
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.
|
Everything on the site should serve those three questions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Infrastruktur ändert man woanders — und vorher
|
||||||
|
|
||||||
|
Diese Anwendung läuft auf einer Infrastruktur, die sie sich mit anderen Projekten teilt:
|
||||||
|
Netz, Datenbankcluster, öffentlicher Eingang und Backup gehören keinem Projekt allein.
|
||||||
|
Dokumentiert ist sie in **`git.netork.io/christianmanivong/infrastructure`**, und dort
|
||||||
|
steht in `CLAUDE.md` auch die verbindliche Regel.
|
||||||
|
|
||||||
|
**Kurz: erst dort dokumentieren, ausdrücklich genehmigen lassen, dann ändern.** Nicht
|
||||||
|
umgekehrt, und „ja mach mal" zu einer früheren Frage deckt die nächste Änderung nicht mit
|
||||||
|
ab.
|
||||||
|
|
||||||
|
Betroffen ist alles, was über dieses Repo hinausreicht — Hosts, Netze, Firewall-Regeln,
|
||||||
|
der Patroni-Cluster samt `pg_hba` und DCS-Parametern, pgBackRest, BunkerWeb-Hosts, DNS,
|
||||||
|
CI-Runner, alles, was eine Anwendung auf den geteilten Datenbankcluster umzieht.
|
||||||
|
**Nicht** betroffen: Anwendungscode, Abhängigkeiten und Migrationen innerhalb der eigenen
|
||||||
|
Datenbank.
|
||||||
|
|
||||||
|
Der Grund für die Reihenfolge ist nicht Bürokratie. Die meisten Zwischenfälle dort waren
|
||||||
|
nicht falsche Werte, sondern richtige Werte in der falschen Reihenfolge — und das fällt
|
||||||
|
beim Aufschreiben auf, nicht beim Tippen. Im Zweifel dorthin.
|
||||||
|
|
||||||
## Content & Design Source of Truth
|
## Content & Design Source of Truth
|
||||||
|
|
||||||
All product content (features, copy, page structure) is in `docs/`:
|
All product content (features, copy, page structure) is in `docs/`:
|
||||||
@@ -53,13 +77,19 @@ exactly as netOrk's `ui/src/components/ui.tsx` does.
|
|||||||
|
|
||||||
## Design Rules (summary — full detail in docs/DESIGN.md)
|
## Design Rules (summary — full detail in docs/DESIGN.md)
|
||||||
|
|
||||||
- **Dark theme only.** Background `bg-slate-950`. Cards `bg-slate-900`.
|
- **Light pages, dark product.** Pages are `paper` with `ink`; the only dark
|
||||||
- **Accent color:** `sky-500` / `sky-600` for CTAs, links, highlights.
|
surfaces are real netOrk screenshots and code, on the `night` stage.
|
||||||
- **No light mode toggle.** Ever.
|
- One accent, `accent` (sky-700), for links, eyebrows and focus. Buttons are ink.
|
||||||
- Font stack: system default (Tailwind sans). No Google Fonts.
|
- Font: Inter Variable, self-hosted via `@fontsource-variable/inter`. No Google
|
||||||
- All interactive elements use `transition-colors` — no layout shifts.
|
Fonts, no request to any other origin.
|
||||||
- Screenshots/mockups of the actual app use a `border border-slate-700 rounded-xl
|
- Layout from `src/components/ui.tsx`: split sections, ruled lists, numbered
|
||||||
overflow-hidden` wrapper to frame them against the dark background.
|
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
|
## 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").
|
- No marketing fluff ("revolutionize", "empower", "seamless").
|
||||||
- Show, don't tell — a screenshot or code block beats three sentences of prose.
|
- Show, don't tell — a real screenshot beats three sentences of prose.
|
||||||
- German is fine for internal docs; the website copy is in **English**.
|
- Website copy exists in **English and German** (German addresses readers with
|
||||||
|
"ihr"); the language follows the browser until the visitor chooses.
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
netork-website:
|
||||||
|
image: registry.netork.io/netork/website:latest
|
||||||
|
container_name: netork-website
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
networks:
|
||||||
|
- proxy-net
|
||||||
|
|
||||||
|
# Zoraxy proxies to netork-website:80 via proxy-net.
|
||||||
|
# No host port binding needed — SSL terminated by Zoraxy.
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
external: true
|
||||||
+59
-242
@@ -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
|
**Light pages, dark product.** The site is calm paper with ink; the only dark
|
||||||
exactly when building the website. The goal is zero visual discontinuity
|
surfaces are real screenshots of netOrk and code, set on a "stage". References:
|
||||||
between the product UI and the marketing site.
|
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.**
|
| Token | Value | Use |
|
||||||
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 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Page / outermost | `bg-slate-950` | Body, full-bleed sections |
|
| `paper` / `paper-2` | `#FAFAF9` / `#F3F3F0` | page / band |
|
||||||
| Card / panel | `bg-slate-900` | Content cards, code blocks, feature boxes |
|
| `line` / `line-strong` | `#E6E6E3` / `#D4D4D0` | hairlines |
|
||||||
| Elevated element | `bg-slate-800` | Hover states, dropdowns, table rows on hover |
|
| `ink` / `ink-soft` / `ink-muted` / `ink-faint` | `#0E1116` … `#9AA0A8` | headings / body / secondary / quiet |
|
||||||
| Border | `border-slate-700` | Between sections, card outlines |
|
| `night` | `#020617` | screenshot and code stage — the netOrk UI's own background |
|
||||||
| Subtle border | `border-slate-800` | Inside cards, dividers |
|
| `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` |
|
| `text-display` | the home headline only |
|
||||||
| Secondary / muted | `text-slate-400` |
|
| `text-h1` | one per page, in `PageHeader` |
|
||||||
| Tertiary / placeholder | `text-slate-500` |
|
| `text-h2` | section headings |
|
||||||
| Accent (interactive) | `text-sky-400` |
|
| `text-h3` | row terms, plan names |
|
||||||
| Danger | `text-red-400` |
|
| `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 |
|
## Layout
|
||||||
|---|---|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
### 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 |
|
Not used: icon tiles, pills for names, cards, centred text blocks, fake browser
|
||||||
|---|---|
|
windows, emoji. Motion is `transition-colors` only.
|
||||||
| 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` |
|
|
||||||
|
|
||||||
### Status Badge Pattern
|
## Screenshots
|
||||||
|
|
||||||
```tsx
|
Only real screenshots of netOrk, from the anonymised demo copy
|
||||||
// Active / online
|
(`scripts/demo`), taken by `scripts/screenshots/capture.py` and published as
|
||||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
|
WebP in `public/screenshots/`. Their sizes are written to
|
||||||
active
|
`src/data/screenshots.json`, which `Shot` reads.
|
||||||
</span>
|
|
||||||
|
|
||||||
// Offline
|
- At most four different images on the site. Each is cropped to the one thing
|
||||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
the text next to it talks about (`clip` or `element` in `shots.py`).
|
||||||
offline
|
- Cropping shows less of a real screen; it never changes what is on it. No
|
||||||
</span>
|
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
|
## Wordmark
|
||||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
|
|
||||||
warning
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
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.**
|
Terms from `src/glossary/terms.ts` get a dotted underline and a dark tooltip
|
||||||
The product uses system fonts; the website must match.
|
(`linkify`). Not on the homepage — short copy there stays unmarked.
|
||||||
|
|
||||||
| 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: [],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
+78
-334
@@ -1,339 +1,83 @@
|
|||||||
# netOrk Website — Page Structure & Content Plan
|
# netOrk website — pages
|
||||||
|
|
||||||
This document defines every page of the website: its purpose, section
|
**Audience:** IT departments in small and mid-sized companies — a small team,
|
||||||
structure, and draft copy. Use this as the brief for implementation.
|
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 |
|
| Home | `/` | 400 |
|
||||||
| `/features` | Full feature list | P1 |
|
| Pricing | `/pricing` | 300 |
|
||||||
| `/drivers` | Supported devices | P1 |
|
| NIS2 | `/nis2` | 550 |
|
||||||
| `/docs/getting-started` | Installation guide | P1 |
|
| Plugins | `/plugins` | 450 |
|
||||||
| `/docs/architecture` | Technical overview | P2 |
|
| Persona ×3 | `/for/it-department`, `/for/it-support`, `/for/msp` | 320 |
|
||||||
| `/plugins` | Plugin system | P2 |
|
| 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` →
|
||||||
## `/` — Landing Page
|
`/#included`, `/drivers` → `/#hardware`, `/roadmap` and `/docs/architecture` → `/`.
|
||||||
|
|
||||||
### Section 1 — Hero
|
## Home
|
||||||
|
|
||||||
**Purpose:** Answer "what is this?" in 5 seconds.
|
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.
|
||||||
**Layout:** Full-width, centered. Heading + subheading + two CTAs + hero
|
2. **How it works** (`#how`) — Define → Detect → Fix, three steps.
|
||||||
screenshot below.
|
3. **Hardware** (`#hardware`) — two lines of names: "desired state and fixes"
|
||||||
|
and "inventory and monitoring"; a note on the untested NAPALM drivers.
|
||||||
**Heading:**
|
4. **Vulnerabilities, with a licence** — the triage queue (`vulnerabilities`),
|
||||||
```
|
link to Pricing.
|
||||||
Network orchestration
|
5. **Also in the box** (`#included`) — eight terms, one short line each; a NIS2
|
||||||
for heterogeneous infrastructure.
|
row with a link.
|
||||||
```
|
6. **Closing band** — "netOrk is free. The licence adds the data."
|
||||||
(`text-slate-100` for first line, second line in `text-sky-400` or keep
|
|
||||||
both `text-slate-100` — designer decides.)
|
## Pricing
|
||||||
|
|
||||||
**Subheading:**
|
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
|
||||||
netOrk discovers, monitors, and manages your routers, switches, access
|
differences come from the licence server's plan defaults; the KB request limit
|
||||||
points, firewalls, and servers from a single UI — regardless of vendor.
|
stays off the page until it has a unit. Four questions below, and the note that
|
||||||
No SaaS dependency. Runs on your infrastructure.
|
there is no public installer yet.
|
||||||
```
|
|
||||||
|
## Persona pages
|
||||||
**CTAs:**
|
|
||||||
- Primary: `Get started →` → `/docs/getting-started`
|
One template (`Persona.tsx`): header, three problems, five ways netOrk helps
|
||||||
- Secondary: `View features` → `/features`
|
(desired state and drift first), the shared closing band. The IT-department
|
||||||
|
page shows the drift screenshot. The service-provider page says where it
|
||||||
**Hero visual:** Full-width screenshot of the device inventory page
|
stops today.
|
||||||
(dark UI visible, framed with the browser chrome component from DESIGN.md).
|
|
||||||
|
## NIS2
|
||||||
---
|
|
||||||
|
Article by article (eight rows plus one "out of scope"), a dot and a word for
|
||||||
### Section 2 — Problem Statement
|
coverage, what netOrk records along the way, the audit log screenshot. States
|
||||||
|
plainly that netOrk does not make anyone compliant.
|
||||||
**Purpose:** Make the pain relatable.
|
|
||||||
|
## Plugins, Glossary, Getting started, Impressum, Datenschutz
|
||||||
**Layout:** Single centered paragraph or short 3-column stat row.
|
|
||||||
|
Plugins: the five included ones with the hosts they talk to, how to write one,
|
||||||
**Copy:**
|
one code example. Glossary: every term from `src/glossary/terms.ts` with a
|
||||||
```
|
category index. Getting started: "no public installer yet", write to us.
|
||||||
Managing a mixed network means juggling a different admin UI for every
|
Impressum and Datenschutz: **placeholders** — the final text must replace the
|
||||||
vendor — one for OPNsense, one for HP ProCurve, one for OpenWRT, one for
|
yellow box before netork.io goes live.
|
||||||
Proxmox. Config changes happen directly on devices with no audit trail.
|
|
||||||
You find out something drifted when it breaks.
|
## 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
|
||||||
### Section 3 — Core Capabilities (3-up)
|
Impressum and Datenschutz.
|
||||||
|
|
||||||
**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`
|
|
||||||
|
|||||||
+234
-20
@@ -1,5 +1,22 @@
|
|||||||
# netOrk — Product Description
|
# 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
|
## One-liner
|
||||||
|
|
||||||
**netOrk is a self-hosted network orchestration platform that discovers,
|
**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
|
8. **Self-hosted, no SaaS** — Runs in Docker Compose. Your data stays on your
|
||||||
infrastructure. No telemetry, no cloud dependency.
|
infrastructure. No telemetry, no cloud dependency.
|
||||||
|
|
||||||
|
9. **NIS2 evidence foundation** — NIS2 Art. 21 mandates asset inventory, patch
|
||||||
|
management, access control, and audit trails. netOrk produces all of these as
|
||||||
|
day-to-day operational outputs: full device inventory, per-device update status,
|
||||||
|
Wazuh CVE tracking, EOL firmware/OS flagging, RBAC with MFA, Git-backed config
|
||||||
|
snapshots with diff/restore, config drift detection, and a complete audit log.
|
||||||
|
|
||||||
|
10. **Build your own view** — Configurable, shareable dashboards: pick from 13
|
||||||
|
widgets, arrange them on a WYSIWYG grid, and share the result with colleagues
|
||||||
|
who can subscribe to the live version or clone their own copy.
|
||||||
|
|
||||||
|
11. **From zero to managed in one flow** — Provision a Cloud-Init VM on a
|
||||||
|
Proxmox hypervisor, assign Ansible roles to configure it, and netOrk
|
||||||
|
auto-links it as a Device — no separate tools, no manual SSH-and-copy.
|
||||||
|
|
||||||
|
12. **Reach sites netOrk can't touch directly** — Deploy a lightweight
|
||||||
|
Satellite agent to poll devices locally at a disconnected or firewalled
|
||||||
|
site and sync results back over HTTPS; scheduled fixes route through it
|
||||||
|
the same way they do for directly reachable devices.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature List
|
## Feature List
|
||||||
@@ -76,64 +112,223 @@ hardware and want operational visibility beyond what consumer dashboards offer.
|
|||||||
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
|
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
|
||||||
- Site assignment with FK to structured Site records
|
- Site assignment with FK to structured Site records
|
||||||
- AP Profile assignment for grouped OpenWRT config
|
- AP Profile assignment for grouped OpenWRT config
|
||||||
|
- Web SSH terminal: sessions log in with each user's own SSH key, never the
|
||||||
|
device's shared account; opened and refused sessions are recorded. Sessions
|
||||||
|
are movable, dockable windows that survive navigating away
|
||||||
|
- A device can hold several roles at once (e.g. storage + hypervisor + Linux)
|
||||||
|
- One device per address per site; duplicates are refused (VMs exempt)
|
||||||
|
- Business criticality per device and site, used in vulnerability ranking
|
||||||
|
|
||||||
### Discovery
|
### Discovery
|
||||||
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
|
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
|
||||||
- Device fingerprinting: vendor + platform confidence scoring
|
- Device fingerprinting: vendor + platform confidence scoring
|
||||||
- FQDN resolution (reverse DNS)
|
- FQDN resolution (reverse DNS)
|
||||||
- Manual adoption from scan results (no auto-create to avoid inventory noise)
|
- Manual adoption from scan results (no auto-create to avoid inventory noise)
|
||||||
|
- Discovery jobs in a sortable, filterable table, grouped per site
|
||||||
|
- LAN Scan: ping sweep from netOrk, each site satellite and every firewall;
|
||||||
|
live results with MAC and manufacturer; a finished scan becomes a discovery
|
||||||
|
job in one step
|
||||||
|
|
||||||
|
### VM Provisioning
|
||||||
|
- Cloud-Init based VM creation directly from a hypervisor's VMs tab — no
|
||||||
|
manual template or VMID setup
|
||||||
|
- Multi-distro image catalog: Debian 12, Ubuntu 22.04/24.04/26.04,
|
||||||
|
Fedora 42/43/44, with Ubuntu and Fedora releases synced automatically as
|
||||||
|
new versions ship
|
||||||
|
- Pick a target VLAN and an IP from its subnet — netOrk creates the DHCP
|
||||||
|
reservation automatically
|
||||||
|
- Cloud-init provisions a real Linux user with an SSH key, plus configurable
|
||||||
|
bootstrap toggles (SNMP, QEMU guest agent)
|
||||||
|
- Reusable provisioning templates for repeatable bootstrap settings
|
||||||
|
- The new VM is auto-linked as a netOrk Device and its hostname assigned to
|
||||||
|
a DNS zone once bootstrap finishes
|
||||||
|
- Deploy progress shown as a live step checklist in the UI
|
||||||
|
- Delete a VM and its linked netOrk Device together, gated behind a
|
||||||
|
name-confirmation prompt
|
||||||
|
|
||||||
### Supported Device Drivers
|
### Supported Device Drivers
|
||||||
Custom NAPALM drivers for all of the following:
|
Custom NAPALM drivers for all of the following:
|
||||||
|
|
||||||
| Driver | Device type |
|
| Driver | Device type |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `openwrt` | OpenWRT access points |
|
| `fritzbox` | AVM Fritz!Box routers (read-only) |
|
||||||
| `opnsense` | OPNsense firewalls |
|
| `hpe_officeconnect` | HPE OfficeConnect 1820 / 1920S switches |
|
||||||
| `proxmox` | Proxmox VE hypervisors |
|
|
||||||
| `linux` | Generic Linux servers |
|
| `linux` | Generic Linux servers |
|
||||||
| `procurve` | HP ProCurve / Aruba switches |
|
| `netgear_plus` | Netgear Plus switches (web UI) |
|
||||||
| `tplink_jetstream` | TP-Link Jetstream managed switches |
|
| `netgear_smart` | Netgear Smart Managed Pro switches |
|
||||||
| `netgear` | Netgear switches |
|
|
||||||
| `fritzbox` | AVM Fritz!Box routers |
|
|
||||||
| `zyxel` | Zyxel switches |
|
|
||||||
| `openmediavault` | OpenMediaVault NAS |
|
| `openmediavault` | OpenMediaVault NAS |
|
||||||
|
| `openwrt` | OpenWrt routers and access points |
|
||||||
|
| `opnsense` | OPNsense firewalls |
|
||||||
|
| `procurve` | HPE ProCurve / Aruba switches |
|
||||||
|
| `proxmox` | Proxmox VE hypervisors |
|
||||||
|
| `qnap_qts` | QNAP NAS on QTS |
|
||||||
| `sonos` | Sonos speakers |
|
| `sonos` | Sonos speakers |
|
||||||
|
| `tplink_jetstream` | TP-Link JetStream managed switches |
|
||||||
|
| `yealink` | Yealink IP phones |
|
||||||
|
| `zyxel` | Zyxel VMG routers (not switches) |
|
||||||
|
|
||||||
Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS.
|
The built-in NAPALM drivers (Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS)
|
||||||
|
are installed but not tested with netOrk and get none of its driver-specific
|
||||||
|
features. Capability matrix (audited against v0.28.0): see `src/pages/Drivers.tsx`.
|
||||||
|
Reboot from netOrk actually restarts only OpenWrt and Proxmox.
|
||||||
|
|
||||||
### Networking & Inventory
|
### Networking & Inventory
|
||||||
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
|
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
|
||||||
- LLDP neighbor discovery and topology graph
|
- LLDP neighbor discovery and topology graph, plus links derived from switch
|
||||||
|
MAC tables (drawn dashed)
|
||||||
|
- Radio problems between the access points of a site are reported
|
||||||
- ARP table and DHCP lease browser per device
|
- ARP table and DHCP lease browser per device
|
||||||
- Subnet browser with interface-to-subnet assignments
|
- Subnet browser with interface-to-subnet assignments
|
||||||
- VLAN list grouped by site; per-VLAN device membership view
|
- VLAN list grouped by site; per-VLAN device membership view
|
||||||
- SSID management with push to OpenWRT APs via UCI
|
- SSID management with push to OpenWRT APs via UCI
|
||||||
|
- Per-SSID MAC access control lists (whitelist / blacklist) pushed to every
|
||||||
|
AP broadcasting the SSID, quick-add straight from the Connected Clients list
|
||||||
|
- MAC ACL state is a first-class drift item — covered by the same drift
|
||||||
|
detection, scheduled auto-fix, and warning aggregation as any other config
|
||||||
|
drift
|
||||||
|
- Dedicated Access Control Lists tab on the Wireless page listing every SSID
|
||||||
|
with its ACL editor inline
|
||||||
|
|
||||||
### Configuration Management
|
### Configuration Management
|
||||||
- Config drift detection: desired state (DB) vs device state (poll snapshot)
|
- Config drift detection: desired state (DB) vs device state (poll snapshot)
|
||||||
- One-click drift fix stream with live SSH output in the browser
|
- One-click drift fix stream with live SSH output in the browser
|
||||||
- UCI-based config push for OpenWRT (VLAN names, SSID settings, radio config)
|
- UCI-based config push for OpenWRT (VLAN names, SSID settings, radio config)
|
||||||
- AP profile system: country code, HT/VHT mode, 802.11r, NTP, syslog, SSH port
|
- AP profile system: country code, HT/VHT mode, 802.11r, NTP, syslog, SSH port
|
||||||
|
- Configuration backup & versioning: every poll captures a config snapshot into
|
||||||
|
a local Git repository, with full history and a side-by-side diff viewer
|
||||||
|
between any two points in time
|
||||||
|
- One-click config restore for OPNsense from any prior snapshot
|
||||||
|
- Unauthorised configuration changes are surfaced as a device warning
|
||||||
|
- netOrk's own config pushes (drift fixes, ACL provisioning) are recognized
|
||||||
|
and auto-accepted as the new baseline — never mistaken for an unauthorized
|
||||||
|
change
|
||||||
|
|
||||||
|
### Configuration Automation (Ansible)
|
||||||
|
- Reusable Ansible roles and playbooks stored and edited directly in
|
||||||
|
netOrk — no separate git checkout
|
||||||
|
- 16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
|
||||||
|
portainer, watchtower, uptime-kuma, vaultwarden, stalwart, bulwark, searxng,
|
||||||
|
postiz, listmonk, wireguard, fail2ban
|
||||||
|
- Roles state their resource needs; undersized hosts are refused with a reason
|
||||||
|
- Automatic dependency resolution — assigning `docker` pulls in `base`
|
||||||
|
automatically, no manual role ordering
|
||||||
|
- Built-in roles can't be deleted but are fully editable; customizations
|
||||||
|
survive upgrades, and only untouched files auto-heal on bugfixes
|
||||||
|
- `ansible-doc`-backed autocomplete while writing roles and playbooks
|
||||||
|
- Upload your own role as an archive
|
||||||
|
- Device-level role assignment with a dedicated Ansible tab on the device
|
||||||
|
detail page
|
||||||
|
- Run history per device, snapshotting the exact role/playbook content
|
||||||
|
that was executed
|
||||||
|
- Wired into VM provisioning: assign roles at VM-creation time and they
|
||||||
|
run automatically after boot
|
||||||
|
|
||||||
### Scheduled Operations
|
### Scheduled Operations
|
||||||
- Scheduled reboots for OpenWRT APs with per-site concurrency lock
|
- Scheduled reboots for OpenWRT APs with per-site concurrency lock
|
||||||
- Failback cron script written to device for netOrk-unreachable scenarios
|
- Failback cron script written to device for netOrk-unreachable scenarios
|
||||||
- Scheduled config drift fixes with time-window enforcement
|
- Scheduled config drift fixes with time-window enforcement
|
||||||
- Package update scheduling and one-click apply
|
- Package update scheduling and one-click apply
|
||||||
|
- Wake-on-LAN via a firewall's driver (OPNsense today) — saved WOL targets
|
||||||
|
with on-demand "Wake now" and recurring schedules; save a seen host as a
|
||||||
|
target directly from the DHCP/ARP tabs
|
||||||
|
|
||||||
|
### Satellite Deployments
|
||||||
|
- Lightweight Docker agent deployed at a site netOrk can't reach directly —
|
||||||
|
polls devices locally and syncs results back to Central over HTTPS
|
||||||
|
- Deployed in one flow via VM provisioning: pick a hypervisor and site,
|
||||||
|
netOrk provisions the VM and installs the satellite container automatically
|
||||||
|
- Central automatically skips direct polling for any device at a site with
|
||||||
|
an online, heartbeating satellite — no manual per-site toggling
|
||||||
|
- Scheduled/on-demand reboots and the SNMP auto-fix flow run through the
|
||||||
|
same command channel whether a device is directly reachable or behind a
|
||||||
|
satellite
|
||||||
|
- Discovery jobs at a satellite-covered site scan locally through the same
|
||||||
|
command channel, instead of failing to reach the subnet from Central
|
||||||
|
- Not yet satellite-covered: SNMP health-metric polling still runs from
|
||||||
|
Central, and WebSSH console access isn't available through a satellite
|
||||||
|
|
||||||
### Monitoring & Health
|
### Monitoring & Health
|
||||||
- SNMP health metrics (CPU, memory, interface counters) via `get_health_metrics()`
|
- SNMP health metrics (CPU, memory, interface counters) via `get_health_metrics()`
|
||||||
- Per-device warning system with severity levels (error / warning / info)
|
- Per-device warning system with severity levels (error / warning / info)
|
||||||
- Docker container and image status (Proxmox/Linux)
|
- One-click Ack on any warning — clears it immediately and writes an audit log
|
||||||
|
entry; for config-change warnings the current state is accepted as the new
|
||||||
|
baseline
|
||||||
|
- Docker container and image status (Linux, OpenMediaVault, QNAP)
|
||||||
- Service status and start/stop/restart (systemd)
|
- Service status and start/stop/restart (systemd)
|
||||||
- VM/container list with OS device cross-linking (Proxmox)
|
- VM/container list with OS device cross-linking (Proxmox)
|
||||||
|
- Per-device availability windows — suppress OFFLINE status and poll-failure
|
||||||
|
warnings during expected downtime (e.g. a nightly power-off); opt-in,
|
||||||
|
unconfigured devices are unaffected
|
||||||
|
- OPNsense: BGP neighbor status polling and display, with a peer-down warning
|
||||||
|
- OPNsense: TLS certificate monitoring for the Trust store, with
|
||||||
|
expiring-soon / expired warnings
|
||||||
|
- OPNsense: Dynamic DNS service-down warning (os-ddclient)
|
||||||
|
- Service checks about once a minute (DNS, NTP, VPN tunnels, core daemons,
|
||||||
|
gateways), derived automatically; three failures before an alert; can run
|
||||||
|
from satellites, including a DHCP check
|
||||||
|
- Site reachability: polling pauses behind a dead tunnel, one warning names
|
||||||
|
it, everything is re-polled when it returns
|
||||||
|
|
||||||
|
### Dashboards
|
||||||
|
- Configurable, shareable dashboards — build your own from a widget picker
|
||||||
|
instead of a fixed layout
|
||||||
|
- WYSIWYG grid-layout editor: drag, resize, and arrange widgets on a canvas
|
||||||
|
- 18 widget types: stats, device warnings, recently updated devices, network
|
||||||
|
topology, EOL status, config drift summary, Wazuh security alerts, audit log
|
||||||
|
activity, discovery jobs status, upcoming scheduled actions, DNS zones
|
||||||
|
overview, site overview, config snapshot history, managed services,
|
||||||
|
certificate expiry, outdated Docker images, firewall profile deployment
|
||||||
|
status, service checks
|
||||||
|
- Multi-instance widgets with independent per-widget settings
|
||||||
|
- Share a dashboard with specific users; recipients can subscribe to the
|
||||||
|
owner's live version or clone it into their own editable copy
|
||||||
|
- Favorite dashboards for quick access from the main menu; set any dashboard
|
||||||
|
as your home view
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- Signal messages for everything netOrk watches; each person registers their
|
||||||
|
own number, administrators pair netOrk once via QR code
|
||||||
|
- One message per site outage, daily summary for recurring items, hourly
|
||||||
|
bundling, quiet hours per number, mute per kind, full history with reasons
|
||||||
|
|
||||||
|
### DHCP
|
||||||
|
- DHCP reservations: import from the firewall, validated, diff, then apply
|
||||||
|
(adds and updates only)
|
||||||
|
- DHCP subnets (Kea on OPNsense) with options and search domains; settings
|
||||||
|
that break a network are refused
|
||||||
|
|
||||||
|
### Managed Services
|
||||||
|
- Every container-based service across devices with endpoints, TLS
|
||||||
|
certificates and access rules
|
||||||
|
- Compose editor with masked secrets and automatic backup snapshot; redeploy
|
||||||
|
is a separate confirmed step
|
||||||
|
- Zoraxy vhosts editable and written back; PostgreSQL databases listed
|
||||||
|
|
||||||
|
### Security Assessment
|
||||||
|
- Security tab per device: TLS/SSH grades A–F, installed software and
|
||||||
|
container images matched against known vulnerabilities, hardening benchmarks
|
||||||
|
- Ratings adjusted to the device (local access, trusted network, not running,
|
||||||
|
not booted kernel; raised when exploited in the wild)
|
||||||
|
- Kernel reboot recommendation with the vulnerabilities it would clear
|
||||||
|
- Exposure from firewall rules; internet-visible ports and abuse reports for
|
||||||
|
own public addresses; on-demand hardening audit and web scan
|
||||||
|
- Vulnerability data from the netOrk Knowledge Base (licence required)
|
||||||
|
|
||||||
|
### Vulnerability Management
|
||||||
|
- Triage queue across all devices, one row per vulnerability, ordered by
|
||||||
|
remediation deadline, exploitation, severity, likelihood, criticality, spread
|
||||||
|
- Decisions (not applicable / accept until / defer until / fixed) with a
|
||||||
|
mandatory reason; accept and not-applicable need an elevated permission
|
||||||
|
- Deferred and accepted items return by themselves; ignored ones go overdue
|
||||||
|
- Daily reassessment verifies fixes and reopens regressions
|
||||||
|
|
||||||
### Security Integrations (plugins)
|
### Security Integrations (plugins)
|
||||||
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
|
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
|
||||||
recent alert history, CIS benchmark scores, one-click agent install fix stream
|
recent alert history, CIS benchmark scores, one-click agent install fix stream
|
||||||
- **Graylog** — rsyslog forwarding status per device, one-click fix to write rule
|
- **Graylog** — rsyslog forwarding status per device, one-click fix to write rule
|
||||||
- **CrowdSec** — org-level decisions, remediation metrics, top attack scenarios
|
- **CrowdSec** — org-level decisions, remediation metrics, top attack scenarios
|
||||||
|
- **EOL Tracking** — flags devices running end-of-life or soon-to-be-end-of-life
|
||||||
|
firmware/OS via the endoflife.date API, checked daily
|
||||||
|
|
||||||
### DNS
|
### DNS
|
||||||
- DNS zone management with authoritative device assignment
|
- DNS zone management with authoritative device assignment
|
||||||
@@ -141,11 +336,26 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
|||||||
- PTR record provisioning to reverse zones
|
- PTR record provisioning to reverse zones
|
||||||
- Pending job queue for zone changes when device is unreachable
|
- Pending job queue for zone changes when device is unreachable
|
||||||
|
|
||||||
|
### RADIUS Management
|
||||||
|
- Global FreeRADIUS server, NAS client, and user management — no site
|
||||||
|
scoping, usable from any site's SSIDs/APs
|
||||||
|
- Changes push to the device via the driver and are only stored after the
|
||||||
|
device confirms — avoids drift between netOrk's view and the actual
|
||||||
|
FreeRADIUS config
|
||||||
|
- Dedicated server list and detail page (NAS clients / users tabs) under
|
||||||
|
the Wireless section
|
||||||
|
- SSID 802.1X integration (auto-provisioning a NAS client from an SSID's
|
||||||
|
RADIUS server) is a deliberate follow-up, not included yet
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
- JWT authentication with remember-me (localStorage) or session-only (sessionStorage)
|
- JWT authentication with remember-me (localStorage) or session-only (sessionStorage)
|
||||||
|
- Two-factor authentication (MFA/TOTP) — authenticator app at login, backup
|
||||||
|
codes for emergencies, session invalidation on TOTP changes, enforceable
|
||||||
|
per role
|
||||||
- RBAC with four built-in roles: viewer / operator / engineer / administrator
|
- RBAC with four built-in roles: viewer / operator / engineer / administrator
|
||||||
- Custom roles with any permission combination
|
- Custom roles with any permission combination
|
||||||
- Full audit log of all orchestration actions
|
- Full audit log of all orchestration actions, filterable by date range,
|
||||||
|
user, action, or resource — export to CSV or PDF
|
||||||
|
|
||||||
### NetBox Sync
|
### NetBox Sync
|
||||||
- Pushes vendor, model, OS version, status to NetBox dcim.devices
|
- Pushes vendor, model, OS version, status to NetBox dcim.devices
|
||||||
@@ -163,11 +373,15 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
|||||||
|
|
||||||
## Architecture in One Paragraph
|
## Architecture in One Paragraph
|
||||||
|
|
||||||
netOrk runs as five Docker containers: a FastAPI API server, two Celery worker
|
netOrk runs as a set of Docker containers: a FastAPI API server, three Celery
|
||||||
pools (general + poll), a Celery Beat scheduler, and an nginx UI server. Redis
|
worker pools (general, poll, and Ansible), a Celery Beat scheduler, and an
|
||||||
is the broker. PostgreSQL stores all state. Device communication is always
|
nginx UI server. Redis is the broker. PostgreSQL stores all state. Device
|
||||||
blocking I/O executed in Celery workers — FastAPI request handlers are
|
communication is always blocking I/O executed in Celery workers — FastAPI
|
||||||
async-only for DB and quick operations. Custom NAPALM drivers live in `vendor/`
|
request handlers are async-only for DB and quick operations. Custom NAPALM
|
||||||
as editable packages and self-register via `@register_driver`. The plugin system
|
drivers live in `vendor/` as editable packages and self-register via
|
||||||
(`netork/plugins/`) provides a hook bus, a plugin registry with enable/disable
|
`@register_driver`. The plugin system (`netork/plugins/`) provides a hook bus,
|
||||||
state in the DB, and a documented pattern for adding integrations.
|
a plugin registry with enable/disable state in the DB, and a documented
|
||||||
|
pattern for adding integrations. For sites Central can't reach directly, a
|
||||||
|
separate Satellite container polls devices locally and syncs results back
|
||||||
|
over HTTPS; Central dispatches actions (reboots, SNMP fixes) to it through a
|
||||||
|
generic command channel, transparently to the UI.
|
||||||
|
|||||||
+18
@@ -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
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2742
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
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 |
@@ -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())
|
||||||
@@ -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.
|
||||||
Executable
+470
@@ -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())
|
||||||
Executable
+89
@@ -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
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy the marketing site to the host that actually serves it.
|
||||||
|
#
|
||||||
|
# ./scripts/deploy.sh [--version=<tag>] [server]
|
||||||
|
#
|
||||||
|
# This used to be a CI job. It ran `docker run` on whichever runner picked the
|
||||||
|
# job up, which was correct while exactly one runner existed — there are now
|
||||||
|
# several, none of them on the host this site runs on, so the job would have
|
||||||
|
# started a second container in the wrong place and reported success. Naming the
|
||||||
|
# target is the whole point of this script.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER="${DEPLOY_SERVER:-10.7.224.11}"
|
||||||
|
REGISTRY="${REGISTRY:-git.netork.io/netork}"
|
||||||
|
VERSION="${VERSION:-latest}"
|
||||||
|
NAME="${CONTAINER_NAME:-netork-website}"
|
||||||
|
|
||||||
|
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/deploy.env"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--version=*) VERSION="${arg#--version=}" ;;
|
||||||
|
*) SERVER="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
IMAGE="${REGISTRY}/website:${VERSION}"
|
||||||
|
echo "[${SERVER}] Deploying ${IMAGE}"
|
||||||
|
|
||||||
|
if [[ -n "${REGISTRY_TOKEN:-}" ]]; then
|
||||||
|
ssh -n "$SERVER" "echo '${REGISTRY_TOKEN}' | docker login git.netork.io -u '${REGISTRY_USER:-christianmanivong}' --password-stdin" \
|
||||||
|
| sed "s/^/[${SERVER}] /"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull first, and let a failure stop the script here: the container is only
|
||||||
|
# removed once there is something to replace it with.
|
||||||
|
echo "[${SERVER}] Pulling..."
|
||||||
|
ssh -n "$SERVER" "docker pull '${IMAGE}'" | tail -2 | sed "s/^/[${SERVER}] /"
|
||||||
|
|
||||||
|
echo "[${SERVER}] Recreating..."
|
||||||
|
ssh -n "$SERVER" "docker rm -f '${NAME}' >/dev/null 2>&1 || true; \
|
||||||
|
docker run -d --name '${NAME}' --restart unless-stopped --network proxy-net '${IMAGE}' >/dev/null && echo started" \
|
||||||
|
| sed "s/^/[${SERVER}] /"
|
||||||
|
|
||||||
|
# `docker run` cannot silently reuse an old container the way `compose up -d`
|
||||||
|
# can, but the tag it resolved might still not be the one that was just pulled.
|
||||||
|
# Compare, rather than trust.
|
||||||
|
echo "[${SERVER}] Verifying..."
|
||||||
|
WANT=$(ssh -n "$SERVER" "docker image inspect --format '{{.Id}}' '${IMAGE}'")
|
||||||
|
GOT=$(ssh -n "$SERVER" "docker inspect --format '{{.Image}}' '${NAME}'")
|
||||||
|
if [[ "$WANT" != "$GOT" ]]; then
|
||||||
|
echo "[${SERVER}] ERROR: container runs ${GOT}, expected ${WANT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[${SERVER}] Verified: ${NAME} runs ${IMAGE}."
|
||||||
|
|
||||||
|
echo "[${SERVER}] Checking the site answers..."
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://netork.io/ || echo 000)
|
||||||
|
echo "[${SERVER}] https://netork.io -> ${CODE}"
|
||||||
|
[[ "$CODE" == "200" ]] || { echo "[${SERVER}] ERROR: site is not answering 200" >&2; exit 1; }
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
export type GlossaryCategory = 'networking' | 'security' | 'compliance' | 'architecture' | 'general'
|
||||||
|
|
||||||
|
export interface GlossaryEntry {
|
||||||
|
id: string
|
||||||
|
category: GlossaryCategory
|
||||||
|
display: string
|
||||||
|
fullName?: { en: string; de: string }
|
||||||
|
definition: { en: string; de: string }
|
||||||
|
match: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_ORDER: GlossaryCategory[] = ['networking', 'security', 'compliance', 'architecture', 'general']
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<GlossaryCategory, { en: string; de: string }> = {
|
||||||
|
networking: { en: 'Networking & Protocols', de: 'Netzwerk & Protokolle' },
|
||||||
|
security: { en: 'Security & Access', de: 'Sicherheit & Zugriff' },
|
||||||
|
compliance: { en: 'Compliance', de: 'Compliance' },
|
||||||
|
architecture: { en: 'netOrk Architecture', de: 'netOrk-Architektur' },
|
||||||
|
general: { en: 'General Tech', de: 'Allgemeine Technik' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOSSARY: GlossaryEntry[] = [
|
||||||
|
// ── Networking & Protocols ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ap',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'AP',
|
||||||
|
fullName: { en: 'Access Point', de: 'Access Point' },
|
||||||
|
definition: {
|
||||||
|
en: 'A device that lets wireless clients join the network — in netOrk, typically an OpenWRT-managed radio.',
|
||||||
|
de: 'Ein Gerät, über das sich WLAN-Clients mit dem Netzwerk verbinden — in netOrk in der Regel ein von OpenWRT verwalteter Access Point.',
|
||||||
|
},
|
||||||
|
match: ['APs', 'AP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'ARP',
|
||||||
|
fullName: { en: 'Address Resolution Protocol', de: 'Address Resolution Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol that maps an IP address to the physical MAC address of a device on the same network segment.',
|
||||||
|
de: 'Das Protokoll, das eine IP-Adresse auf die physische MAC-Adresse eines Geräts im selben Netzwerksegment abbildet.',
|
||||||
|
},
|
||||||
|
match: ['ARP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dhcp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'DHCP',
|
||||||
|
fullName: { en: 'Dynamic Host Configuration Protocol', de: 'Dynamic Host Configuration Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol that automatically hands out IP addresses and network settings to devices as they join.',
|
||||||
|
de: 'Das Protokoll, das Geräten beim Verbinden automatisch IP-Adressen und Netzwerkeinstellungen zuweist.',
|
||||||
|
},
|
||||||
|
match: ['DHCP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dns',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'DNS',
|
||||||
|
fullName: { en: 'Domain Name System', de: 'Domain Name System' },
|
||||||
|
definition: {
|
||||||
|
en: 'Translates human-readable hostnames into IP addresses.',
|
||||||
|
de: 'Übersetzt menschenlesbare Hostnamen in IP-Adressen.',
|
||||||
|
},
|
||||||
|
match: ['DNS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fqdn',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'FQDN',
|
||||||
|
fullName: { en: 'Fully Qualified Domain Name', de: 'Fully Qualified Domain Name' },
|
||||||
|
definition: {
|
||||||
|
en: 'The complete domain name that uniquely identifies a host, e.g. router.example.com.',
|
||||||
|
de: 'Der vollständige Domainname, der einen Host eindeutig identifiziert, z. B. router.example.com.',
|
||||||
|
},
|
||||||
|
match: ['FQDN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'http-https',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'HTTP/HTTPS',
|
||||||
|
fullName: { en: 'Hypertext Transfer Protocol (Secure)', de: 'Hypertext Transfer Protocol (Secure)' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol web browsers and APIs use to exchange data; HTTPS adds TLS encryption on top.',
|
||||||
|
de: 'Das Protokoll, über das Webbrowser und APIs Daten austauschen; HTTPS ergänzt es um TLS-Verschlüsselung.',
|
||||||
|
},
|
||||||
|
match: ['HTTP/HTTPS', 'HTTPS', 'HTTP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'icmp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'ICMP',
|
||||||
|
fullName: { en: 'Internet Control Message Protocol', de: 'Internet Control Message Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol behind network diagnostics like ping — used by netOrk\'s discovery sweep to find live hosts.',
|
||||||
|
de: 'Das Protokoll hinter Netzwerkdiagnosen wie Ping — netOrk nutzt es beim Discovery-Sweep, um aktive Hosts zu finden.',
|
||||||
|
},
|
||||||
|
match: ['ICMP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'IP',
|
||||||
|
fullName: { en: 'Internet Protocol', de: 'Internet Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The addressing scheme (IPv4/IPv6) that lets devices find and reach each other on a network.',
|
||||||
|
de: 'Das Adressierungsschema (IPv4/IPv6), über das Geräte sich im Netzwerk finden und erreichen.',
|
||||||
|
},
|
||||||
|
match: ['IP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lldp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'LLDP',
|
||||||
|
fullName: { en: 'Link Layer Discovery Protocol', de: 'Link Layer Discovery Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'Lets neighboring devices advertise their identity and capabilities, which netOrk uses to build the topology graph.',
|
||||||
|
de: 'Ermöglicht benachbarten Geräten, Identität und Fähigkeiten bekanntzugeben — netOrk nutzt das für den Topologie-Graphen.',
|
||||||
|
},
|
||||||
|
match: ['LLDP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mac',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'MAC',
|
||||||
|
fullName: { en: 'Media Access Control (address)', de: 'Media Access Control (Adresse)' },
|
||||||
|
definition: {
|
||||||
|
en: 'The hardware address burned into a network interface, unique per device.',
|
||||||
|
de: 'Die in eine Netzwerkschnittstelle eingebrannte Hardware-Adresse, eindeutig pro Gerät.',
|
||||||
|
},
|
||||||
|
match: ['MAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mtu',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'MTU',
|
||||||
|
fullName: { en: 'Maximum Transmission Unit', de: 'Maximum Transmission Unit' },
|
||||||
|
definition: {
|
||||||
|
en: 'The largest packet size an interface will forward without fragmenting it.',
|
||||||
|
de: 'Die größte Paketgröße, die eine Schnittstelle weiterleitet, ohne sie zu fragmentieren.',
|
||||||
|
},
|
||||||
|
match: ['MTU'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ntp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'NTP',
|
||||||
|
fullName: { en: 'Network Time Protocol', de: 'Network Time Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'Keeps device clocks synchronized — netOrk pushes NTP settings as part of AP profiles.',
|
||||||
|
de: 'Synchronisiert die Uhrzeit von Geräten — netOrk setzt NTP-Einstellungen als Teil von AP-Profilen.',
|
||||||
|
},
|
||||||
|
match: ['NTP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ptr-record',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'PTR record',
|
||||||
|
definition: {
|
||||||
|
en: 'A reverse-DNS record that maps an IP address back to a hostname.',
|
||||||
|
de: 'Ein Reverse-DNS-Eintrag, der eine IP-Adresse auf einen Hostnamen zurückführt.',
|
||||||
|
},
|
||||||
|
match: ['PTR record', 'PTR'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snmp',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SNMP',
|
||||||
|
fullName: { en: 'Simple Network Management Protocol', de: 'Simple Network Management Protocol' },
|
||||||
|
definition: {
|
||||||
|
en: 'The protocol netOrk uses to pull health metrics — CPU, memory, interface counters — straight from devices.',
|
||||||
|
de: 'Das Protokoll, über das netOrk Gesundheitsmetriken — CPU, Speicher, Schnittstellenzähler — direkt von Geräten abfragt.',
|
||||||
|
},
|
||||||
|
match: ['SNMP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SSH',
|
||||||
|
fullName: { en: 'Secure Shell', de: 'Secure Shell' },
|
||||||
|
definition: {
|
||||||
|
en: 'An encrypted remote-access protocol — netOrk uses it to run commands, push config, and stream the browser-based console.',
|
||||||
|
de: 'Ein verschlüsseltes Protokoll für den Fernzugriff — netOrk nutzt es, um Befehle auszuführen, Konfigurationen zu pushen und die browserbasierte Konsole zu streamen.',
|
||||||
|
},
|
||||||
|
match: ['SSH'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssid',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'SSID',
|
||||||
|
fullName: { en: 'Service Set Identifier', de: 'Service Set Identifier' },
|
||||||
|
definition: {
|
||||||
|
en: 'The public name of a Wi-Fi network, e.g. what shows up in a phone\'s Wi-Fi list.',
|
||||||
|
de: 'Der öffentliche Name eines WLANs — das, was z. B. in der WLAN-Liste eines Smartphones erscheint.',
|
||||||
|
},
|
||||||
|
match: ['SSIDs', 'SSID'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vlan',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'VLAN',
|
||||||
|
fullName: { en: 'Virtual Local Area Network', de: 'Virtual Local Area Network' },
|
||||||
|
definition: {
|
||||||
|
en: 'A logically segmented broadcast domain that runs on shared switching hardware.',
|
||||||
|
de: 'Eine logisch abgegrenzte Broadcast-Domäne, die auf gemeinsam genutzter Switching-Hardware läuft.',
|
||||||
|
},
|
||||||
|
match: ['VLANs', 'VLAN'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security & Access ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'cis-benchmark',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CIS',
|
||||||
|
fullName: { en: 'Center for Internet Security', de: 'Center for Internet Security' },
|
||||||
|
definition: {
|
||||||
|
en: 'A vendor-neutral hardening standard — netOrk surfaces a device\'s CIS benchmark score via the Wazuh integration.',
|
||||||
|
de: 'Ein herstellerneutraler Hardening-Standard — netOrk zeigt den CIS-Benchmark-Score eines Geräts über die Wazuh-Integration.',
|
||||||
|
},
|
||||||
|
match: ['CIS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cve',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CVE',
|
||||||
|
fullName: { en: 'Common Vulnerabilities and Exposures', de: 'Common Vulnerabilities and Exposures' },
|
||||||
|
definition: {
|
||||||
|
en: 'A public identifier for a known security vulnerability, e.g. CVE-2026-44405.',
|
||||||
|
de: 'Eine öffentliche Kennung für eine bekannte Sicherheitslücke, z. B. CVE-2026-44405.',
|
||||||
|
},
|
||||||
|
match: ['CVEs', 'CVE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cvss',
|
||||||
|
category: 'security',
|
||||||
|
display: 'CVSS',
|
||||||
|
fullName: { en: 'Common Vulnerability Scoring System', de: 'Common Vulnerability Scoring System' },
|
||||||
|
definition: {
|
||||||
|
en: 'A standardized 0–10 score for how severe a vulnerability is.',
|
||||||
|
de: 'Ein standardisierter Score von 0–10 für den Schweregrad einer Sicherheitslücke.',
|
||||||
|
},
|
||||||
|
match: ['CVSS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eol',
|
||||||
|
category: 'security',
|
||||||
|
display: 'EOL',
|
||||||
|
fullName: { en: 'End of Life', de: 'End of Life' },
|
||||||
|
definition: {
|
||||||
|
en: 'Software or firmware that no longer receives vendor security updates.',
|
||||||
|
de: 'Software oder Firmware, die keine Sicherheitsupdates vom Hersteller mehr erhält.',
|
||||||
|
},
|
||||||
|
match: ['EOL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hmac-sha256',
|
||||||
|
category: 'security',
|
||||||
|
display: 'HMAC-SHA256',
|
||||||
|
fullName: { en: 'Hash-based Message Authentication Code (SHA-256)', de: 'Hash-based Message Authentication Code (SHA-256)' },
|
||||||
|
definition: {
|
||||||
|
en: 'A cryptographic signature that proves a webhook payload wasn\'t tampered with in transit.',
|
||||||
|
de: 'Eine kryptografische Signatur, die belegt, dass ein Webhook-Payload unterwegs nicht manipuliert wurde.',
|
||||||
|
},
|
||||||
|
match: ['HMAC-SHA256', 'HMAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jwt',
|
||||||
|
category: 'security',
|
||||||
|
display: 'JWT',
|
||||||
|
fullName: { en: 'JSON Web Token', de: 'JSON Web Token' },
|
||||||
|
definition: {
|
||||||
|
en: 'A signed token that proves a logged-in user\'s identity on every API request.',
|
||||||
|
de: 'Ein signiertes Token, das die Identität eines angemeldeten Benutzers bei jeder API-Anfrage belegt.',
|
||||||
|
},
|
||||||
|
match: ['JWT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mfa',
|
||||||
|
category: 'security',
|
||||||
|
display: 'MFA',
|
||||||
|
fullName: { en: 'Multi-Factor Authentication', de: 'Multi-Faktor-Authentifizierung' },
|
||||||
|
definition: {
|
||||||
|
en: 'Requiring a second proof of identity — like a TOTP code — in addition to a password.',
|
||||||
|
de: 'Verlangt neben dem Passwort einen zweiten Identitätsnachweis — etwa einen TOTP-Code.',
|
||||||
|
},
|
||||||
|
match: ['MFA'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kea',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'Kea',
|
||||||
|
fullName: { en: 'ISC Kea DHCP', de: 'ISC Kea DHCP' },
|
||||||
|
definition: {
|
||||||
|
en: 'The DHCP server from ISC that OPNsense uses to hand out addresses; netOrk manages its subnets and reservations.',
|
||||||
|
de: 'Der DHCP-Server von ISC, mit dem OPNsense Adressen vergibt; netOrk verwaltet seine Subnetze und Reservierungen.',
|
||||||
|
},
|
||||||
|
match: ['Kea'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'winrm',
|
||||||
|
category: 'networking',
|
||||||
|
display: 'WinRM',
|
||||||
|
fullName: { en: 'Windows Remote Management', de: 'Windows Remote Management' },
|
||||||
|
definition: {
|
||||||
|
en: 'Microsoft\'s remote management protocol for Windows hosts, the Windows counterpart to SSH for automation.',
|
||||||
|
de: 'Microsofts Protokoll zur Fernverwaltung von Windows-Hosts, das Windows-Gegenstück zu SSH für Automatisierung.',
|
||||||
|
},
|
||||||
|
match: ['WinRM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lapi',
|
||||||
|
category: 'security',
|
||||||
|
display: 'LAPI',
|
||||||
|
fullName: { en: 'CrowdSec Local API', de: 'CrowdSec Local API' },
|
||||||
|
definition: {
|
||||||
|
en: 'The CrowdSec service that collects what its agents detect and holds the resulting ban decisions for one site or host.',
|
||||||
|
de: 'Der CrowdSec-Dienst, der sammelt, was seine Agenten erkennen, und die daraus folgenden Sperrentscheidungen für einen Standort oder Host hält.',
|
||||||
|
},
|
||||||
|
match: ['LAPI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nvd',
|
||||||
|
category: 'security',
|
||||||
|
display: 'NVD',
|
||||||
|
fullName: { en: 'National Vulnerability Database', de: 'National Vulnerability Database' },
|
||||||
|
definition: {
|
||||||
|
en: 'The U.S. government\'s public feed of known CVEs.',
|
||||||
|
de: 'Die öffentliche CVE-Datenbank der US-Regierung.',
|
||||||
|
},
|
||||||
|
match: ['NVD'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'osv',
|
||||||
|
category: 'security',
|
||||||
|
display: 'OSV',
|
||||||
|
fullName: { en: 'Open Source Vulnerabilities', de: 'Open Source Vulnerabilities' },
|
||||||
|
definition: {
|
||||||
|
en: 'A community-run vulnerability database focused on open-source packages.',
|
||||||
|
de: 'Eine community-betriebene Schwachstellendatenbank mit Fokus auf Open-Source-Pakete.',
|
||||||
|
},
|
||||||
|
match: ['OSV'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac',
|
||||||
|
category: 'security',
|
||||||
|
display: 'RBAC',
|
||||||
|
fullName: { en: 'Role-Based Access Control', de: 'Rollenbasierte Zugriffskontrolle' },
|
||||||
|
definition: {
|
||||||
|
en: 'Restricting what a user can see or do based on the role they\'ve been assigned.',
|
||||||
|
de: 'Beschränkt, was ein Benutzer sehen oder tun darf, basierend auf der zugewiesenen Rolle.',
|
||||||
|
},
|
||||||
|
match: ['RBAC'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tls',
|
||||||
|
category: 'security',
|
||||||
|
display: 'TLS',
|
||||||
|
fullName: { en: 'Transport Layer Security', de: 'Transport Layer Security' },
|
||||||
|
definition: {
|
||||||
|
en: 'The encryption protocol behind HTTPS — also used to terminate traffic at an ingress in a Kubernetes deployment.',
|
||||||
|
de: 'Das Verschlüsselungsprotokoll hinter HTTPS — wird z. B. auch zur TLS-Terminierung am Ingress eines Kubernetes-Deployments genutzt.',
|
||||||
|
},
|
||||||
|
match: ['TLS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'totp',
|
||||||
|
category: 'security',
|
||||||
|
display: 'TOTP',
|
||||||
|
fullName: { en: 'Time-based One-Time Password', de: 'Time-based One-Time Password' },
|
||||||
|
definition: {
|
||||||
|
en: 'The rotating 6-digit code generated by an authenticator app, used as a second login factor.',
|
||||||
|
de: 'Der rotierende 6-stellige Code aus einer Authenticator-App, genutzt als zweiter Anmeldefaktor.',
|
||||||
|
},
|
||||||
|
match: ['TOTP'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Compliance ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'nis2',
|
||||||
|
category: 'compliance',
|
||||||
|
display: 'NIS2',
|
||||||
|
fullName: { en: 'Network and Information Security Directive 2', de: 'Network and Information Security Directive 2' },
|
||||||
|
definition: {
|
||||||
|
en: 'The EU\'s second cybersecurity directive — Article 21 sets baseline technical and organizational measures for essential and important entities.',
|
||||||
|
de: 'Die zweite EU-Richtlinie zur Netzwerk- und Informationssicherheit — Art. 21 legt technische und organisatorische Mindestmaßnahmen für wesentliche und wichtige Einrichtungen fest.',
|
||||||
|
},
|
||||||
|
match: ['NIS2'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── netOrk Architecture ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'napalm',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'NAPALM',
|
||||||
|
fullName: {
|
||||||
|
en: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
|
||||||
|
de: 'Network Automation and Programmability Abstraction Layer with Multivendor support',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
en: 'The open-source Python library netOrk uses to talk to network devices through one common interface, regardless of vendor.',
|
||||||
|
de: 'Die Open-Source-Python-Bibliothek, über die netOrk mit Netzwerkgeräten über eine gemeinsame Schnittstelle spricht — herstellerunabhängig.',
|
||||||
|
},
|
||||||
|
match: ['NAPALM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uci',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'UCI',
|
||||||
|
fullName: { en: 'Unified Configuration Interface', de: 'Unified Configuration Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'OpenWRT\'s own configuration system — netOrk pushes VLAN, SSID, and radio settings through it.',
|
||||||
|
de: 'OpenWRTs eigenes Konfigurationssystem — netOrk setzt VLAN-, SSID- und Radio-Einstellungen darüber.',
|
||||||
|
},
|
||||||
|
match: ['UCI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-drift',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Config drift',
|
||||||
|
definition: {
|
||||||
|
en: 'When a device\'s actual configuration silently diverges from the state netOrk expects — usually from a manual change made directly on the device.',
|
||||||
|
de: 'Wenn die tatsächliche Konfiguration eines Geräts stillschweigend vom erwarteten Sollzustand abweicht — meist durch eine manuelle Änderung direkt am Gerät.',
|
||||||
|
},
|
||||||
|
match: ['Config drift', 'config drift', 'Drifted', 'drifted', 'Drift', 'drift'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hook-bus',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Hook bus',
|
||||||
|
definition: {
|
||||||
|
en: 'netOrk\'s internal event system — plugins subscribe handlers to events like poll.complete instead of core code calling into them directly.',
|
||||||
|
de: 'netOrks internes Event-System — Plugins registrieren Handler für Events wie poll.complete, statt dass der Kern-Code sie direkt aufruft.',
|
||||||
|
},
|
||||||
|
match: ['Hook bus', 'hook bus'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plugin-registry',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Plugin registry',
|
||||||
|
definition: {
|
||||||
|
en: 'The database-backed record of which plugins exist and whether each is enabled, checked before a plugin\'s router or hooks run.',
|
||||||
|
de: 'Die datenbankgestützte Übersicht, welche Plugins existieren und ob sie aktiviert sind — wird geprüft, bevor Router oder Hooks eines Plugins laufen.',
|
||||||
|
},
|
||||||
|
match: ['Plugin registry', 'plugin registry'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-snapshot',
|
||||||
|
category: 'architecture',
|
||||||
|
display: 'Configuration snapshot',
|
||||||
|
definition: {
|
||||||
|
en: 'A full copy of a device\'s configuration captured at poll time and committed to Git, so any point in history can be diffed or restored.',
|
||||||
|
de: 'Eine vollständige Kopie der Gerätekonfiguration, die bei jedem Poll erfasst und in Git committet wird — jeder Zeitpunkt lässt sich so diffen oder wiederherstellen.',
|
||||||
|
},
|
||||||
|
match: ['Configuration snapshot', 'configuration snapshot', 'Config snapshot', 'config snapshot'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── General Tech ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'api',
|
||||||
|
category: 'general',
|
||||||
|
display: 'API',
|
||||||
|
fullName: { en: 'Application Programming Interface', de: 'Application Programming Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'A defined set of endpoints other software — or netOrk\'s own UI — uses to talk to a system.',
|
||||||
|
de: 'Eine definierte Menge an Endpunkten, über die andere Software — oder netOrks eigene UI — mit einem System spricht.',
|
||||||
|
},
|
||||||
|
match: ['API'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crud',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CRUD',
|
||||||
|
fullName: { en: 'Create, Read, Update, Delete', de: 'Create, Read, Update, Delete' },
|
||||||
|
definition: {
|
||||||
|
en: 'The four basic operations for managing a record: creating, viewing, editing, and deleting it.',
|
||||||
|
de: 'Die vier Grundoperationen zur Verwaltung eines Datensatzes: anlegen, anzeigen, bearbeiten, löschen.',
|
||||||
|
},
|
||||||
|
match: ['CRUD'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'csv',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CSV',
|
||||||
|
fullName: { en: 'Comma-Separated Values', de: 'Comma-Separated Values' },
|
||||||
|
definition: {
|
||||||
|
en: 'A plain-text spreadsheet format used for exporting tabular data like the audit log.',
|
||||||
|
de: 'Ein textbasiertes Tabellenformat für den Export tabellarischer Daten wie des Audit-Logs.',
|
||||||
|
},
|
||||||
|
match: ['CSV'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cpu',
|
||||||
|
category: 'general',
|
||||||
|
display: 'CPU',
|
||||||
|
fullName: { en: 'Central Processing Unit', de: 'Central Processing Unit' },
|
||||||
|
definition: {
|
||||||
|
en: 'A device\'s processor — netOrk tracks its load as a health metric.',
|
||||||
|
de: 'Der Prozessor eines Geräts — netOrk erfasst dessen Auslastung als Gesundheitsmetrik.',
|
||||||
|
},
|
||||||
|
match: ['CPU'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db',
|
||||||
|
category: 'general',
|
||||||
|
display: 'DB',
|
||||||
|
fullName: { en: 'Database', de: 'Datenbank' },
|
||||||
|
definition: {
|
||||||
|
en: 'Shorthand for the database — where netOrk stores desired device state and configuration.',
|
||||||
|
de: 'Kurzform für die Datenbank — dort speichert netOrk den Sollzustand und die Konfiguration der Geräte.',
|
||||||
|
},
|
||||||
|
match: ['DB'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fk',
|
||||||
|
category: 'general',
|
||||||
|
display: 'FK',
|
||||||
|
fullName: { en: 'Foreign Key', de: 'Foreign Key' },
|
||||||
|
definition: {
|
||||||
|
en: 'A database reference from one record to another — e.g. linking a device to its Site record.',
|
||||||
|
de: 'Ein Datenbank-Verweis von einem Datensatz auf einen anderen — z. B. die Verknüpfung eines Geräts mit seinem Standort-Datensatz.',
|
||||||
|
},
|
||||||
|
match: ['FK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nas',
|
||||||
|
category: 'general',
|
||||||
|
display: 'NAS',
|
||||||
|
fullName: { en: 'Network-Attached Storage', de: 'Network-Attached Storage' },
|
||||||
|
definition: {
|
||||||
|
en: 'A dedicated file-storage device that other machines on the network read and write to.',
|
||||||
|
de: 'Ein dediziertes Speichergerät im Netzwerk, auf das andere Rechner lesend und schreibend zugreifen.',
|
||||||
|
},
|
||||||
|
match: ['NAS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'os',
|
||||||
|
category: 'general',
|
||||||
|
display: 'OS',
|
||||||
|
fullName: { en: 'Operating System', de: 'Betriebssystem' },
|
||||||
|
definition: {
|
||||||
|
en: 'The system software running on a device — firmware, a Linux distribution, etc. — netOrk tracks its version per device.',
|
||||||
|
de: 'Die auf einem Gerät laufende Systemsoftware — Firmware, eine Linux-Distribution usw. — netOrk erfasst die Version pro Gerät.',
|
||||||
|
},
|
||||||
|
match: ['OS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pdf',
|
||||||
|
category: 'general',
|
||||||
|
display: 'PDF',
|
||||||
|
fullName: { en: 'Portable Document Format', de: 'Portable Document Format' },
|
||||||
|
definition: {
|
||||||
|
en: 'A fixed-layout document format used for audit log exports meant for an auditor.',
|
||||||
|
de: 'Ein Dokumentformat mit festem Layout — genutzt für Audit-Log-Exporte, die an einen Prüfer gehen.',
|
||||||
|
},
|
||||||
|
match: ['PDF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'saas',
|
||||||
|
category: 'general',
|
||||||
|
display: 'SaaS',
|
||||||
|
fullName: { en: 'Software as a Service', de: 'Software as a Service' },
|
||||||
|
definition: {
|
||||||
|
en: 'Hosted software you access over the internet and don\'t run yourself — the opposite of netOrk\'s self-hosted model.',
|
||||||
|
de: 'Gehostete Software, die über das Internet genutzt wird, statt sie selbst zu betreiben — das Gegenteil von netOrks Self-Hosted-Modell.',
|
||||||
|
},
|
||||||
|
match: ['SaaS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sse',
|
||||||
|
category: 'general',
|
||||||
|
display: 'SSE',
|
||||||
|
fullName: { en: 'Server-Sent Events', de: 'Server-Sent Events' },
|
||||||
|
definition: {
|
||||||
|
en: 'A one-way streaming connection the server uses to push live output — like fix-stream logs — to the browser.',
|
||||||
|
de: 'Eine unidirektionale Streaming-Verbindung, über die der Server Live-Output — etwa Fix-Stream-Logs — an den Browser sendet.',
|
||||||
|
},
|
||||||
|
match: ['SSE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ui',
|
||||||
|
category: 'general',
|
||||||
|
display: 'UI',
|
||||||
|
fullName: { en: 'User Interface', de: 'User Interface' },
|
||||||
|
definition: {
|
||||||
|
en: 'The interface you interact with — in netOrk\'s case, the web app itself.',
|
||||||
|
de: 'Die Oberfläche, mit der interagiert wird — bei netOrk die Web-App selbst.',
|
||||||
|
},
|
||||||
|
match: ['UI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vm',
|
||||||
|
category: 'general',
|
||||||
|
display: 'VM',
|
||||||
|
fullName: { en: 'Virtual Machine', de: 'Virtuelle Maschine' },
|
||||||
|
definition: {
|
||||||
|
en: 'An emulated computer running on a hypervisor like Proxmox, sharing physical hardware with other VMs.',
|
||||||
|
de: 'Ein emulierter Computer auf einem Hypervisor wie Proxmox, der sich die physische Hardware mit anderen VMs teilt.',
|
||||||
|
},
|
||||||
|
match: ['VMs', 'VM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'websocket',
|
||||||
|
category: 'general',
|
||||||
|
display: 'WebSocket',
|
||||||
|
definition: {
|
||||||
|
en: 'A persistent two-way connection between browser and server — used for the interactive Web-SSH console.',
|
||||||
|
de: 'Eine dauerhafte, bidirektionale Verbindung zwischen Browser und Server — genutzt für die interaktive Web-SSH-Konsole.',
|
||||||
|
},
|
||||||
|
match: ['WebSocket'],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
|
import GlossaryMark from '../components/GlossaryMark'
|
||||||
|
import { GLOSSARY } from '../glossary/terms'
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATCH_TO_ID = new Map<string, string>()
|
||||||
|
for (const entry of GLOSSARY) {
|
||||||
|
for (const surface of entry.match) {
|
||||||
|
MATCH_TO_ID.set(surface, entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_SURFACES = [...MATCH_TO_ID.keys()].sort((a, b) => b.length - a.length)
|
||||||
|
const GLOSSARY_REGEX = new RegExp(`\\b(${ALL_SURFACES.map(escapeRegExp).join('|')})\\b`, 'g')
|
||||||
|
|
||||||
|
/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked <GlossaryMark>.
|
||||||
|
* Always returns a single inline element — never a bare array — so callers can safely place the
|
||||||
|
* result inside a `flex` container (e.g. a bulleted `<li>`) without each fragment becoming its own
|
||||||
|
* flex item and breaking the reading order. */
|
||||||
|
export function linkify(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(GLOSSARY_REGEX)
|
||||||
|
if (parts.length === 1) return text
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
const id = MATCH_TO_ID.get(part)
|
||||||
|
if (id === undefined) return <Fragment key={i}>{part}</Fragment>
|
||||||
|
return (
|
||||||
|
<GlossaryMark key={i} id={id}>
|
||||||
|
{part}
|
||||||
|
</GlossaryMark>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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])
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user