Newer Older
659 lines | 14.468kb
Bogdan Timofte authored 2 weeks ago
1
# SSH Infrastructure - Single Source of Truth
Bogdan Timofte authored 2 weeks ago
2

            
Bogdan Timofte authored 2 weeks ago
3
Last updated: 2026-05-21
Bogdan Timofte authored 2 weeks ago
4

            
Bogdan Timofte authored 2 weeks ago
5
This is the only project documentation file. Keep architecture, key handling,
6
sync/deploy steps, troubleshooting, and maintenance notes here. Do not add
7
separate Markdown documents for the same subject unless this README is split by
8
explicit decision.
Bogdan Timofte authored 2 weeks ago
9

            
Bogdan Timofte authored 2 weeks ago
10
## Read This First
11

            
12
This repository manages SSH access from Bogdan's macOS workstation to Next-Gen
13
company hosts through:
14

            
15
```text
16
local macOS
17
  -> is-jumper 192.168.2.100
18
  -> J1/J2 10.253.51.50/52:25904
19
  -> final hosts: porta, pbx, radius, voip, network gear
20
```
21

            
22
The key detail agents keep missing:
23

            
24
- The local machine does not hold the company hardware key.
25
- The physical RSA smartcard is mounted only on `is-jumper`.
26
- The wrapper logs into `is-jumper`, sets
27
  `SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh`, then runs SSH from there to
28
  J1/J2.
29
- J1/J2 must use user `bogdan.timofte`.
30
- `is-jumper` itself must use local key `~/.ssh/keys/is-jumper_ed25519`.
31
- `ssh` on macOS must resolve to `~/.local/bin/ssh`, not `/usr/bin/ssh`, for
32
  company aliases.
33

            
34
Fast health check:
35

            
36
```bash
37
which ssh
38
ssh -G is-jumper | grep -E '^(hostname|user|identityfile|identitiesonly) '
39
ssh -G j1 | grep -E '^(hostname|user|port) '
40
ssh is-jumper hostname
41
ssh porta-sip hostname
42
```
43

            
44
Expected highlights:
45

            
46
```text
47
/Users/bogdan/.local/bin/ssh
48
identityfile ~/.ssh/keys/is-jumper_ed25519
49
user bogdan.timofte
50
p12.voip.ro
51
```
52

            
53
## Repository Rules
54

            
55
Project source:
56

            
57
```text
58
/Users/bogdan/Documents/Workspaces/Bogdan/ssh-infrastructure
59
```
60

            
61
Runtime OpenSSH state:
62

            
63
```text
64
~/.ssh/config
65
~/.ssh/known_hosts
66
~/.ssh/authorized_keys
67
~/.ssh/keys/
68
~/.local/bin/ssh
69
~/.local/bin/scp
70
~/.local/bin/sftp
71
```
72

            
73
Only edit source files in the repository. Do not edit generated runtime files by
74
hand.
75

            
76
Tracked source files:
77

            
78
```text
79
README.md                         this file, the only documentation
80
inventory/hosts.yaml              upstream/company host inventory
81
inventory/hosts-local.yaml        local overlay and local lab inventory
82
schema/hosts.schema.json          inventory schema
83
scripts/ssh-wrapper.sh            installed as ~/.local/bin/ssh
84
scripts/scp-wrapper.sh            installed as ~/.local/bin/scp
85
scripts/sftp-wrapper.sh           installed as ~/.local/bin/sftp
86
tools/generate-configs.py         config generator
87
tools/deploy-local.sh             local deploy
88
tools/sync-hosts-from-upstream.sh upstream inventory sync
89
tools/migrate-modern-key.sh       legacy local key migration helper
90
.gitignore
91
```
92

            
93
Ignored or runtime-only files:
94

            
95
```text
96
generated/
97
SSH_SETUP_SUMMARY.md
98
authorized_keys
99
known_hosts
100
known_hosts.old
101
keys/
102
agent/
103
conf.d/
104
import/
105
*.pem *.key *.ppk *.der *.csr
106
```
107

            
108
Git basics:
109

            
110
```bash
111
git status
112
git add README.md inventory schema scripts tools .gitignore
113
git commit -m "Describe change"
114
```
115

            
116
Known remotes:
117

            
118
```text
119
nextgen  ssh://git@192.168.2.103/home/git/repositories/bogdan/NextGen-Host-List.git
120
mazeri   ssh://git@192.168.2.102/home/git/repositories/bogdan/SSH-Infrastructure.git
121
```
122

            
123
## Architecture
124

            
125
### Network
126

            
127
```text
128
192.168.2.0/24 - local office/lab network
129
  is-jumper 192.168.2.100 - VPN client and hardware-key guardian
130
  local lab hosts
131

            
132
10.253.51.0/24 - internal company network reached from is-jumper VPN
133
  J1 10.253.51.50:25904
134
  J2 10.253.51.52:25904
135
  final hosts
136
```
137

            
138
`is-jumper` is not a VPN server. It is a local host that has VPN reachability to
139
the company network and has the physical smartcard mounted.
140

            
141
### Access Chains
142

            
143
Standard final-host chain:
144

            
145
```text
146
local wrapper
147
  -> /usr/bin/ssh is-jumper
148
  -> SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh ssh -A J1
149
  -> ssh final-host
150
```
151

            
152
Interactive J1/J2 login:
153

            
154
```text
155
local wrapper -> is-jumper -> J1/J2
156
```
157

            
158
Emergency public routes:
159

            
160
```text
161
local wrapper -> is-jumper -> j1.next-gen.ro or j2.next-gen.ro
162
```
163

            
164
The wrapper strips custom flags before calling real SSH:
165

            
166
```text
167
-J1  use J1 VPN route, default
168
-J2  use J2 VPN route
169
-j1  use public j1 route
170
-j2  use public j2 route
171
```
172

            
173
Do not reintroduce local port forwarding, Python relays, `IdentityAgent
174
/tmp/...`, or helper scripts that bridge the physical-card socket to the local
175
machine. Those were removed for compliance and SentinelOne noise.
176

            
177
## Keys
178

            
179
### Key Matrix
180

            
181
| Key | Location | Purpose |
182
| --- | --- | --- |
183
| Physical smartcard RSA 4096 | only on `is-jumper` | Auth from `is-jumper` to J1/J2/company network |
184
| `is-jumper_ed25519` | local `~/.ssh/keys/is-jumper_ed25519` | Auth from macOS to `is-jumper` |
185
| Modern ED25519 | local `~/.ssh/id_ed25519` or `~/.ssh/keys/id_ed25519` | Local lab and migrated hosts |
186
| Legacy RSA | local `~/.ssh/keys/id_rsa_old` | Temporary migration fallback for old local hosts |
187

            
188
Critical config values:
189

            
190
```yaml
191
entrypoints:
192
  is_jumper:
193
    hostname: 192.168.2.100
194
    user: root
195
    identity_file: ~/.ssh/keys/is-jumper_ed25519
196
    identities_only: true
197

            
198
jumps:
199
  j1:
200
    hostname: 10.253.51.50
201
    user: bogdan.timofte
202
    port: 25904
203
  j2:
204
    hostname: 10.253.51.52
205
    user: bogdan.timofte
206
    port: 25904
207
```
208

            
209
If J1/J2 use `bogdan` instead of `bogdan.timofte`, final host SSH will fail with
210
an error like:
211

            
212
```text
213
bogdan@10.253.51.50: Permission denied (publickey).
214
Connection to 192.168.2.100 closed.
215
```
216

            
217
Fix that in `inventory/hosts-local.yaml`, deploy, then verify:
Bogdan Timofte authored 2 weeks ago
218

            
Bogdan Timofte authored 2 weeks ago
219
```bash
Bogdan Timofte authored 2 weeks ago
220
ssh -G j1 | grep -E '^(hostname|user|port) '
221
tools/deploy-local.sh
222
ssh porta-sip hostname
223
```
224

            
225
## Inventory and Generation
Bogdan Timofte authored 2 weeks ago
226

            
Bogdan Timofte authored 2 weeks ago
227
The generator reads:
Bogdan Timofte authored 2 weeks ago
228

            
Bogdan Timofte authored 2 weeks ago
229
```text
230
inventory/hosts.yaml
231
inventory/hosts-local.yaml if it exists
Bogdan Timofte authored 2 weeks ago
232
```
233

            
Bogdan Timofte authored 2 weeks ago
234
Important: the inventory merge is shallow. Later top-level maps from
235
`hosts-local.yaml` override upstream maps. This is useful for local lab entries
236
but dangerous for defaults. If `hosts-local.yaml` changes `defaults.jump.user`,
237
then local `jumps.j1` and `jumps.j2` must specify `user: bogdan.timofte`
238
explicitly.
Bogdan Timofte authored 2 weeks ago
239

            
Bogdan Timofte authored 2 weeks ago
240
Generated files:
Bogdan Timofte authored 2 weeks ago
241

            
Bogdan Timofte authored 2 weeks ago
242
```text
243
generated/client.conf      installed as ~/.ssh/config
244
generated/is-jumper.conf   server-side helper config
245
generated/j1.conf          server-side final-host config
246
generated/j2.conf          server-side final-host config
247
```
Bogdan Timofte authored 2 weeks ago
248

            
Bogdan Timofte authored 2 weeks ago
249
`generated/` is ignored by git. Recreate it any time:
Bogdan Timofte authored 2 weeks ago
250

            
Bogdan Timofte authored 2 weeks ago
251
```bash
252
python3 tools/generate-configs.py
253
```
Bogdan Timofte authored 2 weeks ago
254

            
Bogdan Timofte authored 2 weeks ago
255
Deploy local runtime:
Bogdan Timofte authored 2 weeks ago
256

            
257
```bash
258
tools/deploy-local.sh
259
```
260

            
Bogdan Timofte authored 2 weeks ago
261
Deploy does:
Bogdan Timofte authored 2 weeks ago
262

            
Bogdan Timofte authored 2 weeks ago
263
```text
264
1. run tools/generate-configs.py
265
2. install generated/client.conf as ~/.ssh/config
266
3. install scripts/ssh-wrapper.sh as ~/.local/bin/ssh
267
4. install scripts/scp-wrapper.sh as ~/.local/bin/scp
268
5. install scripts/sftp-wrapper.sh as ~/.local/bin/sftp
269
6. remove obsolete ~/.ssh/scripts wrapper copies
270
```
Bogdan Timofte authored 2 weeks ago
271

            
Bogdan Timofte authored 2 weeks ago
272
It does not touch private keys, `authorized_keys`, or `known_hosts`.
273

            
274
## Local Shell and Wrappers
275

            
276
For company aliases, `ssh` must be the wrapper:
Bogdan Timofte authored 2 weeks ago
277

            
278
```bash
Bogdan Timofte authored 2 weeks ago
279
which ssh
280
# /Users/bogdan/.local/bin/ssh
281
```
282

            
283
If it shows `/usr/bin/ssh`, fix shell PATH and reload:
284

            
285
```bash
286
source ~/.zshrc
287
which ssh
Bogdan Timofte authored 2 weeks ago
288
```
289

            
Bogdan Timofte authored 2 weeks ago
290
The current shell startup should keep `~/.local/bin` first in both interactive
291
and login shells. If editing these files, preserve this behavior:
Bogdan Timofte authored 2 weeks ago
292

            
Bogdan Timofte authored 2 weeks ago
293
```zsh
294
path=("$HOME/.local/bin" ${path:#"$HOME/.local/bin"})
295
export PATH
296
```
297

            
298
`ssh-wrapper.sh` uses bash 3.2 compatible array expansion under `set -u`.
299
Do not replace guarded forms like:
300

            
301
```bash
302
${cmd_args[@]+"${cmd_args[@]}"}
303
```
304

            
305
with plain:
306

            
307
```bash
308
"${cmd_args[@]}"
309
```
310

            
311
On macOS bash 3.2, empty arrays plus `set -u` can fail with:
312

            
313
```text
314
cmd_args[@]: unbound variable
315
```
316

            
317
## Sync from Upstream
Bogdan Timofte authored 2 weeks ago
318

            
Bogdan Timofte authored 2 weeks ago
319
Pull upstream `hosts.yaml`, apply the local `is-jumper` key override, validate
320
generation, and deploy if changed:
321

            
322
```bash
323
tools/sync-hosts-from-upstream.sh
324
```
325

            
326
Defaults:
327

            
328
```text
329
UPSTREAM_SSH_TARGET=nextgen@192.168.2.103
330
UPSTREAM_HOSTS_PATH=/home/nextgen/projects/ssh-infrastructure/inventory/hosts.yaml
331
LOCAL_IS_JUMPER_IDENTITY_FILE=~/.ssh/keys/is-jumper_ed25519
332
DEPLOY_AFTER_SYNC=1
333
FORCE_DEPLOY=0
334
```
335

            
336
Useful overrides:
337

            
338
```bash
339
UPSTREAM_HOSTS_FILE=/tmp/hosts.yaml tools/sync-hosts-from-upstream.sh
340
DEPLOY_AFTER_SYNC=0 tools/sync-hosts-from-upstream.sh
341
FORCE_DEPLOY=1 tools/sync-hosts-from-upstream.sh
342
UPSTREAM_SSH_TARGET=user@host tools/sync-hosts-from-upstream.sh
343
```
344

            
345
After sync, always check J1 user because the local overlay can override jump
346
defaults:
347

            
348
```bash
349
ssh -G j1 | grep -E '^(hostname|user|port) '
350
```
351

            
352
Expected:
353

            
354
```text
355
user bogdan.timofte
356
hostname 10.253.51.50
357
port 25904
358
```
359

            
360
## Adding or Changing Hosts
361

            
362
For company/Next-Gen hosts:
363

            
364
```text
365
1. Edit inventory/hosts.yaml or sync it from upstream.
366
2. Keep local-only corrections in inventory/hosts-local.yaml.
367
3. Run tools/deploy-local.sh.
368
4. Verify with ssh -G <alias>.
369
5. Verify read-only with ssh <alias> hostname.
370
6. Commit source changes only.
371
```
372

            
373
For local lab hosts:
374

            
375
```text
376
1. Edit inventory/hosts-local.yaml.
377
2. Run tools/deploy-local.sh.
378
3. Verify with ssh <alias> hostname.
379
4. Commit the local overlay change.
380
```
381

            
382
Common inventory defaults:
383

            
384
| Context | User | Port |
385
| --- | --- | --- |
386
| J1/J2 company jump | `bogdan.timofte` | `25904` for VPN route |
387
| Company final hosts | usually `bogdan` | usually `22` |
388
| Company inherited jump config | `bogdan.timofte` | often `24` |
389
| Local lab hosts | usually `bogdan` | usually `22` |
390
| Cisco/OLT interactive devices | inventory-specific | `22` |
391

            
392
For Cisco/OLT/password-interactive devices, set:
393

            
394
```yaml
395
auth: password_interactive
396
```
397

            
398
The wrapper then avoids forcing `BatchMode=yes` and disables pubkey auth for
399
that final hop.
400

            
401
## Key Migration for Local Legacy Hosts
402

            
403
Modern preferred key:
404

            
405
```text
406
~/.ssh/id_ed25519.pub
407
```
408

            
409
Legacy fallback key:
410

            
411
```text
412
~/.ssh/keys/id_rsa_old
413
```
414

            
415
Migrate all configured local legacy hosts:
Bogdan Timofte authored 2 weeks ago
416

            
417
```bash
418
tools/migrate-modern-key.sh
Bogdan Timofte authored 2 weeks ago
419
```
420

            
421
Migrate one host:
Bogdan Timofte authored 2 weeks ago
422

            
Bogdan Timofte authored 2 weeks ago
423
```bash
Bogdan Timofte authored 2 weeks ago
424
tools/migrate-modern-key.sh is-baobab
425
```
426

            
Bogdan Timofte authored 2 weeks ago
427
Manual fallback if password access is available:
Bogdan Timofte authored 2 weeks ago
428

            
Bogdan Timofte authored 2 weeks ago
429
```bash
430
ssh -o PubkeyAuthentication=no user@host \
431
  "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" \
432
  < ~/.ssh/id_ed25519.pub
433
```
434

            
435
Keep `id_rsa_old` until all legacy hosts are verified with the modern key.
436

            
437
## Verification Checklist
438

            
439
Run after deploy, sync, wrapper edits, or inventory changes:
440

            
441
```bash
442
which ssh
443
ssh -G is-jumper | grep -E '^(hostname|user|identityfile|identitiesonly) '
444
ssh -G j1 | grep -E '^(hostname|user|port) '
445
ssh is-jumper hostname
446
ssh is-jumper 'SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh ssh-add -L | sed -n 1p'
447
ssh porta-sip hostname
448
ssh pbx-bo hostname
449
```
450

            
451
Expected signals:
Bogdan Timofte authored 2 weeks ago
452

            
453
```text
Bogdan Timofte authored 2 weeks ago
454
which ssh                         -> /Users/bogdan/.local/bin/ssh
455
is-jumper hostname                -> is-vpn-gw
456
j1 user                           -> bogdan.timofte
457
physical card check               -> ssh-rsa ... cardno:6446168
458
porta-sip hostname                -> p12.voip.ro
459
pbx-bo hostname                   -> pbx-bo
Bogdan Timofte authored 2 weeks ago
460
```
461

            
Bogdan Timofte authored 2 weeks ago
462
Interactive smoke test:
463

            
464
```bash
465
printf "exit\n" | ssh porta-sip
466
printf "exit\n" | ssh pbx-bo
467
```
468

            
469
## Troubleshooting
470

            
471
### `bogdan@10.253.51.50: Permission denied (publickey)`
472

            
473
The wrapper reached `is-jumper`, but J1 was attempted with user `bogdan`.
474
J1/J2 need `bogdan.timofte`.
475

            
476
Check:
477

            
478
```bash
479
ssh -G j1 | grep -E '^(hostname|user|port) '
480
```
481

            
482
Fix:
483

            
484
```yaml
485
# inventory/hosts-local.yaml
486
jumps:
487
  j1:
488
    user: bogdan.timofte
489
  j2:
490
    user: bogdan.timofte
491
```
492

            
493
Deploy:
494

            
495
```bash
496
tools/deploy-local.sh
497
ssh porta-sip hostname
498
```
Bogdan Timofte authored 2 weeks ago
499

            
Bogdan Timofte authored 2 weeks ago
500
### `root@192.168.2.100: Permission denied`
Bogdan Timofte authored 2 weeks ago
501

            
Bogdan Timofte authored 2 weeks ago
502
The local connection to `is-jumper` is using the wrong key.
503

            
504
Check:
505

            
506
```bash
507
ssh -G is-jumper | grep -E '^(user|hostname|identityfile|identitiesonly) '
508
ls -l ~/.ssh/keys/is-jumper_ed25519
509
```
510

            
511
Expected:
Bogdan Timofte authored 2 weeks ago
512

            
513
```text
Bogdan Timofte authored 2 weeks ago
514
user root
515
hostname 192.168.2.100
516
identityfile ~/.ssh/keys/is-jumper_ed25519
517
identitiesonly yes
518
```
519

            
520
If generated config is wrong, fix `inventory/hosts-local.yaml` or
521
`inventory/hosts.yaml`, then deploy.
522

            
523
### `ssh pbx-bo` uses `/usr/bin/ssh`
524

            
525
The wrapper is not first in PATH.
526

            
527
Check:
528

            
529
```bash
530
which ssh
Bogdan Timofte authored 2 weeks ago
531
```
532

            
Bogdan Timofte authored 2 weeks ago
533
Fix current shell:
534

            
535
```bash
536
source ~/.zshrc
537
```
Bogdan Timofte authored 2 weeks ago
538

            
Bogdan Timofte authored 2 weeks ago
539
If needed, ensure `.zprofile` and `.zshrc` both move `~/.local/bin` to the front
540
using zsh `path`, not a guard that leaves it later in PATH.
Bogdan Timofte authored 2 weeks ago
541

            
Bogdan Timofte authored 2 weeks ago
542
### `cmd_args[@]: unbound variable`
543

            
544
This is a bash 3.2 plus `set -u` empty-array issue in `ssh-wrapper.sh`.
545

            
546
Use guarded array expansion:
547

            
548
```bash
549
${array[@]+"${array[@]}"}
550
```
551

            
552
Do not simplify it.
553

            
554
### Physical card missing on `is-jumper`
555

            
556
Check:
557

            
558
```bash
559
ssh is-jumper 'ls -l /run/user/0/gnupg/S.gpg-agent.ssh'
560
ssh is-jumper 'SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh ssh-add -L | sed -n 1p'
561
```
562

            
563
Expected key output contains:
Bogdan Timofte authored 2 weeks ago
564

            
Bogdan Timofte authored 2 weeks ago
565
```text
Bogdan Timofte authored 2 weeks ago
566
cardno:6446168
Bogdan Timofte authored 2 weeks ago
567
```
568

            
Bogdan Timofte authored 2 weeks ago
569
If missing, the issue is on `is-jumper`: gpg-agent, card mount, permissions, or
570
hardware state.
Bogdan Timofte authored 2 weeks ago
571

            
Bogdan Timofte authored 2 weeks ago
572
### Direct command works but wrapper fails
Bogdan Timofte authored 2 weeks ago
573

            
Bogdan Timofte authored 2 weeks ago
574
Compare generated command behavior:
575

            
576
```bash
577
bash -x ~/.local/bin/ssh porta-sip hostname
578
```
579

            
580
Look for:
581

            
582
```text
583
SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh
584
bogdan.timofte@10.253.51.50
585
```
586

            
587
If either is wrong, fix inventory/local overlay or wrapper.
588

            
589
### Generated config was edited manually
590

            
591
Discard manual runtime edits by redeploying:
Bogdan Timofte authored 2 weeks ago
592

            
593
```bash
Bogdan Timofte authored 2 weeks ago
594
tools/deploy-local.sh
Bogdan Timofte authored 2 weeks ago
595
```
596

            
597
Then verify:
598

            
599
```bash
600
ssh -G j1 | grep -E '^(hostname|user|port) '
601
```
602

            
603
## Compatibility and Compliance
604

            
605
Do not reintroduce these removed patterns:
606

            
607
```text
608
j1-relay.sh
609
ssh-proxy.sh
610
ensure-ssh-agent-bridge.sh
611
ensure-ssh-jump.sh
612
local socket forwarding for the hardware card
613
Python/base64 port-forwarding relays
614
per-host local ProxyCommand bridges
615
```
Bogdan Timofte authored 2 weeks ago
616

            
Bogdan Timofte authored 2 weeks ago
617
Current compliant model:
618

            
619
```text
620
local wrapper -> ssh is-jumper -> run normal ssh from is-jumper
Bogdan Timofte authored 2 weeks ago
621
```
622

            
Bogdan Timofte authored 2 weeks ago
623
Compatibility options for old final hosts belong in inventory or on jump hosts,
624
not in ad-hoc local forwarding scripts.
Bogdan Timofte authored 2 weeks ago
625

            
Bogdan Timofte authored 2 weeks ago
626
## Maintenance Notes for Agents
627

            
628
Before changing anything:
Bogdan Timofte authored 2 weeks ago
629

            
630
```bash
Bogdan Timofte authored 2 weeks ago
631
git status --short --branch
632
which ssh
633
ssh -G j1 | grep -E '^(hostname|user|port) '
634
```
635

            
636
When fixing auth:
Bogdan Timofte authored 2 weeks ago
637

            
Bogdan Timofte authored 2 weeks ago
638
```text
639
1. Identify which hop failed from the error user@host.
640
2. is-jumper failures mean local key/config.
641
3. J1/J2 failures mean hardware card, SSH_AUTH_SOCK, or jump user.
642
4. final-host failures mean final host user/auth/port.
643
5. Apply the fix in inventory or wrapper source, not generated config.
644
6. Run tools/deploy-local.sh.
645
7. Run read-only SSH verification.
646
8. Commit the source change.
647
```
648

            
649
Do not assume `hosts.yaml` alone is the effective config. Always remember
650
`inventory/hosts-local.yaml` is merged in by `tools/generate-configs.py`.
651

            
652
Do not trust stale docs, comments, or generated files over these commands:
653

            
654
```bash
655
ssh -G <alias>
656
tools/deploy-local.sh
657
ssh <alias> hostname
658
git diff
Bogdan Timofte authored 2 weeks ago
659
```