nhd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. nhd-0.1.0/PKG-INFO +536 -0
  2. nhd-0.1.0/README.md +502 -0
  3. nhd-0.1.0/nhd/__init__.py +0 -0
  4. nhd-0.1.0/nhd/app.py +1065 -0
  5. nhd-0.1.0/nhd/assets/logo.svg +135 -0
  6. nhd-0.1.0/nhd/connect.py +171 -0
  7. nhd-0.1.0/nhd/dialogs.py +957 -0
  8. nhd-0.1.0/nhd/nethuds/__init__.py +1 -0
  9. nhd-0.1.0/nhd/nethuds/arista/__init__.py +0 -0
  10. nhd-0.1.0/nhd/nethuds/arista/collector.py +305 -0
  11. nhd-0.1.0/nhd/nethuds/arista/config.example.yaml +25 -0
  12. nhd-0.1.0/nhd/nethuds/arista/parsers.py +198 -0
  13. nhd-0.1.0/nhd/nethuds/arista/server.py +511 -0
  14. nhd-0.1.0/nhd/nethuds/arista/static/index.html +1342 -0
  15. nhd-0.1.0/nhd/nethuds/bootstrap.py +68 -0
  16. nhd-0.1.0/nhd/nethuds/cisco_ios/__init__.py +0 -0
  17. nhd-0.1.0/nhd/nethuds/cisco_ios/collector.py +237 -0
  18. nhd-0.1.0/nhd/nethuds/cisco_ios/config.example.yaml +19 -0
  19. nhd-0.1.0/nhd/nethuds/cisco_ios/parsers.py +500 -0
  20. nhd-0.1.0/nhd/nethuds/cisco_ios/server.py +557 -0
  21. nhd-0.1.0/nhd/nethuds/cisco_ios/static/index.html +865 -0
  22. nhd-0.1.0/nhd/nethuds/devices.example.yaml +68 -0
  23. nhd-0.1.0/nhd/nethuds/juniper/README.md +136 -0
  24. nhd-0.1.0/nhd/nethuds/juniper/__init__.py +0 -0
  25. nhd-0.1.0/nhd/nethuds/juniper/collector.py +429 -0
  26. nhd-0.1.0/nhd/nethuds/juniper/config.example.yaml +17 -0
  27. nhd-0.1.0/nhd/nethuds/juniper/server.py +503 -0
  28. nhd-0.1.0/nhd/nethuds/juniper/static/index.html +1091 -0
  29. nhd-0.1.0/nhd/nethuds/launcher.py +528 -0
  30. nhd-0.1.0/nhd/nethuds/legacy_ssh_versions.json +51 -0
  31. nhd-0.1.0/nhd/nethuds/linux/README.md +340 -0
  32. nhd-0.1.0/nhd/nethuds/linux/__init__.py +0 -0
  33. nhd-0.1.0/nhd/nethuds/linux/collector.py +1567 -0
  34. nhd-0.1.0/nhd/nethuds/linux/config.example.yaml +14 -0
  35. nhd-0.1.0/nhd/nethuds/linux/server.py +507 -0
  36. nhd-0.1.0/nhd/nethuds/linux/static/index.html +1179 -0
  37. nhd-0.1.0/nhd/nethuds/paths.py +51 -0
  38. nhd-0.1.0/nhd/nethuds/static/launcher.html +719 -0
  39. nhd-0.1.0/nhd/server_manager.py +261 -0
  40. nhd-0.1.0/nhd/session_store.py +223 -0
  41. nhd-0.1.0/nhd/sessions.py +227 -0
  42. nhd-0.1.0/nhd/vault.py +510 -0
  43. nhd-0.1.0/nhd/vaultctl.py +203 -0
  44. nhd-0.1.0/nhd.egg-info/PKG-INFO +536 -0
  45. nhd-0.1.0/nhd.egg-info/SOURCES.txt +49 -0
  46. nhd-0.1.0/nhd.egg-info/dependency_links.txt +1 -0
  47. nhd-0.1.0/nhd.egg-info/entry_points.txt +3 -0
  48. nhd-0.1.0/nhd.egg-info/requires.txt +9 -0
  49. nhd-0.1.0/nhd.egg-info/top_level.txt +1 -0
  50. nhd-0.1.0/pyproject.toml +70 -0
  51. nhd-0.1.0/setup.cfg +4 -0
nhd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,536 @@
1
+ Metadata-Version: 2.4
2
+ Name: nhd
3
+ Version: 0.1.0
4
+ Summary: nethuds desktop — a single-HUD SSH triage cockpit for Arista, Juniper, Cisco IOS, and Linux
5
+ Author: Scott Peterman
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/scottpeterman/nhd
8
+ Project-URL: Repository, https://github.com/scottpeterman/nhd
9
+ Project-URL: Issues, https://github.com/scottpeterman/nhd/issues
10
+ Keywords: network,netops,ssh,telemetry,hud,bgp,arista,juniper,cisco,observability
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: X11 Applications :: Qt
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Networking :: Monitoring
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: PyQt6>=6.6
26
+ Requires-Dist: PyQt6-WebEngine>=6.6
27
+ Requires-Dist: fastapi>=0.110
28
+ Requires-Dist: uvicorn[standard]>=0.29
29
+ Requires-Dist: httpx>=0.27
30
+ Requires-Dist: paramiko>=3.4
31
+ Requires-Dist: netmiko>=4.3
32
+ Requires-Dist: cryptography>=42.0
33
+ Requires-Dist: PyYAML>=6.0
34
+
35
+ # nethuds desktop (nhd) — proof of concept
36
+
37
+ <p align="center">
38
+ <img src="screenshots/slides.gif"
39
+ alt="nethuds desktop cycling through the Arista, Juniper, Cisco IOS and Linux HUDs"
40
+ width="100%">
41
+ </p>
42
+
43
+ A triage cockpit for network devices. Log into a box you've never seen — with
44
+ nothing but an IP and credentials — and `nhd` renders that device's live health
45
+ as a single HUD: routing engines, BGP, thermals, optics, interfaces, OSPF
46
+ adjacencies, the event log, all read at a glance and the *same method - SSH* across
47
+ Arista, Juniper, Cisco IOS, and Linux.
48
+
49
+ The point is that it assumes **no prior instrumentation**. The device doesn't
50
+ have to be in an inventory, streaming telemetry, or wired into a dashboard
51
+ someone built ahead of time — the only prerequisite is SSH access. That's the
52
+ one moment every poll-and-store tool has already missed: the cold open on an
53
+ unfamiliar box, mid-incident, when you have no baseline in your head and a
54
+ terminal would make you interrogate the device one `show` command at a time. The
55
+ HUD orients you instead — and because each reading carries its own reference
56
+ frame (35.4°C against a 77.8 ceiling, 51 peers / 0 down), a value means
57
+ something even on a box you've never met.
58
+
59
+ Around that core it adds the chrome a server-only HUD lacks: an **editable,
60
+ filterable** session tree, an encrypted credential vault with a full management
61
+ UI, key-based SSH auth, zoomable tabbed multi-device views, a dark UI, and window
62
+ state that persists across restarts — all without rewriting the
63
+ [nethuds](https://pypi.org/project/nethuds/) HUD pages.
64
+
65
+ <table>
66
+ <tr>
67
+ <td width="50%"><img src="screenshots/arista.png" alt="Arista EOS HUD" width="100%"></td>
68
+ <td width="50%"><img src="screenshots/juniper.png" alt="Juniper Junos HUD" width="100%"></td>
69
+ </tr>
70
+ <tr>
71
+ <td align="center"><em>Arista EOS — compute, thermal matrix, power &amp; cooling, transceiver inventory</em></td>
72
+ <td align="center"><em>Juniper Junos — routing engines, FPC sensors, optics diagnostics, system alarms</em></td>
73
+ </tr>
74
+ </table>
75
+
76
+ Different gear, the *same reading frame*: every value carries its own reference
77
+ (35.4 °C against a 77.8 ceiling, 51 peers / 0 down), so a number means something
78
+ on a box you've never met.
79
+
80
+ ## What this is
81
+
82
+ nethuds ships each vendor dashboard as its own FastAPI server that serves a
83
+ self-contained HTML/JS HUD and proxies a live SSH session. That is excellent
84
+ for one device in a browser tab, but it is server-only: there is no session
85
+ manager, no credential store, and no desktop chrome.
86
+
87
+ `nhd` wraps those servers in a PyQt6 application. It loads a session file
88
+ (terminal-telemetry format, or a nethuds `devices.yaml`), shows the devices in
89
+ a grouped tree you can edit in place, and opens each one in its own tab backed
90
+ by a `QWebEngineView`. Authentication is resolved from an encrypted vault (or
91
+ prompted for at connect time) and the wrapper establishes the SSH session
92
+ itself, so a private key never has to ride a URL.
93
+
94
+ It is still a proof of concept — the roadmap below lists what is deliberately
95
+ unfinished — but the three things that made the original POC a "file viewer"
96
+ rather than a tool are now done: **CRUD session editing with persistence, an
97
+ encrypted credential vault, and key-capable auth wired into the login path.**
98
+
99
+ <p align="center">
100
+ <img src="screenshots/cisco.png"
101
+ alt="Cisco IOS HUD — ENG-LEAF-1 with LLDP topology, spanning tree, MAC and routing tables"
102
+ width="100%">
103
+ <br>
104
+ <em>A Cisco IOS leaf on a cold open — LLDP topology, spanning tree, MAC and
105
+ routing tables, interfaces and the live event log, all from one SSH session.</em>
106
+ </p>
107
+
108
+ ## How it works
109
+
110
+ Three observations make the HUD-hosting an "easy path" rather than a rewrite:
111
+
112
+ **The servers run in-process — mostly.** Rather than spawning subprocesses for
113
+ everything (what nethuds' own CLI launcher does), the wrapper imports each
114
+ vendor's FastAPI `app` and runs it on a background thread via uvicorn, bound to
115
+ `127.0.0.1` only. That gives direct control of the bound port, keeps no listener
116
+ exposed off-box on a laptop that holds SSH credentials, and ties every server's
117
+ lifecycle to the window. Servers start lazily — a vendor only spins up when its
118
+ first tab opens. The one exception is Linux, which runs as a **subprocess per
119
+ tab** (see Process model below); the session vendors share one thread server
120
+ each.
121
+
122
+ **The HUD pages are origin-relative.** Each page derives its WebSocket target
123
+ from `location.host`, so it reconnects to whatever port the wrapper bound. The
124
+ chosen port never has to be threaded into the JavaScript — serving the page from
125
+ that port is enough. If the preferred vendor port is taken, the wrapper walks
126
+ forward to the next free one.
127
+
128
+ <p align="center">
129
+ <img src="screenshots/linux.png"
130
+ alt="Linux HUD with an embedded terminal running btop over the same SSH session"
131
+ width="100%">
132
+ <br>
133
+ <em>The wrapper-established session, attached: the Linux HUD with a live
134
+ terminal (here <code>btop</code>) running over the very SSH connection the HUD reads.</em>
135
+ </p>
136
+
137
+ **Connection: the wrapper establishes, the page attaches.** This is the part
138
+ that changed from the original POC. Each HUD's `/api/connect` accepts a key's
139
+ *contents* (`key_text`) but the login modal can only forward a key from a file
140
+ `<input>`, never a path or stored secret — so a key cannot travel through the
141
+ page. Instead, for any session with a resolved identity (vault credential,
142
+ inline key, or a connect-time prompt), the wrapper POSTs `/api/connect` itself
143
+ over loopback with the key/password and `legacy_ssh`, gets back a `session_id`,
144
+ and loads the page pointed at that session so it **attaches** rather than
145
+ reconnecting. The decrypted secret lives only in that loopback POST body — never
146
+ in the URL, the page's JavaScript, or sessionStorage.
147
+
148
+ * **Session vendors (Arista/Juniper/Cisco)** return a `session_id`; the page
149
+ is loaded as `/?session=<id>&name=…` and its resume path
150
+ (`/api/status?session=…`) attaches. This needed a ~5-line addition to each
151
+ of the three pages so they read `?session=` from the URL into `_sessionId`
152
+ (see Front-end patch below).
153
+ * **Linux** is single-target, returns no id, and needs no page change: the
154
+ wrapper establishes its one device and loads the page bare (`/?name=…`),
155
+ whose existing no-params branch attaches telemetry to the live collector.
156
+
157
+ The blocking SSH handshake runs off the UI thread in a `ConnectWorker`, and a
158
+ bad login surfaces as the tab's error splash because the wrapper sees the
159
+ server's `status: error` response (see Authentication below).
160
+
161
+ > The **vendor-default** path still uses the page's original
162
+ > `?host=…&autoconnect=true` mechanism, where the page authenticates from the
163
+ > server's own vendor yaml. It is now an explicit, opt-in mode rather than a
164
+ > silent fallback, because it is the one path the wrapper can't validate (it
165
+ > doesn't hold the yaml's secret).
166
+
167
+ ### Process model: shared vs dedicated
168
+
169
+ <p align="center">
170
+ <img src="screenshots/ubuntu.png"
171
+ alt="The full Linux HUD cockpit — systemd services, Docker, interfaces, routing and thermals"
172
+ width="100%">
173
+ <br>
174
+ <em>The full Linux cockpit — systemd services, Docker, network interfaces,
175
+ routing and thermals from one SSH login. Each Linux tab runs as its own
176
+ subprocess (below).</em>
177
+ </p>
178
+
179
+ `ServerManager` runs two kinds of server:
180
+
181
+ - **Shared (Arista, Juniper, Cisco)** — one in-process thread server per vendor,
182
+ reused by every tab. These servers isolate devices by `session_id`, so many
183
+ tabs to many devices coexist on one server. Kept warm until the app exits.
184
+ - **Dedicated (Linux)** — a fresh **subprocess** per tab. The Linux server is
185
+ single-target: `CONFIG["device"]`, the collector, and the poll task are
186
+ module-level globals, so two tabs in one process would collide. A separate OS
187
+ process gives each Linux tab its own globals and loop. `release()` terminates
188
+ the subprocess when its tab closes; `stop_all()` (also wired to `atexit`)
189
+ reaps any survivors.
190
+
191
+ A Linux tab therefore costs one Python process (~60–80 MB) and a ~1–2 s cold
192
+ start while the child imports and binds.
193
+
194
+ ## Authentication & the credential vault
195
+
196
+ Authentication is no longer a single plaintext identity per vendor yaml. Each
197
+ session declares **how** it authenticates via its `credential` field, resolved
198
+ on the UI thread before any server is touched:
199
+
200
+ | `credential` value | behaviour |
201
+ |--------------------|-----------|
202
+ | a vault name | use that stored credential (vault must be unlocked) |
203
+ | `@prompt` | always ask for username + password/key at connect; nothing stored |
204
+ | `""` (none) | a matching/default vault credential if the vault is unlocked, else an inline secret, else prompt |
205
+ | `@vendor` | the HUD server authenticates from its own vendor yaml (the only in-page auth path) |
206
+
207
+ The important property: a session with nothing to authenticate with **prompts**
208
+ rather than silently failing. Connect-time prompts and stored credentials both
209
+ go through the wrapper-establishes path, so a wrong password/key shows as the
210
+ tab's red error splash instead of a dead HUD.
211
+
212
+ ### The vault (`nhd/vault.py`)
213
+
214
+ A direct descendant of the [nterm-qt](https://github.com/scottpeterman/nterm-qt)
215
+ vault: SQLite for storage, Fernet for encryption, a master password stretched
216
+ with PBKDF2-HMAC-SHA256 (480k iterations) and a verify-token unlock check.
217
+ Credentials store a key's *contents* (not a path), which is exactly what
218
+ `/api/connect` consumes. `cryptography` is already in the dependency tree
219
+ (paramiko depends on it), so the vault adds no new dependency. The vault lives
220
+ at `~/.nhd/vault.db`.
221
+
222
+ `CredentialResolver` matches a device to a credential — a pinned name wins
223
+ outright; otherwise the most-specific host glob beats a tag match beats the
224
+ `is_default` catch-all — and emits a `ResolvedIdentity` carrying just the
225
+ `/api/connect` identity subset (username + password XOR key_text, `use_keys`,
226
+ `legacy_ssh`).
227
+
228
+ `legacy_ssh` is deliberately **not** stored on the credential — it is a property
229
+ of the device (does this box speak only old KEX/host-key algorithms), so it
230
+ lives on the session and is supplied at resolve time. The IOSv-era gear needs it.
231
+
232
+ ### Managing credentials — the vault manager
233
+
234
+ **Vault → Manage credentials…** opens the manager dialog that retires the CLI
235
+ for day-to-day use. It lists every credential (name, username, auth type, host
236
+ globs, tags, default) and offers add / edit / delete / set-default — plus the
237
+ two edits `vaultctl` has no path for: **rename** and **username change** in
238
+ place. **Change master…** re-keys the vault, re-encrypting every secret under a
239
+ new master password (the GUI equivalent of `rekey`).
240
+
241
+ The manager is honest about the vault's lock state, because the store is. The
242
+ metadata listing, delete, and set-default all work while the vault is *locked*
243
+ — they touch no ciphertext — so the dialog opens and stays useful before an
244
+ unlock, with an inline banner offering to unlock. Add and edit decrypt and
245
+ re-encrypt secrets, so they stay disabled until the vault is unlocked, behind
246
+ the same gate the connect path uses. Editing a credential pre-fills its
247
+ decrypted secrets (only reachable unlocked), so what you see is what gets
248
+ stored: clear the password or key field and that secret is removed. A credential
249
+ must keep at least one secret — the invariant `vaultctl add` already enforces —
250
+ and a passphrase-looking key draws the same non-blocking warning, since the HUD
251
+ server can't forward a passphrase to paramiko. The editor reads a key's
252
+ *contents* from a file (never a path), matching what the vault stores and what
253
+ `/api/connect` consumes.
254
+
255
+ ### Managing credentials from the CLI — `nhd.vaultctl`
256
+
257
+ The scriptable front door to the vault, for headless setup and automation. The
258
+ manager above covers the same ground interactively (and adds rename / username
259
+ change), so the CLI is no longer the only way in:
260
+
261
+ ```bash
262
+ python -m nhd.vaultctl init
263
+ python -m nhd.vaultctl add lab --user admin \
264
+ --key-file ~/.ssh/lab_ed25519 --hosts '172.16.*,10.0.0.*' --default
265
+ python -m nhd.vaultctl add netops --user oxidize --password --hosts '10.7.*'
266
+ python -m nhd.vaultctl list # NAME is the first column
267
+ python -m nhd.vaultctl show lab
268
+ python -m nhd.vaultctl set-default lab
269
+ python -m nhd.vaultctl remove old-cred
270
+ python -m nhd.vaultctl rekey # change master password
271
+ ```
272
+
273
+ `remove`/`show`/`set-default` take the credential **NAME** (the first column of
274
+ `list`), not its username. `--hosts`/`--tags` only matter for automatic
275
+ resolution; a session that pins a credential by name skips scoring. The master
276
+ password is prompted interactively, or read from `$NHD_VAULT_PASSWORD` for
277
+ scripting; the db path can be overridden with `--db` or `$NHD_VAULT_DB`.
278
+
279
+ ## Session management
280
+
281
+ The session tree is editable. Right-click for a context menu:
282
+
283
+ - on a **session**: Connect · Edit… · Duplicate · Delete · New session… · New folder…
284
+ - on a **folder**: New session in folder… · Rename folder… · Delete folder · New folder…
285
+ - on **empty space**: New session… · New folder…
286
+
287
+ **Folders.** A folder is just a session's `group`, so historically a folder
288
+ could only exist once something lived in it. **New folder…** creates an empty
289
+ one up front — name it, then drop sessions in. An empty folder persists across a
290
+ save/reload as `{folder_name: …, sessions: []}`; it stops being "empty" the
291
+ moment a session joins it and becomes an ordinary derived group again. (Deleting
292
+ a folder's last session still removes the folder; only folders you create
293
+ explicitly stick around while empty.)
294
+
295
+ Double-click still connects. The editor covers name, host, device type
296
+ (dropdown → routes to the right HUD), group (editable combo), credential
297
+ (dropdown of vault names + the modes above), username, tags, port, and the
298
+ legacy-SSH checkbox.
299
+
300
+ **Filtering.** A filter box sits above the tree. Typing narrows it to matching
301
+ sessions, and the match runs against the whole session — name, host, vendor,
302
+ device type, group, username, credential, and tags — not just the visible
303
+ label, so `arista`, `10.7`, a tag, a credential name, or `@prompt` all work.
304
+ Whitespace-separated terms are ANDed (`spine prod` keeps only sessions matching
305
+ both) and matching is case-insensitive. Groups with no surviving session are
306
+ hidden and groups with a hit auto-expand, so results surface without clicking;
307
+ an empty box restores everything and a filter that matches nothing tints the
308
+ box. The filter re-applies itself after an edit rebuilds the tree, so it
309
+ survives a rename or a new session. **Ctrl+F** focuses the box, **Esc** clears
310
+ it.
311
+
312
+ Edits persist back to the loaded file automatically (Ctrl+S, or File → Save
313
+ sessions as… to choose a location), in the same termtels format the loaders
314
+ read, so a saved file reloads identically. **Secrets never round-trip** — the
315
+ file stores a credential *name*, never a password. The title bar shows a `•`
316
+ when there are unsaved changes. A `devices.yaml` is treated as an import (not
317
+ the native save format), so it won't be silently rewritten in a different shape.
318
+
319
+ **File → New session file** (Ctrl/Cmd+N) starts an empty, unsaved set — build it
320
+ up with New folder… / New session…, and the first save picks a location, after
321
+ which edits auto-persist like any loaded file. Anything that would discard
322
+ pending edits — New file, opening another file, or quitting — first offers to
323
+ **save / discard / cancel**, so an unsaved set (or an edited `devices.yaml`
324
+ import, which has no auto-save target) can't vanish silently on exit.
325
+
326
+ Editing a session mutates the object in place, so an open tab keeps pointing at
327
+ the right session — but a live connection isn't retroactively changed; close and
328
+ reconnect to pick up a new host or credential.
329
+
330
+ **Tabs.** Each device opens in its own closable, movable tab. Right-clicking the
331
+ tab bar offers **Close**, **Close Others**, **Close to the Right**, and **Close
332
+ All** (right-clicking empty bar space offers just Close All). The bulk closers
333
+ run the same teardown as a single close, so a Linux tab's dedicated subprocess
334
+ is still reaped when it goes.
335
+
336
+ **Window state.** Window geometry, the tree/tab splitter position, the HUD zoom
337
+ level, the application scale factor, and the path of the last-loaded session
338
+ file are remembered between runs via `QSettings` (the platform-native backend —
339
+ registry / plist / `.conf`). Launch with no `--session-file`/`--devices` and the
340
+ file open at last exit is reopened — a `devices.yaml` is re-imported (never
341
+ silently rewritten), and a CLI path always wins over the remembered one. Nothing
342
+ stored here is a secret — geometry, a zoom factor, a file *path* — so the vault
343
+ stays the only store of credentials.
344
+
345
+ **High-DPI & scaling.** Two independent knobs, both under **View**:
346
+
347
+ * **Zoom** (Ctrl/Cmd +/-/0) is the HUD *page* zoom — Chromium-level, applied
348
+ live to every tab and re-applied across the connect navigation. This is the
349
+ one to reach for moment to moment.
350
+ * **Scale Factor** is the *application* scale (Qt's `QT_SCALE_FACTOR`): Auto
351
+ (detect from the display) or a forced preset. Qt fixes the UI scale when the
352
+ process starts, so a change here is saved to `QSettings` and applies on the
353
+ **next launch**, not live — the menu says as much when you pick one. It's the
354
+ fix for a display whose auto-detected scaling is wrong, and it scales the
355
+ chrome *and* the web views together. `--scale FACTOR` does the same from the
356
+ CLI (and persists it); `--scale 0` clears the override back to Auto.
357
+
358
+ Qt6 enables high-DPI scaling by default (the Qt5 `AA_EnableHighDpiScaling` /
359
+ `AA_UseHighDpiPixmaps` attributes are no-ops now), and the wrapper sets the
360
+ scale-factor rounding policy to `PassThrough` so fractional display scales
361
+ (125 % / 150 % / 175 % on Windows & Linux 4K panels) stay crisp instead of
362
+ rounding to the nearest integer. On macOS Retina (integer 2×) none of this is
363
+ needed; it's there for the mixed-DPI desktops the tool also runs on.
364
+
365
+ ## Layout
366
+ ![img.png](img.png)
367
+ ```
368
+ nhd/
369
+ ├── app.py # PyQt6 main window, editable tree, tabs, connect flow,
370
+ │ # window-state persistence, CLI
371
+ ├── server_manager.py # in-process vendor servers + dynamic port binding
372
+ ├── sessions.py # session-file parsing -> DeviceSession objects
373
+ ├── session_store.py # load/save persistence + CRUD over the session list
374
+ ├── connect.py # establish_session() + ConnectWorker (wrapper-side connect)
375
+ ├── vault.py # encrypted credential store + resolver
376
+ ├── vaultctl.py # CLI for vault management
377
+ ├── dialogs.py # vault unlock, session editor, connect-time auth prompt,
378
+ │ # the credential manager (CRUD + rekey), and About
379
+ ├── __init__.py
380
+ ├── assets/ # logo.svg, shown in the About dialog
381
+ └── nethuds/ # vendored nethuds package (the four vendor HUD servers)
382
+ ```
383
+
384
+ ## Front-end patch
385
+
386
+ The three session-vendor pages (`nethuds/<vendor>/static/index.html` for
387
+ arista, juniper, cisco_ios) carry one small addition in the login modal's
388
+ `init()`: read `?session=` from the URL into `_sessionId` so the page attaches
389
+ to a wrapper-established session. The Linux page is unchanged. The Linux
390
+ **server** got one change — `/api/connect` now runs `test_connect()` before it
391
+ returns (like the session vendors already did), so a bad login is reported to
392
+ the caller instead of failing silently inside the poll loop. Since you own
393
+ nethuds, both are clean upstream changes to make there and re-vendor.
394
+
395
+ ## Install & run
396
+
397
+ Requires Python 3.10+. From the project root (the parent of `nhd/`):
398
+
399
+ ```bash
400
+ python -m venv .venv && source .venv/bin/activate
401
+ pip install -r requirements.txt # PyQt6 + the vendor HUD servers' deps
402
+ python -m nhd.app --session-file sessions/sessions.yaml
403
+ ```
404
+
405
+ `requirements.txt` lists only the direct dependencies (PyQt6, PyQt6-WebEngine,
406
+ fastapi, uvicorn[standard], httpx, paramiko, netmiko, cryptography, PyYAML); the
407
+ rest resolve transitively. For a byte-for-byte reproducible environment, pin
408
+ against the bundled lockfile: `pip install -r requirements.txt -c constraints.txt`.
409
+
410
+ Alternative inputs:
411
+
412
+ ```bash
413
+ python -m nhd.app --devices path/to/devices.yaml # a nethuds devices.yaml
414
+ python -m nhd.app # reopen last session (empty on first run)
415
+ python -m nhd.app --scale 1.5 # force a UI scale (persists; see View → Scale Factor)
416
+ ```
417
+
418
+ Open a device by double-clicking it, or right-click → Connect.
419
+
420
+ ## Session file format
421
+
422
+ `--session-file` parses a terminal-telemetry YAML. The loader is permissive: it
423
+ walks folder-nested or flat structures and accepts a range of field-name
424
+ aliases. Secrets are not stored here — `credential` references a vault entry.
425
+
426
+ ```yaml
427
+ sessions:
428
+ - folder_name: Eng Lab
429
+ sessions:
430
+ - display_name: eng-spine1
431
+ host: 172.16.11.2
432
+ device_type: arista_eos # or Vendor: arista
433
+ credential: lab # vault credential name (or @prompt / @vendor)
434
+ tags: [lab, spine]
435
+ - display_name: eng-rtr-1
436
+ host: 172.16.2.1
437
+ device_type: cisco_ios
438
+ credential: lab
439
+ legacy_ssh: true # IOSv only offers legacy KEX/host-key algos
440
+ - folder_name: Hosts
441
+ sessions:
442
+ - display_name: t1k
443
+ host: 10.0.0.108
444
+ device_type: linux
445
+ credential: lab
446
+ ```
447
+
448
+ Recognised aliases (first match wins):
449
+
450
+ | field | accepted keys |
451
+ |------------|---------------|
452
+ | host | `host`, `hostname`, `ip`, `address` |
453
+ | name | `display_name`, `name`, `label` |
454
+ | username | `username`, `user` |
455
+ | port | `port`, `ssh_port` |
456
+ | vendor | `device_type`, `Vendor`, `Model`, `platform`, `os` |
457
+ | legacy | `legacy_ssh`, `legacy` |
458
+ | key file | `key_file`, `keyfile`, `identity_file` |
459
+ | credential | `credential`, `cred`, `credential_name` |
460
+ | tags | `tags`, `labels` |
461
+ | group | `folder_name`, `group`, `folder` |
462
+
463
+ The vendor hint is mapped to a netmiko `device_type`, which selects the HUD
464
+ server (`arista_eos` → Arista, `juniper_junos` → Juniper, `cisco_*` → Cisco,
465
+ anything else → Linux). All alias tables live in `sessions.py`.
466
+
467
+ ## Ports
468
+
469
+ Each vendor server binds its preferred port on `127.0.0.1`, walking forward if
470
+ it's in use:
471
+
472
+ | vendor | preferred port |
473
+ |---------|----------------|
474
+ | Arista | 8470 |
475
+ | Juniper | 8471 |
476
+ | Cisco | 8472 |
477
+ | Linux | 8478 |
478
+
479
+ ## Remote debugging
480
+
481
+ ```bash
482
+ python -m nhd.app --remote-debug 9921 --session-file sessions/sessions.yaml
483
+ ```
484
+
485
+ Point a Chromium-based browser at `http://127.0.0.1:9921`. The Network tab
486
+ (filter to WS) shows the telemetry and terminal sockets and the `/api/connect`
487
+ request/response. The flag sets `QTWEBENGINE_REMOTE_DEBUGGING` before Qt
488
+ initialises; setting it after has no effect.
489
+
490
+ ## Known limitations / roadmap
491
+
492
+ **Done since the first POC.** Credential management (encrypted vault + resolver),
493
+ key-capable auth wired into the login path, the editable session tree with
494
+ persistence, and uniform connect-failure reporting across all four vendors. Then
495
+ the **Qt credential manager** — `Vault → Manage credentials…`, full CRUD plus
496
+ rename and username change in place and a master-password rekey, lock-state
497
+ aware so it sits next to the existing unlock/lock — and a **session-tree filter**
498
+ that narrows the tree across every session field with ANDed, case-insensitive
499
+ terms and survives edits, and **HUD zoom** (`View → Zoom`, Ctrl/Cmd
500
+ +/-/0) — a global zoom level applied to every tab and re-applied across the
501
+ connect navigation — so the HUD scales on macOS, where the page's own
502
+ Ctrl+scroll never fired. Most recently, **persisted UI state** (`QSettings`:
503
+ window geometry, the splitter position, the zoom level, and the last-loaded
504
+ session file, reopened on launch when no file is given on the CLI), a **tab-bar
505
+ context menu** for closing the current / other / right-hand / all tabs, and a
506
+ **Help → About** dialog showing the logo, a short description, and the project
507
+ link. And most recently still: **first-class folders** — `New folder…` creates
508
+ an empty folder that persists through save/reload (`{folder_name, sessions: []}`)
509
+ until a session joins it — **`File → New session file`** for starting a fresh set
510
+ from scratch, an **unsaved-changes guard** (save / discard / cancel) on new-file,
511
+ open, and quit so an in-memory or imported set can't be lost on exit, and a
512
+ **`View → Scale Factor`** application-scaling control (Auto + presets, persisted
513
+ in `QSettings`, applied at next launch) alongside a `PassThrough` rounding policy
514
+ for crisp fractional high-DPI on Windows/Linux.
515
+
516
+ **Passphrase-protected keys don't work.** The HUD server writes `key_text` to a
517
+ temp file and never passes a passphrase to paramiko, so an encrypted key fails.
518
+ `connect.py` warns when it resolves one, and the credential manager warns when
519
+ you paste or load one. Use a passphrase-less key for the vault, or hold the key
520
+ in an agent and use `@vendor` for those boxes.
521
+
522
+ **Server-side session GC.** Closing a tab on a shared vendor leaves its
523
+ `/api/connect` session alive on the server until app exit. Dedicated Linux
524
+ subprocesses are reaped on tab close; the shared vendors aren't.
525
+
526
+ **Richer connect errors.** A failed login surfaces, but the paramiko message can
527
+ be terse ("Authentication failed."). Mapping auth-failure vs unreachable-host vs
528
+ host-key-mismatch to clearer text is a small enhancement in `connect.py`.
529
+
530
+ **Clean shutdown.** `stop_all()` runs on window close and `atexit`, so a terminal
531
+ `Ctrl-C` no longer orphans Linux subprocesses. A hard `SIGKILL` still bypasses
532
+ cleanup; a process-group / SIGTERM handler would close that last gap.
533
+
534
+ ## License
535
+
536
+ Inherits the license of the bundled nethuds package (GPL).