Showing 11 changed files with 1910 additions and 0 deletions
+222 -0
.doc/ssh-jump-architecture.md
@@ -0,0 +1,222 @@
1
+# Arhitectura SSH Curenta
2
+
3
+Acest document descrie configuratia activa din `/home/nextgen/.ssh`. Sursa versionata a proiectului este in `/home/nextgen/projects/ssh-infrastructure`; `/home/nextgen/.ssh` ramane runtime OpenSSH si nu contine repo-ul git. Ultima actualizare: 2026-05-15. Jurnalul istoric `ssh-jump-attempts.md` este obsolete si poate fi consultat doar din git.
4
+
5
+## Structura Locala
6
+
7
+```text
8
+/home/nextgen/.ssh/
9
+  config                        <- single-file generat din inventory/hosts.yaml
10
+  known_hosts
11
+  known_hosts.old
12
+  authorized_keys
13
+  keys/
14
+    id_ed25519
15
+    id_ed25519.pub
16
+    elastix-root.pub
17
+  .doc/
18
+    ssh-jump-architecture.md
19
+
20
+~/.local/bin/
21
+  ssh
22
+  scp
23
+  sftp
24
+```
25
+
26
+Reguli:
27
+
28
+- Proiectul versionat sta in `~/projects/ssh-infrastructure`; `~/.ssh` ramane runtime OpenSSH.
29
+- Cheile de identitate stau in `~/.ssh/keys`.
30
+- Hosturile SSH sunt definite in `inventory/hosts.yaml`, apoi generate in `generated/*.conf`.
31
+- `~/.ssh/config` este instalat din `generated/client.conf` prin `tools/deploy-local.sh`; nu se editeaza manual.
32
+- Scripturile sursa stau versionate in `scripts/` in repo.
33
+- `~/.local/bin/` contine copiile executabile instalate de `tools/deploy-local.sh`; nu se editeaza manual.
34
+- `authorized_keys`, `known_hosts` si `config` raman in radacina `.ssh`, pentru compatibilitate cu OpenSSH.
35
+- `j1-relay.sh`, `ssh-proxy.sh`, `ensure-ssh-agent-bridge.sh` si `ensure-ssh-jump.sh` au fost eliminate; `ssh-wrapper.sh` ruleaza clientul SSH activ pe `is-jumper`, unde se afla cheia fizica.
36
+
37
+## Wrappers locale
38
+
39
+Scripturile sursa sunt in `scripts/` in repo; `~/.local/bin/` contine copiile executabile instalate local.
40
+
41
+| Comanda | Script | Mecanism |
42
+| --- | --- | --- |
43
+| `ssh` | `ssh-wrapper.sh` | parseaza args, rezolva hostul via `ssh -G`, construieste sesiunea nested |
44
+| `scp` | `scp-wrapper.sh` | apeleaza `/usr/bin/scp -S ~/.local/bin/ssh` — rutarea e delegata wrapper-ului ssh |
45
+| `sftp` | `sftp-wrapper.sh` | apeleaza `/usr/bin/sftp -S ~/.local/bin/ssh` — idem |
46
+
47
+- Compatibilitatea SSH pentru hop-ul final este responsabilitatea serverelor `J1`/`J2`; wrapperele locale nu reproduc algoritmi, KEX, cipher-uri sau `OPENSSL_CONF`.
48
+- Daca PATH-ul include `~/.local/bin` inaintea `/usr/bin`, comenzile `ssh`/`scp`/`sftp` folosesc automat aceste wrappers.
49
+
50
+## Arhitectura rețelei
51
+
52
+```
53
+192.168.2.0/24  — rețea locală de birou
54
+  is-toltec  (workstation-ul local)
55
+  is-jumper  192.168.2.100  (gardian cheie + client VPN)
56
+
57
+10.253.51.0/24  — rețea internă companie (acces via VPN de pe is-jumper)
58
+  J1  10.253.51.50:25904
59
+  J2  10.253.51.52:25904
60
+  hosturi finale (voip, porta, radius etc.)
61
+```
62
+
63
+**is-jumper** (192.168.2.100) este pe rețeaua locală de birou — accesibil direct din is-toltec, **fără VPN**. Este un **client** VPN (nu server): prin el se obține acces la `10.253.51.0/24`. Este **gardianul cheii fizice** (card RSA 4096) deoarece deservește mai mulți utilizatori din `192.168.2.0/24`.
64
+
65
+**Cheia fizică** (cardul) este montată exclusiv pe is-jumper și expusă prin GPG agent la `/run/user/0/gnupg/S.gpg-agent.ssh`. Wrapper-ul nu mai forwardează acest socket pe mașina locală; în schimb intră pe `is-jumper`, setează `SSH_AUTH_SOCK` la socketul local de acolo și rulează de pe `is-jumper` clientul SSH către J1/J2/j1/j2.
66
+
67
+## Lanțul de acces
68
+
69
+Calea standard (VPN activ, J1):
70
+
71
+```text
72
+local
73
+  -> is-jumper (192.168.2.100, executor SSH + cheie fizica)
74
+  -> J1 (10.253.51.50:25904)
75
+  -> host final
76
+```
77
+
78
+Calea publică (urgențe, fără rută VPN, j1/j2):
79
+
80
+```text
81
+local
82
+  -> is-jumper (192.168.2.100, executor SSH + cheie fizica)
83
+  -> j1.next-gen.ro:25904
84
+  -> host final
85
+```
86
+
87
+Sesiuni interactive pe J1/J2:
88
+
89
+```text
90
+local -> is-jumper -> J1
91
+local -> is-jumper -> J2
92
+```
93
+
94
+Nu mai exista bridge local de agent (`/tmp/is-jumper-agent.sock`). Cheia fizica ramane folosita local pe `is-jumper`.
95
+
96
+Jumps disponibile via flaguri wrapper:
97
+
98
+| Flag | Jump | Rută | Când |
99
+| --- | --- | --- | --- |
100
+| implicit / `-J1` | J1 (10.253.51.50) | client SSH rulat pe is-jumper | standard |
101
+| `-J2` | J2 (10.253.51.52) | client SSH rulat pe is-jumper | failover VPN |
102
+| `-j1` | j1.next-gen.ro | client SSH rulat pe is-jumper | urgențe |
103
+| `-j2` | j2.next-gen.ro | client SSH rulat pe is-jumper | urgențe |
104
+
105
+## Scripturi
106
+
107
+### ssh-wrapper.sh
108
+
109
+Parseaza argumentele `ssh`, rezolva hostul via `ssh -G`, construieste sesiunea nested activa pe `is-jumper`:
110
+
111
+```
112
+exec /usr/bin/ssh is-jumper "SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh ssh -A <jump_host> '<final_ssh_cmd>'"
113
+```
114
+
115
+Bypass automat (pass-through direct) pentru:
116
+- `is-jumper` / `192.168.2.100`
117
+- Flaguri de interogare: `-G`, `-Q`, `-V`, `--help`
118
+
119
+Pentru J1/J2/j1/j2, wrapper-ul conecteaza intai local la `is-jumper`, apoi ruleaza acolo `ssh -A` cu `SSH_AUTH_SOCK=/run/user/0/gnupg/S.gpg-agent.ssh`. Pentru hosturile finale, comanda de pe jump ruleaza inca un `ssh` catre hostul final. Pentru `scp`/`sftp`, wrapper-ul pastreaza forma de subsystem `-s ... sftp`.
120
+
121
+### scp-wrapper.sh / sftp-wrapper.sh
122
+
123
+Wrappers subtiri care injecteaza `-S ~/.local/bin/ssh` la apelul sistemului:
124
+
125
+```bash
126
+/usr/bin/scp -S ~/.local/bin/ssh "$@"
127
+/usr/bin/sftp -S ~/.local/bin/ssh "$@"
128
+```
129
+
130
+Rutarea este delegata integral catre `ssh-wrapper.sh`; `scp`/`sftp` raman transparente pentru utilizator.
131
+
132
+Nu mai exista scripturi active bazate pe Python, ProxyJump sau port-forwarding externe; vechiul helper Python a fost eliminat dupa ce a fost semnalat de Sentinel.
133
+
134
+## Config SSH
135
+
136
+`~/.ssh/config` este un single-file generat din `inventory/hosts.yaml`:
137
+
138
+```sshconfig
139
+# Generated by tools/generate-configs.py.
140
+# Do not edit this file directly; edit inventory/hosts.yaml.
141
+```
142
+
143
+Generatorul produce:
144
+
145
+| Fisier | Destinatie | Rol |
146
+| --- | --- | --- |
147
+| `generated/client.conf` | client local | config runtime pentru `~/.ssh/config` |
148
+| `generated/is-jumper.conf` | is-jumper | config pentru jump aliases |
149
+| `generated/j1.conf` | J1 | hosturi finale |
150
+| `generated/j2.conf` | J2 | hosturi finale |
151
+
152
+Reguli de configurare:
153
+
154
+- hosturile finale definesc doar `HostName`, `User` si `Port`;
155
+- pe client, hosturile importate din IFS folosesc drept `HostName` numele
156
+  canonic deja cunoscut pe jump, iar IP-ul ramane alias pentru autocomplete;
157
+- `is-jumper` defineste si cheia locala de acces (`IdentityFile`, `IdentitiesOnly`);
158
+- wrapperul construieste ruta nested prin `is-jumper -> J1` implicit;
159
+- optiunile comune per-grup (ConnectTimeout etc.) stau in `inventory/hosts.yaml`;
160
+- `generated/j1.conf` si `generated/j2.conf` mostenesc blocul global company-managed
161
+  si omit `User`/`Port` cand toate aliasurile unui host sunt deja acoperite de
162
+  regulile `Match Host` de pe jump;
163
+- `generated/j1.conf` si `generated/j2.conf` nu mai includ hosturile care folosesc
164
+  doar defaulturi mostenite; raman doar override-urile efective si exceptiile
165
+  per-pattern care trebuie pastrate pe jump;
166
+- `generated/j1.conf` si `generated/j2.conf` sunt generate fara comentarii,
167
+  doar cu stanza-urile SSH necesare;
168
+- hosturile Radius folosesc acelasi flux nested prin J1 ca restul hosturilor finale.
169
+
170
+Aliasuri principale:
171
+
172
+| Alias | Rol | Rutare |
173
+| --- | --- | --- |
174
+| `is-jumper` | VPN gateway + gardian cheie fizica | direct (IP 192.168.2.100) |
175
+| `J1`, `J2` | jump hosts, login interactiv | wrapper prin is-jumper |
176
+| `voip-prov`, `voip-prov-root` | host VoIP provider | wrapper nested prin J1 |
177
+| `porta-sip`, `porta-web`, `porta-db`, `porta-configurator` | PortaOne MR30 | wrapper nested prin J1 |
178
+| `elastix` | host vechi | wrapper nested prin J1 |
179
+| `*.radius-db`, `*.radius-pppoe` | baze Radius regionale | wrapper nested prin J1 |
180
+
181
+`J1` si `J2` nu mai folosesc `ProxyJump`, `IdentityAgent` sau `RemoteCommand` in configul local; wrapper-ul orchestreaza explicit conexiunea prin `is-jumper`.
182
+
183
+## PortaOne
184
+
185
+Aliasuri PortaOne:
186
+
187
+- `porta-sip`, `p12-sip`, `p12`, `p12.voip.ro` -> `193.16.148.4`
188
+- `porta-web`, `porta-api`, `porta-slave`, `porta7`, `telefonie.next-gen.ro` -> `193.16.148.7`
189
+- `porta-db`, `porta-master`, `porta1` -> `193.16.148.11`
190
+- `porta-configurator`, `porta-config` -> `193.16.148.13`
191
+
192
+Aceste hosturi folosesc ruta nested din wrapper; eventualele optiuni de compatibilitate ale hop-ului final sunt asigurate pe `J1`/`J2`.
193
+
194
+## Verificari
195
+
196
+Verificare configuratie fara conectare:
197
+
198
+```bash
199
+ssh -G is-jumper | grep -E '^(hostname|user|identityfile|identitiesonly)'
200
+ssh -G porta-db | grep -E '^(hostname|user|port)'
201
+ssh -G falticeni.radius-db | grep -E '^(hostname|user|port|connecttimeout)'
202
+```
203
+
204
+Verificare read-only cu conectare:
205
+
206
+```bash
207
+ssh porta-db hostname
208
+ssh porta-sip 'service porta-sip status'
209
+ssh voip-prov hostname
210
+scp porta-db:/etc/hosts /tmp/test-scp
211
+sftp porta-db <<< 'ls /'
212
+```
213
+
214
+## Mentenanta
215
+
216
+- Cand adaugi o cheie noua, pune-o in `~/.ssh/keys` si seteaza permisiuni `600` pentru cheia privata.
217
+- Cand adaugi un host nou: adauga-l in `inventory/hosts.yaml`, ruleaza generatorul si apoi `tools/deploy-local.sh`.
218
+- Cand adaugi un domeniu nou: adauga un grup nou in `inventory/hosts.yaml`.
219
+- Cand adaugi un wrapper nou: adauga scriptul in `scripts/` in repo si lasa `tools/deploy-local.sh` sa il instaleze in `~/.local/bin/`.
220
+- Nu readuce `ProxyJump`, `IdentityAgent /tmp/is-jumper-agent.sock`, helperul Python sau port-forwarding per-host.
221
+- Nu activa `ForwardAgent yes` pe hosturile finale fara un motiv explicit.
222
+- Dupa modificari la wrappers sau config, verifica: `ssh -G <alias>` si cel putin un alias read-only (`ssh porta-db hostname`).
+27 -0
.gitignore
@@ -0,0 +1,27 @@
1
+# Ignore SSH agent state and dynamic socket directories
2
+agent/
3
+
4
+# Ignore known hosts, authorized keys, and private keys
5
+authorized_keys
6
+known_hosts
7
+known_hosts.old
8
+keys/
9
+*.pem
10
+*.key
11
+*.ppk
12
+*.der
13
+*.csr
14
+
15
+# Ignore local or temporary files
16
+*.swp
17
+*.tmp
18
+.DS_Store
19
+__pycache__/
20
+
21
+# Ignore generated deploy artifacts
22
+generated/
23
+SSH_SETUP_SUMMARY.md
24
+
25
+# Ignore local import/export work areas and ad-hoc overlay trees
26
+import/
27
+conf.d/
+116 -0
README.md
@@ -0,0 +1,116 @@
1
+# SSH Infrastructure
2
+
3
+Source-controlled SSH routing and wrapper configuration for the Next-Gen jump
4
+infrastructure.
5
+
6
+## Runtime vs project
7
+
8
+- Project source: `~/Documents/Workspaces/Bogdan/ssh-infrastructure`
9
+- OpenSSH runtime: `~/.ssh`
10
+
11
+Keep secrets and machine-local state out of this repository:
12
+
13
+- private keys: `~/.ssh/keys/`
14
+- `authorized_keys`
15
+- `known_hosts`
16
+- socket or agent state
17
+
18
+The runtime `~/.ssh/config` is a generated single-file OpenSSH config. The
19
+project is the place to edit wrappers, documentation, inventory, and generator
20
+code.
21
+
22
+Deploy the local runtime copy with:
23
+
24
+```bash
25
+tools/deploy-local.sh
26
+```
27
+
28
+The deploy script generates `generated/client.conf`, installs it as
29
+`~/.ssh/config`, and installs the wrapper commands into `~/.local/bin`.
30
+It does not touch keys, `authorized_keys`, or `known_hosts`.
31
+
32
+## Version control
33
+
34
+This directory is the git repository for source files only. Generated configs,
35
+local state, keys, known hosts, and handoff notes stay out of version control.
36
+
37
+Track source changes with:
38
+
39
+```bash
40
+git status
41
+git add inventory schema scripts tools .doc README.md .gitignore
42
+git commit
43
+```
44
+
45
+## Current client layout
46
+
47
+```text
48
+~/.ssh/config
49
+~/.local/bin/ssh
50
+~/.local/bin/scp
51
+~/.local/bin/sftp
52
+```
53
+
54
+The wrapper sources stay versioned in `scripts/` inside the project; deploy
55
+installs executable copies into `~/.local/bin` and removes the obsolete
56
+`~/.ssh/scripts` runtime layout from older checkouts.
57
+
58
+## Source of Truth
59
+
60
+The structured source of truth starts in:
61
+
62
+```text
63
+inventory/hosts.yaml
64
+schema/hosts.schema.json
65
+tools/generate-configs.py
66
+```
67
+
68
+The `generated/*.conf` files are deploy artifacts. They are ignored by git and
69
+can be recreated at any time with `tools/deploy-local.sh`.
70
+
71
+## Sync from upstream
72
+
73
+Pull the upstream inventory, apply this machine's local `is-jumper` key override,
74
+regenerate, and deploy if the inventory changed:
75
+
76
+```bash
77
+tools/sync-hosts-from-upstream.sh
78
+```
79
+
80
+Defaults:
81
+
82
+```text
83
+UPSTREAM_SSH_TARGET=nextgen@192.168.2.103
84
+UPSTREAM_HOSTS_PATH=/home/nextgen/projects/ssh-infrastructure/inventory/hosts.yaml
85
+LOCAL_IS_JUMPER_IDENTITY_FILE=~/.ssh/keys/is-jumper_ed25519
86
+DEPLOY_AFTER_SYNC=1
87
+```
88
+
89
+Useful overrides:
90
+
91
+```bash
92
+UPSTREAM_HOSTS_FILE=/tmp/hosts.yaml tools/sync-hosts-from-upstream.sh
93
+DEPLOY_AFTER_SYNC=0 tools/sync-hosts-from-upstream.sh
94
+FORCE_DEPLOY=1 tools/sync-hosts-from-upstream.sh
95
+```
96
+
97
+Known defaults captured there:
98
+
99
+- jump hosts default to user `bogdan.timofte`;
100
+- jump hosts default to port `24`, with explicit overrides such as `25904`;
101
+- final hosts default to user `bogdan` because most distributions do not like
102
+  dotted local usernames;
103
+- when imported configs disagree between `bogdan` and `root`, `bogdan` wins;
104
+- final hosts default to port `22`, with explicit overrides where needed.
105
+- Cisco/TACACS router entries use `auth: password_interactive`; generated SSH
106
+  config marks them so the wrapper does not force `BatchMode=yes`.
107
+- Imported IFS devices use the canonical hostname already known on the jump
108
+  servers as client-side `HostName`, while IPs remain available as aliases for
109
+  autocomplete.
110
+- J1/J2 configs inherit the company-managed global compatibility block and
111
+  selected `Match Host` user/port defaults from the jump servers instead of
112
+  duplicating them in generated output.
113
+- J1/J2 configs only emit host entries when there is an effective override to
114
+  keep on the jump side; hosts that only use inherited defaults are omitted.
115
+- J1/J2 generated configs are stripped down to functional SSH stanzas only,
116
+  without generated comments or group annotations.
+700 -0
inventory/hosts.yaml
@@ -0,0 +1,700 @@
1
+version: 1
2
+facts:
3
+  jump_default_port: 24
4
+  jump_default_user: bogdan.timofte
5
+  common_distribution_user: bogdan
6
+  notes:
7
+  - Most distributions do not like dots in local usernames, so most final-host installs
8
+    use bogdan.
9
+  - In bogdan/root import conflicts, bogdan wins.
10
+ssh_options:
11
+  legacy_compatibility:
12
+    description: Company-managed jump global ssh_config compatibility options
13
+    options:
14
+      KexAlgorithms: +diffie-hellman-group1-sha1,diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1
15
+      Ciphers: +aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc
16
+      HostKeyAlgorithms: +ssh-rsa
17
+      PubkeyAcceptedAlgorithms: +ssh-rsa
18
+      ForwardAgent: true
19
+      ForwardX11: false
20
+      PasswordAuthentication: true
21
+      HostbasedAuthentication: false
22
+      CheckHostIP: true
23
+      StrictHostKeyChecking: ask
24
+      Tunnel: false
25
+      SendEnv: LANG LC_* GIT_* ANSIBLE_*
26
+      HashKnownHosts: true
27
+company_managed:
28
+  jump_hosts:
29
+    inherit_globals_on_targets:
30
+    - j1
31
+    - j2
32
+    match_defaults:
33
+    - patterns:
34
+      - '*.dr0?'
35
+      - '*.ar0?'
36
+      - '*.cr01'
37
+      - '*.br01'
38
+      - '*.as??'
39
+      - '*.cs0?'
40
+      - '*.tv01'
41
+      - '*.ds0?'
42
+      - bucuresti.ines.dcm01
43
+      - bucuresti.nxdata.voip
44
+      - bucuresti.dolce.tv01
45
+      - '*dasan*'
46
+      user: bogdan.timofte
47
+      port: 22
48
+    - patterns:
49
+      - '*.olt'
50
+      user: bogdan.timofte@next-gen.ro
51
+      port: 22
52
+    - patterns:
53
+      - '*.dhcp'
54
+      - '*.shaper*'
55
+      - '*.sentinel'
56
+      - '*.scan'
57
+      - redmine
58
+      - speedtest
59
+      - webdevel
60
+      - scripting
61
+      - zabbix
62
+      - itpve-*
63
+      - cacti
64
+      - mx
65
+      - bucuresti.radius-pppoe
66
+      - flood-detector
67
+      - tacacs2
68
+      - tacacs1
69
+      - ns2
70
+      - ns1
71
+      - backup1
72
+      - gitlab
73
+      - nlg
74
+      - nexus
75
+      - dhcp-cmts
76
+      - '*.radius-db'
77
+      - jump1
78
+      - aggregator-buc
79
+      - mappix
80
+      - docker.*
81
+      - cpanel
82
+      - jump2
83
+      - nocpve-*
84
+      - ocvpn
85
+      user: bogdan.timofte
86
+      port: 24
87
+defaults:
88
+  jump:
89
+    user: bogdan.timofte
90
+    port: 24
91
+  final_host:
92
+    user: bogdan
93
+    port: 22
94
+    connect_timeout: 10
95
+    connection_attempts: 1
96
+entrypoints:
97
+  is_jumper:
98
+    aliases:
99
+    - is-jumper
100
+    hostname: 192.168.2.100
101
+    user: root
102
+    identity_file: ~/.ssh/keys/is-jumper_ed25519
103
+    identities_only: true
104
+jumps:
105
+  j1:
106
+    aliases:
107
+    - j1
108
+    hostname: 10.253.51.50
109
+    port: 25904
110
+    role: primary_vpn
111
+  j2:
112
+    aliases:
113
+    - j2
114
+    hostname: 10.253.51.52
115
+    port: 25904
116
+    role: failover_vpn
117
+  j1_public:
118
+    aliases:
119
+    - j1
120
+    hostname: j1.next-gen.ro
121
+    port: 25904
122
+    role: emergency_public
123
+  j2_public:
124
+    aliases:
125
+    - j2
126
+    hostname: j2.next-gen.ro
127
+    port: 25904
128
+    role: emergency_public
129
+groups:
130
+  voip_applications:
131
+    description: PBX systems
132
+    default_jump: j1
133
+    hosts:
134
+      vo52:
135
+        aliases:
136
+        - vo52
137
+        - vo522
138
+        - vo52-new
139
+        - 10.253.51.140
140
+        hostname: 10.253.51.140
141
+        user: root
142
+      vo52_old:
143
+        aliases:
144
+        - vo52-old
145
+        hostname: 193.16.148.152
146
+        user: root
147
+      vo53:
148
+        aliases:
149
+        - vo53
150
+        - 193.16.148.153
151
+        hostname: 193.16.148.153
152
+        port: 60011
153
+      elastix:
154
+        aliases:
155
+        - elastix
156
+        - 10.253.50.62
157
+        - 188.173.1.15
158
+        hostname: 10.253.50.62
159
+        user: root
160
+      ss7:
161
+        aliases:
162
+        - ss7
163
+        hostname: 10.253.51.138
164
+        user: root
165
+      voip_pbx_dispecerat:
166
+        aliases:
167
+        - voip-pbx-dispeceri
168
+        - pbx-dispeceri
169
+        - 10.253.51.134
170
+        hostname: 10.253.51.134
171
+        user: bogdan
172
+      voip_pbx_bo:
173
+        aliases:
174
+        - voip-pbx-bo
175
+        - pbx-bo
176
+        - 10.253.51.135
177
+        hostname: 10.253.51.135
178
+        user: bogdan
179
+  voip_network:
180
+    description: VoIP network infrastructure
181
+    default_jump: j1
182
+    hosts:
183
+      sbc0:
184
+        aliases:
185
+        - sbc0
186
+        - 10.253.51.130
187
+        - 10.20.30.10
188
+        - 193.16.148.197
189
+        hostname: 10.253.51.130
190
+      sbc1:
191
+        aliases:
192
+        - sbc1
193
+        - 10.253.51.131
194
+        - 10.20.30.10
195
+        - 193.16.148.194
196
+        - 193.16.148.195
197
+        - 193.16.148.196
198
+        - 193.16.148.198
199
+        - 193.16.148.199
200
+        hostname: 10.253.51.131
201
+      sbc2:
202
+        aliases:
203
+        - sbc2
204
+        - 10.253.51.132
205
+        - 10.20.30.11
206
+        hostname: 10.253.51.132
207
+      voip_prov:
208
+        aliases:
209
+        - voip-prov
210
+        - 10.253.51.139
211
+        hostname: 10.253.51.139
212
+      portabilitate:
213
+        aliases:
214
+        - portabilitate
215
+        - bdc
216
+        - 10.253.51.133
217
+        - 89.165.199.20
218
+        - 89.165.232.232
219
+        hostname: 10.253.51.133
220
+  porta:
221
+    description: PortaOne MR30 legacy
222
+    default_jump: j1
223
+    hosts:
224
+      porta_sip:
225
+        aliases:
226
+        - porta-sip
227
+        - p12-sip
228
+        - p12
229
+        - p12.voip.ro
230
+        - 193.16.148.4
231
+        hostname: 193.16.148.4
232
+      porta_web:
233
+        aliases:
234
+        - porta-web
235
+        - porta-api
236
+        - porta-slave
237
+        - porta7
238
+        - telefonie.next-gen.ro
239
+        - 193.16.148.7
240
+        hostname: 193.16.148.7
241
+      porta_db:
242
+        aliases:
243
+        - porta-db
244
+        - porta-master
245
+        - porta1
246
+        - 193.16.148.11
247
+        hostname: 193.16.148.11
248
+      porta_config:
249
+        aliases:
250
+        - porta-config
251
+        - porta-configurator
252
+        - 193.16.148.13
253
+        hostname: 193.16.148.13
254
+  pppoe:
255
+    description: RADIUS and PPPOE systems
256
+    default_jump: j1
257
+    defaults:
258
+      user: bogdan.timofte
259
+      port: 24
260
+    patterns:
261
+      '*.radius-db':
262
+        connect_timeout: 10
263
+        connection_attempts: 1
264
+      '*.radius-pppoe':
265
+        connect_timeout: 10
266
+        connection_attempts: 1
267
+    hosts:
268
+      radauti_radius_db:
269
+        aliases:
270
+        - radauti.radius-db
271
+        - 94.53.112.30
272
+        - 10.132.96.121
273
+        hostname: radauti.radius-db
274
+      pascani_radius_db:
275
+        aliases:
276
+        - pascani.radius-db
277
+        - 46.214.144.7
278
+        - 10.132.0.121
279
+        hostname: pascani.radius-db
280
+      falticeni_radius_db:
281
+        aliases:
282
+        - falticeni.radius-db
283
+        - 46.214.136.7
284
+        - 10.132.64.121
285
+        hostname: falticeni.radius-db
286
+      tg_frumos_radius_db:
287
+        aliases:
288
+        - tg_frumos.radius-db
289
+        - 94.53.170.7
290
+        - 10.132.32.121
291
+        hostname: tg_frumos.radius-db
292
+      buhusi_radius_db:
293
+        aliases:
294
+        - buhusi.radius-db
295
+        - 46.214.240.7
296
+        - 10.132.128.121
297
+        hostname: buhusi.radius-db
298
+      bucuresti_radius_pppoe:
299
+        aliases:
300
+        - bucuresti.radius-pppoe
301
+        - 188.173.1.29
302
+        hostname: bucuresti.radius-pppoe
303
+  legacy_public:
304
+    description: Legacy public VoIP jump
305
+    default_jump: j1
306
+    hosts:
307
+      voce_pub:
308
+        aliases:
309
+        - voce-pub
310
+        - voce-pub2
311
+        - 188.173.0.230
312
+        hostname: 188.173.0.230
313
+        user: bogdan
314
+        port: 22
315
+  imported_jump_hosts:
316
+    description: Hosts imported from J1/J2 user SSH configs
317
+    default_jump: j1
318
+    defaults:
319
+      user: bogdan.timofte
320
+      port: 24
321
+    hosts:
322
+      host_10_132_128_121:
323
+        aliases:
324
+        - 10.132.128.121
325
+        hostname: 10.132.128.121
326
+      host_188_173_0_163:
327
+        aliases:
328
+        - 188.173.0.163
329
+        hostname: 188.173.0.163
330
+        user: bogdan
331
+      host_188_173_0_141:
332
+        aliases:
333
+        - 188.173.0.141
334
+        hostname: 188.173.0.141
335
+        user: bogdan
336
+  noc:
337
+    description: NOC hosts grouped by function
338
+    default_jump: j1
339
+    defaults:
340
+      user: bogdan.timofte
341
+      port: 24
342
+    pve:
343
+      description: Proxmox hosts
344
+      default_jump: j1
345
+      hosts:
346
+        nocpve_nxdata1:
347
+          aliases:
348
+          - nocpve-nxdata1
349
+          - 188.173.1.112
350
+          - 10.253.51.24
351
+          hostname: 10.253.51.24
352
+          user: root
353
+        nocpve_nxdata2:
354
+          aliases:
355
+          - nocpve-nxdata2
356
+          - 188.173.1.116
357
+          - 10.253.51.25
358
+          hostname: 10.253.51.25
359
+          user: root
360
+        nocpve_ines1:
361
+          aliases:
362
+          - nocpve-ines1
363
+          - 188.173.1.117
364
+          - 10.253.51.27
365
+          hostname: 10.253.51.27
366
+          user: root
367
+        nocpve_ines2:
368
+          aliases:
369
+          - nocpve-ines2
370
+          - 188.173.1.118
371
+          - 10.253.51.28
372
+          hostname: 10.253.51.28
373
+          user: root
374
+        itpve_ines1:
375
+          aliases:
376
+          - itpve-ines1
377
+          - 188.173.0.211
378
+          - 10.253.51.211
379
+          hostname: 10.253.51.211
380
+          user: root
381
+        itpve_ines2:
382
+          aliases:
383
+          - itpve-ines2
384
+          - 188.173.0.212
385
+          - 10.253.51.212
386
+          hostname: 10.253.51.212
387
+          user: root
388
+        itpve_ines3:
389
+          aliases:
390
+          - itpve-ines3
391
+          - 188.173.0.213
392
+          - 10.253.51.213
393
+          hostname: 10.253.51.213
394
+          user: root
395
+        itpve_ines4:
396
+          aliases:
397
+          - itpve-ines4
398
+          - 188.173.0.222
399
+          - 10.253.51.222
400
+          hostname: 10.253.51.222
401
+          user: root
402
+        itpve_bns1:
403
+          aliases:
404
+          - itpve-bns1
405
+          - 188.173.0.201
406
+          - 10.253.51.201
407
+          hostname: 10.253.51.201
408
+          user: root
409
+        itpve_bns2:
410
+          aliases:
411
+          - itpve-bns2
412
+          - 188.173.0.202
413
+          - 10.253.51.202
414
+          hostname: 10.253.51.202
415
+          user: root
416
+        itpve_bns3:
417
+          aliases:
418
+          - itpve-bns3
419
+          - 188.173.0.203
420
+          - 10.253.51.203
421
+          hostname: 10.253.51.203
422
+          user: root
423
+        itpve_bns4:
424
+          aliases:
425
+          - itpve-bns4
426
+          - 188.173.0.220
427
+          - 10.253.51.204
428
+          hostname: 10.253.51.204
429
+          user: root
430
+    backup:
431
+      description: Backup hosts
432
+      default_jump: j1
433
+      hosts:
434
+        backup_bns_01:
435
+          aliases:
436
+          - backup-bns-01
437
+          - 188.173.1.83
438
+          hostname: 188.173.1.83
439
+          user: root
440
+  huawei_olts:
441
+    description: Huawei OLT access equipment with interactive password auth
442
+    default_jump: j1
443
+    defaults:
444
+      user: bogdan.timofte@next-gen.ro
445
+      port: 22
446
+      auth: password_interactive
447
+    hosts:
448
+      pascani_olt:
449
+        aliases:
450
+        - pascani.olt
451
+        hostname: pascani.olt
452
+      radauti_olt:
453
+        aliases:
454
+        - radauti.olt
455
+        - 10.132.96.50
456
+        hostname: radauti.olt
457
+  cisco_routers:
458
+    description: Cisco and similar managed devices with interactive password auth
459
+    default_jump: j1
460
+    defaults:
461
+      user: bogdan.timofte
462
+      port: 22
463
+      auth: password_interactive
464
+    hosts:
465
+      pascani_headend_cr01:
466
+        aliases:
467
+        - pascani.headend.cr01
468
+        - 10.132.0.97
469
+        hostname: pascani.headend.cr01
470
+      buhusi_headend_as01:
471
+        aliases:
472
+        - buhusi.headend.as01
473
+        - 10.132.128.11
474
+        hostname: buhusi.headend.as01
475
+      buhusi_headend_as02:
476
+        aliases:
477
+        - buhusi.headend.as02
478
+        - 10.132.128.12
479
+        hostname: buhusi.headend.as02
480
+      buhusi_headend_as03:
481
+        aliases:
482
+        - buhusi.headend.as03
483
+        - 10.132.128.13
484
+        hostname: buhusi.headend.as03
485
+      buhusi_headend_as04:
486
+        aliases:
487
+        - buhusi.headend.as04
488
+        - 10.132.128.14
489
+        hostname: buhusi.headend.as04
490
+      buhusi_headend_as05:
491
+        aliases:
492
+        - buhusi.headend.as05
493
+        - 10.132.128.15
494
+        hostname: buhusi.headend.as05
495
+      buhusi_headend_dr01:
496
+        aliases:
497
+        - buhusi.headend.dr01
498
+        - 10.132.128.1
499
+        hostname: buhusi.headend.dr01
500
+      buhusi_headend_ds02:
501
+        aliases:
502
+        - buhusi.headend.ds02
503
+        - 10.132.128.5
504
+        hostname: buhusi.headend.ds02
505
+      falticeni_headend_dr01:
506
+        aliases:
507
+        - falticeni.headend.dr01
508
+        - 10.132.64.1
509
+        hostname: falticeni.headend.dr01
510
+      falticeni_headend_ds02:
511
+        aliases:
512
+        - falticeni.headend.ds02
513
+        - 10.132.64.5
514
+        hostname: falticeni.headend.ds02
515
+      falticeni_headend_ds04:
516
+        aliases:
517
+        - falticeni.headend.ds04
518
+        - 10.132.64.7
519
+        hostname: falticeni.headend.ds04
520
+      pascani_headend_as01:
521
+        aliases:
522
+        - pascani.headend.as01
523
+        - 10.132.0.5
524
+        hostname: pascani.headend.as01
525
+      pascani_headend_dr01:
526
+        aliases:
527
+        - pascani.headend.dr01
528
+        - 10.132.0.1
529
+        hostname: pascani.headend.dr01
530
+      pascani_headend_dr02:
531
+        aliases:
532
+        - pascani.headend.dr02
533
+        - 10.132.0.100
534
+        hostname: pascani.headend.dr02
535
+      pascani_headend_dr03:
536
+        aliases:
537
+        - pascani.headend.dr03
538
+        - 10.132.0.99
539
+        hostname: pascani.headend.dr03
540
+      pascani_headend_ds01:
541
+        aliases:
542
+        - pascani.headend.ds01
543
+        - 10.132.0.3
544
+        hostname: pascani.headend.ds01
545
+      pascani_headend_tv01:
546
+        aliases:
547
+        - pascani.headend.tv01
548
+        - 10.132.0.101
549
+        hostname: pascani.headend.tv01
550
+      radauti_headend_as01:
551
+        aliases:
552
+        - radauti.headend.as01
553
+        - 10.132.96.11
554
+        hostname: radauti.headend.as01
555
+      radauti_headend_dr01:
556
+        aliases:
557
+        - radauti.headend.dr01
558
+        - 172.30.255.101
559
+        hostname: radauti.headend.dr01
560
+      tg_frumos_headend_as01:
561
+        aliases:
562
+        - tg_frumos.headend.as01
563
+        - 10.132.32.11
564
+        hostname: tg_frumos.headend.as01
565
+      tg_frumos_headend_dr01:
566
+        aliases:
567
+        - tg_frumos.headend.dr01
568
+        - 10.132.32.1
569
+        hostname: tg_frumos.headend.dr01
570
+      tg_frumos_headend_ds01:
571
+        aliases:
572
+        - tg_frumos.headend.ds01
573
+        - 10.132.32.3
574
+        hostname: tg_frumos.headend.ds01
575
+  network_switches:
576
+    description: DCN switches with interactive password auth
577
+    default_jump: j1
578
+    defaults:
579
+      user: bogdan.timofte
580
+      port: 22
581
+      auth: password_interactive
582
+    hosts:
583
+      buhusi_psw_010:
584
+        aliases:
585
+        - buhusi-psw-010
586
+        - 10.132.128.20
587
+        hostname: buhusi-psw-010
588
+      buhusi_psw_011:
589
+        aliases:
590
+        - buhusi-psw-011
591
+        - 10.132.128.21
592
+        hostname: buhusi-psw-011
593
+      buhusi_psw_012:
594
+        aliases:
595
+        - buhusi-psw-012
596
+        - 10.132.128.22
597
+        hostname: buhusi-psw-012
598
+      buhusi_psw_013:
599
+        aliases:
600
+        - buhusi-psw-013
601
+        - 10.132.128.23
602
+        hostname: buhusi-psw-013
603
+      buhusi_psw_014:
604
+        aliases:
605
+        - buhusi-psw-014
606
+        - 10.132.128.24
607
+        hostname: buhusi-psw-014
608
+      buhusi_silistea_psw_001:
609
+        aliases:
610
+        - buhusi.silistea.psw-001
611
+        - 10.132.128.50
612
+        hostname: buhusi.silistea.psw-001
613
+      falticeni_psw_110:
614
+        aliases:
615
+        - falticeni-psw-110
616
+        - 10.132.64.20
617
+        hostname: falticeni-psw-110
618
+      radauti_headend_ag001:
619
+        aliases:
620
+        - radauti.headend.ag001
621
+        - 10.132.96.12
622
+        hostname: radauti.headend.ag001
623
+  mikrotik_routers:
624
+    description: MikroTik CRS/CCR equipment with interactive password auth
625
+    default_jump: j1
626
+    defaults:
627
+      user: admin
628
+      port: 24
629
+      auth: password_interactive
630
+    hosts:
631
+      buhusi_mikrotik_dr01:
632
+        aliases:
633
+        - buhusi.mikrotik.dr01
634
+        - 10.132.128.110
635
+        hostname: buhusi.mikrotik.dr01
636
+      buhusi_mikrotik_ds01:
637
+        aliases:
638
+        - buhusi.mikrotik.ds01
639
+        - 10.132.128.100
640
+        hostname: buhusi.mikrotik.ds01
641
+      buhusi_mikrotik_pppoe01:
642
+        aliases:
643
+        - buhusi.mikrotik.pppoe01
644
+        - 10.132.128.111
645
+        hostname: buhusi.mikrotik.pppoe01
646
+      buhusi_mikrotik_pppoe02:
647
+        aliases:
648
+        - buhusi.mikrotik.pppoe02
649
+        - 10.132.128.112
650
+        hostname: buhusi.mikrotik.pppoe02
651
+      falticeni_mikrotik_dr01:
652
+        aliases:
653
+        - falticeni.mikrotik.dr01
654
+        - 10.132.64.110
655
+        hostname: falticeni.mikrotik.dr01
656
+      falticeni_mikrotik_ds01:
657
+        aliases:
658
+        - falticeni.mikrotik.ds01
659
+        - 10.132.64.100
660
+        hostname: falticeni.mikrotik.ds01
661
+      falticeni_mikrotik_pppoe1:
662
+        aliases:
663
+        - falticeni.mikrotik.pppoe1
664
+        - 10.132.64.111
665
+        hostname: falticeni.mikrotik.pppoe1
666
+      falticeni_mikrotik_pppoe2:
667
+        aliases:
668
+        - falticeni.mikrotik.pppoe2
669
+        - 10.132.64.112
670
+        hostname: falticeni.mikrotik.pppoe2
671
+      pascani_mikrotik_pppoe1:
672
+        aliases:
673
+        - pascani.mikrotik.pppoe1
674
+        - 10.132.0.111
675
+        hostname: pascani.mikrotik.pppoe1
676
+      pascani_mikrotik_pppoe2:
677
+        aliases:
678
+        - pascani.mikrotik.pppoe2
679
+        - 10.132.0.112
680
+        hostname: pascani.mikrotik.pppoe2
681
+      radauti_mikrotik_pppoe1:
682
+        aliases:
683
+        - radauti.mikrotik.pppoe1
684
+        - 10.132.96.111
685
+        hostname: radauti.mikrotik.pppoe1
686
+      radauti_mikrotik_pppoe2:
687
+        aliases:
688
+        - radauti.mikrotik.pppoe2
689
+        - 10.132.96.112
690
+        hostname: radauti.mikrotik.pppoe2
691
+      tg_frumos_mikrotik_dr01:
692
+        aliases:
693
+        - tg_frumos.mikrotik.dr01
694
+        - 94.53.170.1
695
+        hostname: tg_frumos.mikrotik.dr01
696
+      tg_frumos_mikrotik_pppoe1:
697
+        aliases:
698
+        - tg_frumos.mikrotik.pppoe1
699
+        - 10.132.32.111
700
+        hostname: tg_frumos.mikrotik.pppoe1
+159 -0
schema/hosts.schema.json
@@ -0,0 +1,159 @@
1
+{
2
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+  "$id": "https://next-gen.local/ssh-infrastructure/hosts.schema.json",
4
+  "title": "SSH infrastructure inventory",
5
+  "type": "object",
6
+  "required": ["version", "defaults", "entrypoints", "jumps", "groups"],
7
+  "additionalProperties": false,
8
+  "properties": {
9
+    "version": { "const": 1 },
10
+    "facts": { "type": "object" },
11
+    "ssh_options": {
12
+      "type": "object",
13
+      "additionalProperties": {
14
+        "type": "object",
15
+        "required": ["options"],
16
+        "additionalProperties": false,
17
+        "properties": {
18
+          "description": { "type": "string" },
19
+          "options": {
20
+            "type": "object",
21
+            "additionalProperties": {
22
+              "anyOf": [
23
+                { "type": "string" },
24
+                { "type": "integer" },
25
+                { "type": "boolean" }
26
+              ]
27
+            }
28
+          }
29
+        }
30
+      }
31
+    },
32
+    "company_managed": {
33
+      "type": "object",
34
+      "additionalProperties": false,
35
+      "properties": {
36
+        "jump_hosts": {
37
+          "type": "object",
38
+          "additionalProperties": false,
39
+          "properties": {
40
+            "inherit_globals_on_targets": {
41
+              "type": "array",
42
+              "items": { "type": "string" }
43
+            },
44
+            "match_defaults": {
45
+              "type": "array",
46
+              "items": { "$ref": "#/$defs/companyManagedMatch" }
47
+            }
48
+          }
49
+        }
50
+      }
51
+    },
52
+    "defaults": {
53
+      "type": "object",
54
+      "required": ["jump", "final_host"],
55
+      "additionalProperties": false,
56
+      "properties": {
57
+        "jump": { "$ref": "#/$defs/defaults" },
58
+        "final_host": { "$ref": "#/$defs/defaults" }
59
+      }
60
+    },
61
+    "entrypoints": {
62
+      "type": "object",
63
+      "additionalProperties": { "$ref": "#/$defs/host" }
64
+    },
65
+    "jumps": {
66
+      "type": "object",
67
+      "additionalProperties": {
68
+        "allOf": [
69
+          { "$ref": "#/$defs/host" },
70
+          {
71
+            "type": "object",
72
+            "properties": {
73
+              "role": { "type": "string" }
74
+            }
75
+          }
76
+        ]
77
+      }
78
+    },
79
+    "groups": {
80
+      "type": "object",
81
+      "additionalProperties": {
82
+        "type": "object",
83
+        "required": ["hosts"],
84
+        "additionalProperties": false,
85
+        "properties": {
86
+          "description": { "type": "string" },
87
+          "default_jump": { "type": "string" },
88
+          "defaults": { "$ref": "#/$defs/defaults" },
89
+          "patterns": {
90
+            "type": "object",
91
+            "additionalProperties": { "$ref": "#/$defs/options" }
92
+          },
93
+          "hosts": {
94
+            "type": "object",
95
+            "additionalProperties": { "$ref": "#/$defs/host" }
96
+          }
97
+        }
98
+      }
99
+    }
100
+  },
101
+  "$defs": {
102
+    "defaults": {
103
+      "type": "object",
104
+      "additionalProperties": false,
105
+      "properties": {
106
+        "user": { "type": "string" },
107
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
108
+        "connect_timeout": { "type": "integer", "minimum": 1 },
109
+        "connection_attempts": { "type": "integer", "minimum": 1 },
110
+        "auth": { "$ref": "#/$defs/auth" }
111
+      }
112
+    },
113
+    "options": {
114
+      "type": "object",
115
+      "additionalProperties": false,
116
+      "properties": {
117
+        "connect_timeout": { "type": "integer", "minimum": 1 },
118
+        "connection_attempts": { "type": "integer", "minimum": 1 }
119
+      }
120
+    },
121
+    "companyManagedMatch": {
122
+      "type": "object",
123
+      "required": ["patterns"],
124
+      "additionalProperties": false,
125
+      "properties": {
126
+        "patterns": {
127
+          "type": "array",
128
+          "minItems": 1,
129
+          "items": { "type": "string" }
130
+        },
131
+        "user": { "type": "string" },
132
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 }
133
+      }
134
+    },
135
+    "host": {
136
+      "type": "object",
137
+      "required": ["aliases", "hostname"],
138
+      "additionalProperties": false,
139
+      "properties": {
140
+        "aliases": {
141
+          "type": "array",
142
+          "minItems": 1,
143
+          "items": { "type": "string" }
144
+        },
145
+        "hostname": { "type": "string" },
146
+        "user": { "type": "string" },
147
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
148
+        "identity_file": { "type": "string" },
149
+        "identities_only": { "type": "boolean" },
150
+        "role": { "type": "string" },
151
+        "auth": { "$ref": "#/$defs/auth" }
152
+      }
153
+    },
154
+    "auth": {
155
+      "type": "string",
156
+      "enum": ["key", "password_interactive"]
157
+    }
158
+  }
159
+}
+2 -0
scripts/scp-wrapper.sh
@@ -0,0 +1,2 @@
1
+#!/usr/bin/env bash
2
+exec /usr/bin/scp -S ~/.local/bin/ssh "$@"
+2 -0
scripts/sftp-wrapper.sh
@@ -0,0 +1,2 @@
1
+#!/usr/bin/env bash
2
+exec /usr/bin/sftp -S ~/.local/bin/ssh "$@"
+288 -0
scripts/ssh-wrapper.sh
@@ -0,0 +1,288 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+real_ssh="/usr/bin/ssh"
5
+ssh_config="$HOME/.ssh/config"
6
+remote_agent="/run/user/0/gnupg/S.gpg-agent.ssh"
7
+
8
+# Access model, May 2026
9
+# ----------------------
10
+#
11
+# Cheia fizica este montata pe is-jumper. Wrapper-ul local nu mai face bridge
12
+# de agent in /tmp; in schimb ruleaza clientul SSH activ pe is-jumper:
13
+#
14
+#     local ssh wrapper
15
+#       -> is-jumper (192.168.2.100, acces local cu id_ed25519)
16
+#       -> J1/J2/j1/j2 (autentificare cu agentul fizic de pe is-jumper)
17
+#       -> ssh final-host
18
+#
19
+# Custom jump flags (stripped by wrapper, not passed to real ssh):
20
+#   -J1  use J1 via VPN (default for configured hosts)
21
+#   -J2  use J2 via VPN
22
+#   -j1  use j1 via public DNS (urgente, fara ruta VPN)
23
+#   -j2  use j2 via public DNS (urgente, fara ruta VPN)
24
+#
25
+# Hosturi arbitrare (fara config SSH): wrapper-ul le ruteza prin jump doar daca
26
+# unul dintre flagurile de mai sus este prezent explicit.
27
+
28
+target_user=""
29
+target_host=""
30
+target_port=""
31
+target_auth=""
32
+found_target=0
33
+cmd_args=()
34
+ssh_config_args=()
35
+jump_alias="j1"
36
+custom_jump_set=0
37
+host_configured=1
38
+target_is_jump=0
39
+want_subsystem=0
40
+user_option=""
41
+port_option=""
42
+
43
+has_explicit_config() {
44
+    local arg
45
+
46
+    for arg in "$@"; do
47
+        case "$arg" in
48
+            -F|-F*)
49
+                return 0
50
+                ;;
51
+        esac
52
+    done
53
+
54
+    return 1
55
+}
56
+
57
+quote_cmd() {
58
+    local out="" q part
59
+
60
+    for part in "$@"; do
61
+        printf -v q "%q" "$part"
62
+        out+="$q "
63
+    done
64
+
65
+    printf "%s" "$out"
66
+}
67
+
68
+resolve_ssh_config() {
69
+    local target=$1
70
+    local line
71
+
72
+    target_user=""
73
+    target_host=""
74
+    target_port=""
75
+    target_auth=""
76
+
77
+    while IFS= read -r line; do
78
+        case "$line" in
79
+            user\ *)     target_user=${line#user } ;;
80
+            hostname\ *) target_host=${line#hostname } ;;
81
+            port\ *)     target_port=${line#port } ;;
82
+            setenv\ *NG_SSH_AUTH=password-interactive*) target_auth="password_interactive" ;;
83
+        esac
84
+    done < <("$real_ssh" ${ssh_config_args[@]+"${ssh_config_args[@]}"} -G "$target" 2>/dev/null)
85
+
86
+    [[ -n "$target_user" && -n "$target_host" && -n "$target_port" ]]
87
+}
88
+
89
+run_real_ssh() {
90
+    exec "$real_ssh" ${ssh_config_args[@]+"${ssh_config_args[@]}"} "$@"
91
+}
92
+
93
+resolve_target_from_config() {
94
+    local target=$1
95
+    local default_user=${USER:-${LOGNAME:-}}
96
+    local user_override=""
97
+
98
+    case "$target" in
99
+        *@*)
100
+            user_override=${target%@*}
101
+            target=${target#*@}
102
+            ;;
103
+    esac
104
+
105
+    case "$target" in
106
+        is-jumper|192.168.2.100)
107
+            return 1
108
+            ;;
109
+        J1|J2|j1|j2)
110
+            target_is_jump=1
111
+            ;;
112
+    esac
113
+
114
+    resolve_ssh_config "$target" || return 1
115
+
116
+    if [[ -n "$user_override" ]]; then
117
+        target_user=$user_override
118
+    fi
119
+    if [[ -n "$user_option" ]]; then
120
+        target_user=$user_option
121
+    fi
122
+    if [[ -n "$port_option" ]]; then
123
+        target_port=$port_option
124
+    fi
125
+
126
+    # Unconfigured host (ssh -G returns defaults): bypass unless a custom jump
127
+    # was requested explicitly.
128
+    if [[ "$target_is_jump" -eq 0 && "$target_host" == "$target" && "$target_port" == "22" && "$target_user" == "$default_user" ]]; then
129
+        [[ $custom_jump_set -eq 0 ]] && return 1
130
+        host_configured=0
131
+    fi
132
+
133
+    [[ -n "$target_user" && -n "$target_host" && -n "$target_port" ]]
134
+}
135
+
136
+resolve_jump() {
137
+    local saved_user saved_host saved_port saved_auth
138
+
139
+    saved_user=$target_user
140
+    saved_host=$target_host
141
+    saved_port=$target_port
142
+    saved_auth=$target_auth
143
+
144
+    resolve_ssh_config "$jump_alias" || {
145
+        printf "ssh-wrapper: cannot resolve jump alias %s\n" "$jump_alias" >&2
146
+        exit 255
147
+    }
148
+
149
+    jump_user=$target_user
150
+    jump_host=$target_host
151
+    jump_port=$target_port
152
+
153
+    target_user=$saved_user
154
+    target_host=$saved_host
155
+    target_port=$saved_port
156
+    target_auth=$saved_auth
157
+}
158
+
159
+# Pre-process: extract custom jump flags and strip them from args.
160
+filtered_args=()
161
+for arg in "$@"; do
162
+    case "$arg" in
163
+        -J1) jump_alias="j1"; custom_jump_set=1 ;;
164
+        -J2) jump_alias="j2"; custom_jump_set=1 ;;
165
+        -j1) jump_alias="j1"; custom_jump_set=1 ;;
166
+        -j2) jump_alias="j2"; custom_jump_set=1 ;;
167
+        *) filtered_args+=("$arg") ;;
168
+    esac
169
+done
170
+set -- "${filtered_args[@]+"${filtered_args[@]}"}"
171
+
172
+if [[ -f "$ssh_config" ]] && ! has_explicit_config "$@"; then
173
+    ssh_config_args=(-F "$ssh_config")
174
+fi
175
+
176
+skip_next=0
177
+capture_user=0
178
+capture_port=0
179
+after_double_dash=0
180
+
181
+for arg in "$@"; do
182
+    if [[ $found_target -eq 1 ]]; then
183
+        cmd_args+=("$arg")
184
+        continue
185
+    fi
186
+
187
+    if [[ $capture_user -eq 1 ]]; then
188
+        user_option=$arg
189
+        capture_user=0
190
+        continue
191
+    fi
192
+
193
+    if [[ $capture_port -eq 1 ]]; then
194
+        port_option=$arg
195
+        capture_port=0
196
+        continue
197
+    fi
198
+
199
+    if [[ $skip_next -eq 1 ]]; then
200
+        skip_next=0
201
+        continue
202
+    fi
203
+
204
+    case "$arg" in
205
+        -G|-Q|-V|-h|--help)
206
+            run_real_ssh "$@"
207
+            ;;
208
+        --)
209
+            after_double_dash=1
210
+            continue
211
+            ;;
212
+    esac
213
+
214
+    if [[ $after_double_dash -eq 0 ]]; then
215
+        case "$arg" in
216
+            -s)
217
+                want_subsystem=1
218
+                continue
219
+                ;;
220
+            -l)
221
+                capture_user=1
222
+                continue
223
+                ;;
224
+            -l*)
225
+                user_option=${arg#-l}
226
+                continue
227
+                ;;
228
+            -p)
229
+                capture_port=1
230
+                continue
231
+                ;;
232
+            -p*)
233
+                port_option=${arg#-p}
234
+                continue
235
+                ;;
236
+            -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-m|-O|-o|-Q|-R|-S|-W|-w)
237
+                skip_next=1
238
+                continue
239
+                ;;
240
+            -b*|-c*|-D*|-E*|-e*|-F*|-I*|-i*|-J*|-L*|-m*|-O*|-o*|-Q*|-R*|-S*|-W*|-w*)
241
+                continue
242
+                ;;
243
+            -*)
244
+                continue
245
+                ;;
246
+        esac
247
+    fi
248
+
249
+    if ! resolve_target_from_config "$arg"; then
250
+        run_real_ssh "$@"
251
+    fi
252
+
253
+    found_target=1
254
+done
255
+
256
+if [[ $found_target -eq 0 ]]; then
257
+    run_real_ssh "$@"
258
+fi
259
+
260
+tty_flag="-tt"
261
+if [[ ${#cmd_args[@]} -gt 0 || $want_subsystem -eq 1 ]]; then
262
+    tty_flag="-T"
263
+fi
264
+
265
+if [[ $target_is_jump -eq 1 ]]; then
266
+    jump_cmd=(ssh "$tty_flag" -A -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p "$target_port" "$target_user@$target_host")
267
+    jump_cmd+=(${cmd_args[@]+"${cmd_args[@]}"})
268
+else
269
+    resolve_jump
270
+
271
+    final_cmd=(ssh "$tty_flag" -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o ProxyJump=none -o ProxyCommand=none)
272
+    if [[ "$target_auth" == "password_interactive" ]]; then
273
+        final_cmd+=( -o BatchMode=no -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no )
274
+    elif [[ $host_configured -eq 1 ]]; then
275
+        final_cmd+=( -o BatchMode=yes )
276
+    fi
277
+    if [[ $want_subsystem -eq 1 ]]; then
278
+        final_cmd+=( -s )
279
+    fi
280
+    final_cmd+=( -p "$target_port" "$target_user@$target_host" )
281
+    final_cmd+=(${cmd_args[@]+"${cmd_args[@]}"})
282
+
283
+    jump_cmd=(ssh "$tty_flag" -A -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p "$jump_port" "$jump_user@$jump_host" "$(quote_cmd "${final_cmd[@]}")")
284
+fi
285
+
286
+is_jumper_cmd="SSH_AUTH_SOCK=$remote_agent exec $(quote_cmd "${jump_cmd[@]}")"
287
+
288
+exec "$real_ssh" ${ssh_config_args[@]+"${ssh_config_args[@]}"} "$tty_flag" -o BatchMode=yes -o ConnectTimeout=10 is-jumper "$is_jumper_cmd"
+23 -0
tools/deploy-local.sh
@@ -0,0 +1,23 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+project_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
5
+ssh_root=${SSH_ROOT:-"$HOME/.ssh"}
6
+local_bin_root=${LOCAL_BIN_ROOT:-"$HOME/.local/bin"}
7
+
8
+install -d -m 700 "$ssh_root"
9
+install -d -m 700 "$local_bin_root"
10
+
11
+"$project_root/tools/generate-configs.py"
12
+
13
+install -m 600 "$project_root/generated/client.conf" "$ssh_root/config"
14
+
15
+rm -f "$local_bin_root/ssh" "$local_bin_root/scp" "$local_bin_root/sftp"
16
+install -m 700 "$project_root/scripts/ssh-wrapper.sh" "$local_bin_root/ssh"
17
+install -m 755 "$project_root/scripts/scp-wrapper.sh" "$local_bin_root/scp"
18
+install -m 755 "$project_root/scripts/sftp-wrapper.sh" "$local_bin_root/sftp"
19
+
20
+rm -f "$ssh_root/scripts/ssh-wrapper.sh" "$ssh_root/scripts/scp-wrapper.sh" "$ssh_root/scripts/sftp-wrapper.sh"
21
+rmdir --ignore-fail-on-non-empty "$ssh_root/scripts" 2>/dev/null || true
22
+
23
+printf 'deployed SSH config to %s and wrappers to %s\n' "$ssh_root" "$local_bin_root"
+246 -0
tools/generate-configs.py
@@ -0,0 +1,246 @@
1
+#!/usr/bin/env python3
2
+import argparse
3
+import fnmatch
4
+from pathlib import Path
5
+
6
+import yaml
7
+
8
+
9
+ROOT = Path(__file__).resolve().parents[1]
10
+
11
+
12
+def load_inventory(path: Path) -> dict:
13
+    with path.open("r", encoding="utf-8") as handle:
14
+        data = yaml.safe_load(handle)
15
+    if data.get("version") != 1:
16
+        raise SystemExit("unsupported inventory version")
17
+    return data
18
+
19
+
20
+def fmt_bool(value: bool) -> str:
21
+    return "yes" if value else "no"
22
+
23
+
24
+def fmt_option(value) -> str:
25
+    if isinstance(value, bool):
26
+        return fmt_bool(value)
27
+    return str(value)
28
+
29
+
30
+def aliases_match_rule(aliases, rule):
31
+    patterns = rule.get("patterns", [])
32
+    if not patterns:
33
+        return False
34
+    return all(any(fnmatch.fnmatch(str(alias), pattern) for pattern in patterns) for alias in aliases)
35
+
36
+
37
+def company_managed_rule(data, target, aliases, user, port):
38
+    managed = data.get("company_managed", {}).get("jump_hosts", {})
39
+    if target not in managed.get("inherit_globals_on_targets", []):
40
+        return None
41
+
42
+    for rule in managed.get("match_defaults", []):
43
+        if rule.get("user") != user or rule.get("port") != port:
44
+            continue
45
+        if aliases_match_rule(aliases, rule):
46
+            return rule
47
+    return None
48
+
49
+
50
+def aliases_for_host(host):
51
+    aliases = [str(alias) for alias in host["aliases"]]
52
+    if host["hostname"] not in aliases:
53
+        aliases.append(host["hostname"])
54
+    return aliases
55
+
56
+
57
+def host_block(aliases, hostname, user=None, port=None, extra=None):
58
+    lines = [f"Host {' '.join(str(alias) for alias in aliases)}", f"    HostName {hostname}"]
59
+    if user:
60
+        lines.append(f"    User {user}")
61
+    if port:
62
+        lines.append(f"    Port {port}")
63
+    auth = (extra or {}).pop("auth", None)
64
+    if auth == "password_interactive":
65
+        lines.append("    SetEnv NG_SSH_AUTH=password-interactive")
66
+        lines.append("    BatchMode no")
67
+        lines.append("    PreferredAuthentications keyboard-interactive,password")
68
+        lines.append("    PubkeyAuthentication no")
69
+    for key, value in (extra or {}).items():
70
+        lines.append(f"    {key} {value}")
71
+    lines.append("")
72
+    return lines
73
+
74
+
75
+def pattern_block(pattern, options):
76
+    lines = [f"Host {pattern}"]
77
+    if "connect_timeout" in options:
78
+        lines.append(f"    ConnectTimeout {options['connect_timeout']}")
79
+    if "connection_attempts" in options:
80
+        lines.append(f"    ConnectionAttempts {options['connection_attempts']}")
81
+    lines.append("")
82
+    return lines
83
+
84
+
85
+def generated_header(target, include_comments=True):
86
+    if not include_comments:
87
+        return []
88
+    return [
89
+        "# Generated by tools/generate-configs.py.",
90
+        "# Do not edit this file directly; edit inventory/hosts.yaml.",
91
+        f"# Target: {target}",
92
+        "",
93
+    ]
94
+
95
+
96
+def emit_global_options(data, include_comments=True):
97
+    blocks = data.get("ssh_options", {})
98
+    if not blocks:
99
+        return []
100
+
101
+    lines = []
102
+    if include_comments:
103
+        lines.extend(["# Global SSH compatibility options", ""])
104
+    for name, block in blocks.items():
105
+        if include_comments:
106
+            lines.append(f"# {name}: {block.get('description', '')}")
107
+        lines.append("Host *")
108
+        for key, value in block.get("options", {}).items():
109
+            lines.append(f"    {key} {fmt_option(value)}")
110
+        lines.append("")
111
+    return lines
112
+
113
+
114
+def inherit_globals(data, target):
115
+    managed = data.get("company_managed", {}).get("jump_hosts", {})
116
+    return target in managed.get("inherit_globals_on_targets", [])
117
+
118
+
119
+def merged(defaults, group_defaults, host):
120
+    result = dict(defaults)
121
+    result.update(group_defaults or {})
122
+    result.update(host)
123
+    return result
124
+
125
+
126
+def host_differs_from_defaults(host, defaults):
127
+    for key in ("user", "port", "auth"):
128
+        if key in host and host[key] != defaults.get(key):
129
+            return True
130
+    return False
131
+
132
+
133
+def should_emit_host_on_target(data, target, group_defaults, host):
134
+    if target not in ("j1", "j2"):
135
+        return True
136
+
137
+    baseline = merged(data["defaults"]["final_host"], group_defaults, {})
138
+    return host_differs_from_defaults(host, baseline)
139
+
140
+
141
+def emit_entrypoints(data, include_comments=True):
142
+    lines = ["# Entrypoints", ""] if include_comments else []
143
+    for host in data["entrypoints"].values():
144
+        extra = {}
145
+        if host.get("identity_file"):
146
+            extra["IdentityFile"] = host["identity_file"]
147
+        if "identities_only" in host:
148
+            extra["IdentitiesOnly"] = fmt_bool(host["identities_only"])
149
+        lines.extend(host_block(aliases_for_host(host), host["hostname"], host.get("user"), host.get("port"), extra))
150
+    return lines
151
+
152
+
153
+def emit_jumps(data, include_comments=True):
154
+    lines = ["# Jump hosts", ""] if include_comments else []
155
+    defaults = data["defaults"]["jump"]
156
+    for jump in data["jumps"].values():
157
+        item = merged(defaults, {}, jump)
158
+        lines.extend(host_block(aliases_for_host(item), item["hostname"], item.get("user"), item.get("port")))
159
+    return lines
160
+
161
+
162
+def emit_hosts_for_group(data, group, target, defaults):
163
+    group_defaults = group.get("defaults", {})
164
+    lines = []
165
+    for host in group.get("hosts", {}).values():
166
+        if not should_emit_host_on_target(data, target, group_defaults, host):
167
+            continue
168
+        item = merged(defaults, group_defaults, host)
169
+        aliases = aliases_for_host(item)
170
+        extra = {}
171
+        if item.get("auth"):
172
+            extra["auth"] = item["auth"]
173
+        user = item.get("user")
174
+        port = item.get("port")
175
+        if company_managed_rule(data, target, aliases, user, port):
176
+            user = None
177
+            port = None
178
+        lines.extend(host_block(aliases, item["hostname"], user, port, extra))
179
+    for pattern, options in group.get("patterns", {}).items():
180
+        lines.extend(pattern_block(pattern, options))
181
+    return lines
182
+
183
+
184
+def emit_groups(data, target=None, include_comments=True):
185
+    lines = []
186
+    defaults = data["defaults"]["final_host"]
187
+    metadata_keys = {"description", "default_jump", "defaults", "patterns", "hosts"}
188
+    queue = [(name, group) for name, group in data["groups"].items()]
189
+
190
+    while queue:
191
+        group_name, group = queue.pop(0)
192
+        group_lines = emit_hosts_for_group(data, group, target, defaults)
193
+        if group_lines:
194
+            if include_comments:
195
+                lines.extend([f"# Group: {group_name}", f"# Description: {group.get('description', '')}", ""])
196
+            lines.extend(group_lines)
197
+
198
+        for child_name, child in group.items():
199
+            if child_name in metadata_keys or not isinstance(child, dict):
200
+                continue
201
+            if "hosts" in child:
202
+                queue.append((f"{group_name}.{child_name}", child))
203
+    return lines
204
+
205
+
206
+def write(path: Path, lines):
207
+    path.parent.mkdir(parents=True, exist_ok=True)
208
+    path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
209
+
210
+
211
+def generate(data, output_dir: Path):
212
+    final_groups = emit_groups(data)
213
+
214
+    client = generated_header("client")
215
+    client.extend(emit_global_options(data))
216
+    client.extend(emit_entrypoints(data))
217
+    client.extend(emit_jumps(data))
218
+    client.extend(final_groups)
219
+    write(output_dir / "client.conf", client)
220
+
221
+    is_jumper = generated_header("is-jumper")
222
+    is_jumper.extend(emit_global_options(data))
223
+    is_jumper.extend(emit_jumps(data))
224
+    write(output_dir / "is-jumper.conf", is_jumper)
225
+
226
+    for target in ("j1", "j2"):
227
+        lines = generated_header(target, include_comments=False)
228
+        if inherit_globals(data, target):
229
+            pass
230
+        else:
231
+            lines.extend(emit_global_options(data, include_comments=False))
232
+        lines.extend(emit_groups(data, target, include_comments=False))
233
+        write(output_dir / f"{target}.conf", lines)
234
+
235
+
236
+def main():
237
+    parser = argparse.ArgumentParser()
238
+    parser.add_argument("--inventory", default=ROOT / "inventory" / "hosts.yaml", type=Path)
239
+    parser.add_argument("--output-dir", default=ROOT / "generated", type=Path)
240
+    args = parser.parse_args()
241
+
242
+    generate(load_inventory(args.inventory), args.output_dir)
243
+
244
+
245
+if __name__ == "__main__":
246
+    main()
+125 -0
tools/sync-hosts-from-upstream.sh
@@ -0,0 +1,125 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+project_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
5
+
6
+inventory_path=${INVENTORY_PATH:-"$project_root/inventory/hosts.yaml"}
7
+upstream_hosts_file=${UPSTREAM_HOSTS_FILE:-}
8
+upstream_ssh_target=${UPSTREAM_SSH_TARGET:-nextgen@192.168.2.103}
9
+upstream_hosts_path=${UPSTREAM_HOSTS_PATH:-/home/nextgen/projects/ssh-infrastructure/inventory/hosts.yaml}
10
+local_is_jumper_identity_file=${LOCAL_IS_JUMPER_IDENTITY_FILE:-}
11
+if [[ -z "$local_is_jumper_identity_file" ]]; then
12
+    local_is_jumper_identity_file='~/.ssh/keys/is-jumper_ed25519'
13
+fi
14
+deploy_after_sync=${DEPLOY_AFTER_SYNC:-1}
15
+force_deploy=${FORCE_DEPLOY:-0}
16
+
17
+tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/ssh-infra-sync.XXXXXX")
18
+trap 'rm -rf "$tmpdir"' EXIT
19
+
20
+tmp_hosts="$tmpdir/hosts.yaml"
21
+tmp_generated="$tmpdir/generated"
22
+
23
+if [[ -n "$upstream_hosts_file" ]]; then
24
+    cp "$upstream_hosts_file" "$tmp_hosts"
25
+else
26
+    /usr/bin/scp -q "${upstream_ssh_target}:${upstream_hosts_path}" "$tmp_hosts"
27
+fi
28
+
29
+python3 - "$tmp_hosts" "$local_is_jumper_identity_file" <<'PY'
30
+from pathlib import Path
31
+import sys
32
+
33
+import yaml
34
+
35
+path = Path(sys.argv[1])
36
+identity_file = sys.argv[2]
37
+text = path.read_text(encoding="utf-8")
38
+
39
+with path.open("r", encoding="utf-8") as handle:
40
+    data = yaml.safe_load(handle)
41
+
42
+is_jumper = data.get("entrypoints", {}).get("is_jumper", {})
43
+if (
44
+    is_jumper.get("identity_file") == identity_file
45
+    and is_jumper.get("identities_only") is True
46
+):
47
+    raise SystemExit(0)
48
+
49
+lines = text.splitlines(keepends=True)
50
+
51
+entrypoints_idx = None
52
+for idx, line in enumerate(lines):
53
+    if line.strip() == "entrypoints:" and not line.startswith((" ", "\t")):
54
+        entrypoints_idx = idx
55
+        break
56
+if entrypoints_idx is None:
57
+    raise SystemExit("missing entrypoints section")
58
+
59
+is_jumper_idx = None
60
+for idx in range(entrypoints_idx + 1, len(lines)):
61
+    line = lines[idx]
62
+    if line and not line.startswith((" ", "\t")) and line.strip():
63
+        break
64
+    if line.startswith("  is_jumper:"):
65
+        is_jumper_idx = idx
66
+        break
67
+if is_jumper_idx is None:
68
+    raise SystemExit("missing entrypoints.is_jumper section")
69
+
70
+block_end = len(lines)
71
+for idx in range(is_jumper_idx + 1, len(lines)):
72
+    line = lines[idx]
73
+    if line.strip() and not line.startswith("    "):
74
+        block_end = idx
75
+        break
76
+
77
+identity_line = f"    identity_file: {identity_file}\n"
78
+identities_only_line = "    identities_only: true\n"
79
+identity_idx = None
80
+identities_only_idx = None
81
+for idx in range(is_jumper_idx + 1, block_end):
82
+    stripped = lines[idx].strip()
83
+    if stripped.startswith("identity_file:"):
84
+        identity_idx = idx
85
+    elif stripped.startswith("identities_only:"):
86
+        identities_only_idx = idx
87
+
88
+if identity_idx is None:
89
+    insert_at = identities_only_idx if identities_only_idx is not None else block_end
90
+    lines.insert(insert_at, identity_line)
91
+    if identities_only_idx is not None:
92
+        identities_only_idx += 1
93
+        block_end += 1
94
+else:
95
+    lines[identity_idx] = identity_line
96
+
97
+if identities_only_idx is None:
98
+    insert_at = (identity_idx + 1) if identity_idx is not None else block_end
99
+    lines.insert(insert_at, identities_only_line)
100
+else:
101
+    lines[identities_only_idx] = identities_only_line
102
+
103
+path.write_text("".join(lines), encoding="utf-8")
104
+PY
105
+
106
+python3 "$project_root/tools/generate-configs.py" \
107
+    --inventory "$tmp_hosts" \
108
+    --output-dir "$tmp_generated"
109
+
110
+if cmp -s "$tmp_hosts" "$inventory_path"; then
111
+    printf 'inventory unchanged: %s\n' "$inventory_path"
112
+    if [[ "$deploy_after_sync" == "1" && "$force_deploy" == "1" ]]; then
113
+        "$project_root/tools/deploy-local.sh"
114
+    fi
115
+    exit 0
116
+fi
117
+
118
+install -m 644 "$tmp_hosts" "$inventory_path"
119
+printf 'updated inventory from upstream: %s\n' "$inventory_path"
120
+
121
+if [[ "$deploy_after_sync" == "1" ]]; then
122
+    "$project_root/tools/deploy-local.sh"
123
+else
124
+    printf 'skipped deploy because DEPLOY_AFTER_SYNC=%s\n' "$deploy_after_sync"
125
+fi