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.
- nhd-0.1.0/PKG-INFO +536 -0
- nhd-0.1.0/README.md +502 -0
- nhd-0.1.0/nhd/__init__.py +0 -0
- nhd-0.1.0/nhd/app.py +1065 -0
- nhd-0.1.0/nhd/assets/logo.svg +135 -0
- nhd-0.1.0/nhd/connect.py +171 -0
- nhd-0.1.0/nhd/dialogs.py +957 -0
- nhd-0.1.0/nhd/nethuds/__init__.py +1 -0
- nhd-0.1.0/nhd/nethuds/arista/__init__.py +0 -0
- nhd-0.1.0/nhd/nethuds/arista/collector.py +305 -0
- nhd-0.1.0/nhd/nethuds/arista/config.example.yaml +25 -0
- nhd-0.1.0/nhd/nethuds/arista/parsers.py +198 -0
- nhd-0.1.0/nhd/nethuds/arista/server.py +511 -0
- nhd-0.1.0/nhd/nethuds/arista/static/index.html +1342 -0
- nhd-0.1.0/nhd/nethuds/bootstrap.py +68 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/__init__.py +0 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/collector.py +237 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/config.example.yaml +19 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/parsers.py +500 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/server.py +557 -0
- nhd-0.1.0/nhd/nethuds/cisco_ios/static/index.html +865 -0
- nhd-0.1.0/nhd/nethuds/devices.example.yaml +68 -0
- nhd-0.1.0/nhd/nethuds/juniper/README.md +136 -0
- nhd-0.1.0/nhd/nethuds/juniper/__init__.py +0 -0
- nhd-0.1.0/nhd/nethuds/juniper/collector.py +429 -0
- nhd-0.1.0/nhd/nethuds/juniper/config.example.yaml +17 -0
- nhd-0.1.0/nhd/nethuds/juniper/server.py +503 -0
- nhd-0.1.0/nhd/nethuds/juniper/static/index.html +1091 -0
- nhd-0.1.0/nhd/nethuds/launcher.py +528 -0
- nhd-0.1.0/nhd/nethuds/legacy_ssh_versions.json +51 -0
- nhd-0.1.0/nhd/nethuds/linux/README.md +340 -0
- nhd-0.1.0/nhd/nethuds/linux/__init__.py +0 -0
- nhd-0.1.0/nhd/nethuds/linux/collector.py +1567 -0
- nhd-0.1.0/nhd/nethuds/linux/config.example.yaml +14 -0
- nhd-0.1.0/nhd/nethuds/linux/server.py +507 -0
- nhd-0.1.0/nhd/nethuds/linux/static/index.html +1179 -0
- nhd-0.1.0/nhd/nethuds/paths.py +51 -0
- nhd-0.1.0/nhd/nethuds/static/launcher.html +719 -0
- nhd-0.1.0/nhd/server_manager.py +261 -0
- nhd-0.1.0/nhd/session_store.py +223 -0
- nhd-0.1.0/nhd/sessions.py +227 -0
- nhd-0.1.0/nhd/vault.py +510 -0
- nhd-0.1.0/nhd/vaultctl.py +203 -0
- nhd-0.1.0/nhd.egg-info/PKG-INFO +536 -0
- nhd-0.1.0/nhd.egg-info/SOURCES.txt +49 -0
- nhd-0.1.0/nhd.egg-info/dependency_links.txt +1 -0
- nhd-0.1.0/nhd.egg-info/entry_points.txt +3 -0
- nhd-0.1.0/nhd.egg-info/requires.txt +9 -0
- nhd-0.1.0/nhd.egg-info/top_level.txt +1 -0
- nhd-0.1.0/pyproject.toml +70 -0
- 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 & 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
|
+

|
|
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).
|