network-infra-utility 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +70 -0
  5. data/Rakefile +1 -1
  6. data/bin/dns-query +834 -0
  7. data/bin/geo-doc +135 -0
  8. data/bin/geo-get +1 -1
  9. data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
  10. data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
  11. data/document/Geo/345/221/275/344/273/244/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +441 -0
  12. data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
  13. data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
  14. data/document/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/344/275/277/347/224/250/346/226/271/346/263/225.md +764 -0
  15. data/network-infra-utility.gemspec +4 -2
  16. data/network.rb +3 -1
  17. data/service/geodb/geodb.rb +278 -1
  18. data/service/ssh/README.md +942 -0
  19. data/service/ssh/bin/ssh-client +198 -0
  20. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/345/212/237/350/203/275/351/234/200/346/261/202/346/226/207/346/241/243.md +292 -0
  21. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/257/246/347/273/206/350/256/276/350/256/241/346/226/207/346/241/243.md +1521 -0
  22. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/275/257/344/273/266/350/256/276/350/256/241/346/226/207/346/241/243.md +2493 -0
  23. data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
  24. data/service/ssh/ext/ssh_core/config/sys.config +0 -0
  25. data/service/ssh/ext/ssh_core/config/vm.args +0 -0
  26. data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
  27. data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
  28. data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
  29. data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
  30. data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
  31. data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
  32. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
  33. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
  34. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
  35. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
  36. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
  37. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
  38. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
  39. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
  40. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
  41. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
  42. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
  43. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
  44. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
  45. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
  46. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
  47. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
  48. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
  49. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
  50. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
  51. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
  52. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
  53. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
  54. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
  55. data/service/ssh/ext/ssh_core/rebar.config +24 -0
  56. data/service/ssh/ext/ssh_core/rebar.lock +1 -0
  57. data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
  58. data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
  59. data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
  60. data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
  61. data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
  62. data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
  63. data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
  64. data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
  65. data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +104 -0
  66. data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
  67. data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
  68. data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
  69. data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
  70. data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
  71. data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
  72. data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
  73. data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
  74. data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
  75. data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
  76. data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
  77. data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
  78. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
  79. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
  80. data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
  81. data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
  82. data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
  83. data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
  84. data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
  85. data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
  86. data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
  87. data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
  88. data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
  89. data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
  90. data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
  91. data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
  92. data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
  93. data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
  94. data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
  95. data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
  96. data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
  97. data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
  98. data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
  99. data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
  100. data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
  101. data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
  102. data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
  103. data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
  104. data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
  105. data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
  106. data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
  107. data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
  108. data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
  109. data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
  110. data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
  111. data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
  112. data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
  113. data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
  114. data/support/basic/as_num.rb +221 -0
  115. data/support/basic/mac_address.rb +281 -0
  116. data/version.rb +1 -1
  117. metadata +138 -1
@@ -0,0 +1,535 @@
1
+ -module(ssh_conn_worker).
2
+ -behaviour(gen_statem).
3
+
4
+ %% Connection worker — manages a single SSH connection's lifecycle + channels.
5
+ %% State machine: idle → connecting → authenticating → ready → reconnecting/closing → failed/closed
6
+ %%
7
+ %% Key design (LLD §6.3, E1/E2/E5 fixes):
8
+ %% - OTP ssh integrates auth into ssh:connect/2,3
9
+ %% - gen_statem (state_functions mode), not gen_fsm
10
+ %% - Channels are spawned as children via proc_lib:spawn_link
11
+ %% - Reconnect is NOT autonomous — Erlang notifies, Ruby decides
12
+
13
+ -export([
14
+ start_link/1,
15
+ do_connect/1,
16
+ rpc_disconnect/1,
17
+ rpc_channel_open/1,
18
+ rpc_reconnect/1,
19
+ get_state_name/1,
20
+ get_state/1,
21
+ get_ssh_ref/1,
22
+ conn_id/1,
23
+ build_ssh_options/1
24
+ ]).
25
+ -export([callback_mode/0, init/1]).
26
+ -export([idle/3, connecting/3, authenticating/3, ready/3,
27
+ reconnecting/3, closing/3, failed/3]).
28
+
29
+ -include("ssh_ipc.hrl").
30
+
31
+ %% ---------- Public API ----------
32
+
33
+ %% @doc Start a connection worker. Spec is the connect_spec map.
34
+ start_link(Spec) ->
35
+ gen_statem:start_link(?MODULE, [Spec], []).
36
+
37
+ %% @doc Trigger the connection sequence.
38
+ %% Uses a generous gen_statem call timeout (connect_timeout + 10s buffer)
39
+ %% to avoid the call timing out before ssh:connect does.
40
+ do_connect(Pid) ->
41
+ %% Get the spec's connect_timeout or default, add 10s buffer for state machine overhead
42
+ case catch ssh_conn_worker:get_state(Pid) of
43
+ #conn{spec = Spec} ->
44
+ Timeout = maps:get(<<"connect_timeout_ms">>, Spec, ?CONNECT_TIMEOUT_MS),
45
+ gen_statem:call(Pid, do_connect, Timeout + 10000);
46
+ _ ->
47
+ gen_statem:call(Pid, do_connect, ?CONNECT_TIMEOUT_MS + 10000)
48
+ end.
49
+
50
+ %% @doc RPC handler for conn.disconnect
51
+ rpc_disconnect(#{<<"id">> := ConnId}) ->
52
+ case find_by_conn_id(ConnId) of
53
+ {ok, Pid} ->
54
+ gen_statem:call(Pid, disconnect),
55
+ #{ok => true};
56
+ {error, not_found} ->
57
+ {error, conn_not_found}
58
+ end.
59
+
60
+ %% @doc RPC handler for conn.reconnect
61
+ rpc_reconnect(#{<<"id">> := ConnId}) ->
62
+ case find_by_conn_id(ConnId) of
63
+ {ok, Pid} ->
64
+ gen_statem:call(Pid, reconnect);
65
+ {error, not_found} ->
66
+ {error, conn_not_found}
67
+ end.
68
+
69
+ %% @doc RPC handler for channel.open
70
+ rpc_channel_open(#{<<"conn_id">> := ConnId} = Params) ->
71
+ case find_by_conn_id(ConnId) of
72
+ {ok, Pid} ->
73
+ gen_statem:call(Pid, {open_channel, Params});
74
+ {error, not_found} ->
75
+ {error, conn_not_found}
76
+ end.
77
+
78
+ %% @doc Get current state name (for conn.list).
79
+ get_state_name(Pid) ->
80
+ gen_statem:call(Pid, get_state_name).
81
+
82
+ %% @doc Get full state record (internal use).
83
+ get_state(Pid) ->
84
+ gen_statem:call(Pid, get_state).
85
+
86
+ %% @doc Get the OTP ssh connection reference.
87
+ get_ssh_ref(Pid) ->
88
+ gen_statem:call(Pid, get_ssh_ref).
89
+
90
+ %% @doc Get conn_id for this worker.
91
+ conn_id(Pid) ->
92
+ gen_statem:call(Pid, conn_id).
93
+
94
+ %% ---------- gen_statem callbacks ----------
95
+
96
+ callback_mode() -> state_functions.
97
+
98
+ init([Spec]) ->
99
+ ConnId = ssh_codec:gen_conn_id(),
100
+ Data = #conn{
101
+ id = ConnId,
102
+ spec = Spec,
103
+ options = build_ssh_options(Spec)
104
+ },
105
+ {ok, idle, Data}.
106
+
107
+ %% ---------- State: idle ----------
108
+
109
+ idle({call, From}, do_connect, #conn{spec = Spec, id = Id} = D) ->
110
+ {next_state, connecting, D, [{next_event, internal, {From, do_connect}}]};
111
+
112
+ idle({call, From}, get_state_name, _D) ->
113
+ {keep_state_and_data, [{reply, From, idle}]};
114
+
115
+ idle({call, From}, conn_id, #conn{id = Id}) ->
116
+ {keep_state_and_data, [{reply, From, Id}]};
117
+
118
+ idle(Type, Msg, D) ->
119
+ handle_common(Type, Msg, D).
120
+
121
+ %% ---------- State: connecting ----------
122
+
123
+ connecting(internal, {From, do_connect}, #conn{spec = Spec, id = Id, options = Opts} = D) ->
124
+ Jumps = maps:get(<<"jumps">>, Spec, []),
125
+ ConnectTimeout = maps:get(<<"connect_timeout_ms">>, Spec, ?CONNECT_TIMEOUT_MS),
126
+ Result = case Jumps of
127
+ [] ->
128
+ Host = binary_to_list(maps:get(<<"host">>, Spec)),
129
+ Port = maps:get(<<"port">>, Spec, 22),
130
+ ssh:connect(Host, Port, Opts, ConnectTimeout);
131
+ _ ->
132
+ %% ProxyJump: jump chain handles the entire connection
133
+ ssh_jump_chain:build(Spec, Jumps)
134
+ end,
135
+ case Result of
136
+ {ok, SshRef} ->
137
+ Fingerprint = get_host_fingerprint(SshRef),
138
+ ssh_ipc_gateway:push_event(<<"conn.ready">>, #{
139
+ <<"conn_id">> => Id,
140
+ <<"fingerprint">> => Fingerprint
141
+ }),
142
+ {next_state, ready, D#conn{ssh_ref = SshRef},
143
+ [{reply, From, {ok, #{conn_id => Id, fingerprint => Fingerprint}}}]};
144
+ {error, Reason} ->
145
+ ssh_ipc_gateway:push_event(<<"conn.failed">>, #{
146
+ <<"conn_id">> => Id,
147
+ <<"reason">> => atom_to_binary(Reason, utf8)
148
+ }),
149
+ {next_state, failed, D#conn{fail_reason = Reason},
150
+ [{reply, From, {error, Reason}}]}
151
+ end;
152
+
153
+ connecting({call, From}, get_state_name, _D) ->
154
+ {keep_state_and_data, [{reply, From, connecting}]};
155
+
156
+ connecting(Type, Msg, D) ->
157
+ handle_common(Type, Msg, D).
158
+
159
+ %% ---------- State: authenticating ----------
160
+
161
+ %% OTP ssh does auth in ssh:connect. This state is a transient
162
+ %% placeholder for keyboard_interactive prompt handling.
163
+ authenticating({call, From}, get_state_name, _D) ->
164
+ {keep_state_and_data, [{reply, From, authenticating}]};
165
+
166
+ authenticating(Type, Msg, D) ->
167
+ handle_common(Type, Msg, D).
168
+
169
+ %% ---------- State: ready ----------
170
+
171
+ ready({call, From}, {open_channel, Params}, #conn{ssh_ref = Ref, id = ConnId, next_ch_seq = Seq} = D) ->
172
+ ChId = ssh_codec:gen_channel_id(ConnId),
173
+ ChType = maps:get(<<"type">>, Params, <<"shell">>),
174
+ Cols = maps:get(<<"cols">>, Params, 80),
175
+ Rows = maps:get(<<"rows">>, Params, 24),
176
+ TermType = maps:get(<<"term">>, Params, <<"xterm-256color">>),
177
+ case ssh_channel_stm:start_link(ChId, ConnId, Ref, #{
178
+ type => binary_to_atom(ChType, utf8),
179
+ cols => Cols,
180
+ rows => Rows,
181
+ term_type => TermType,
182
+ command => maps:get(<<"command">>, Params, undefined)
183
+ }) of
184
+ {ok, ChPid} ->
185
+ %% Register in ETS index for channel lookup by id
186
+ ets:insert(ch_index, {ChId, ChPid}),
187
+ Channels = maps:put(ChId, ChPid, D#conn.channels),
188
+ {keep_state, D#conn{channels = Channels, next_ch_seq = Seq + 1},
189
+ [{reply, From, {ok, #{channel_id => ChId}}}]};
190
+ {error, Reason} ->
191
+ {keep_state_and_data, [{reply, From, {error, Reason}}]}
192
+ end;
193
+
194
+ ready({call, From}, disconnect, #conn{ssh_ref = Ref} = D) ->
195
+ ssh:close(Ref),
196
+ {next_state, closing, D, [{reply, From, #{ok => true}}]};
197
+
198
+ ready({call, From}, get_state_name, _D) ->
199
+ {keep_state_and_data, [{reply, From, ready}]};
200
+
201
+ ready({call, From}, get_ssh_ref, #conn{ssh_ref = Ref}) ->
202
+ {keep_state_and_data, [{reply, From, Ref}]};
203
+
204
+ ready({call, From}, get_state, D) ->
205
+ {keep_state_and_data, [{reply, From, D}]};
206
+
207
+ ready({call, From}, conn_id, #conn{id = Id}) ->
208
+ {keep_state_and_data, [{reply, From, Id}]};
209
+
210
+ ready(info, {ssh_channel_down, ChId}, #conn{channels = Chs} = D) ->
211
+ ets:delete(ch_index, ChId),
212
+ NewChs = maps:remove(ChId, Chs),
213
+ {keep_state, D#conn{channels = NewChs}};
214
+
215
+ ready(info, {ssh_closed, _Reason}, #conn{id = Id} = D) ->
216
+ ssh_ipc_gateway:push_event(<<"conn.closed">>, #{
217
+ <<"conn_id">> => Id,
218
+ <<"reason">> => <<"ssh_closed">>
219
+ }),
220
+ {next_state, failed, D#conn{ssh_ref = undefined}};
221
+
222
+ ready(Type, Msg, D) ->
223
+ handle_common(Type, Msg, D).
224
+
225
+ %% ---------- State: reconnecting ----------
226
+
227
+ reconnecting({call, From}, reconnect, #conn{spec = Spec, id = Id, options = Opts} = D) ->
228
+ Jumps = maps:get(<<"jumps">>, Spec, []),
229
+ ConnectTimeout = maps:get(<<"connect_timeout_ms">>, Spec, ?CONNECT_TIMEOUT_MS),
230
+ Result = case Jumps of
231
+ [] ->
232
+ Host = binary_to_list(maps:get(<<"host">>, Spec)),
233
+ Port = maps:get(<<"port">>, Spec, 22),
234
+ ssh:connect(Host, Port, Opts, ConnectTimeout);
235
+ _ ->
236
+ ssh_jump_chain:build(Spec, Jumps)
237
+ end,
238
+ case Result of
239
+ {ok, SshRef} ->
240
+ ssh_ipc_gateway:push_event(<<"conn.ready">>, #{<<"conn_id">> => Id}),
241
+ {next_state, ready, D#conn{ssh_ref = SshRef, reconnect_count = 0},
242
+ [{reply, From, {ok, #{conn_id => Id}}}]};
243
+ {error, Reason} ->
244
+ {keep_state, D#conn{reconnect_count = D#conn.reconnect_count + 1},
245
+ [{reply, From, {error, Reason}}]}
246
+ end;
247
+
248
+ reconnecting({call, From}, get_state_name, _D) ->
249
+ {keep_state_and_data, [{reply, From, reconnecting}]};
250
+
251
+ reconnecting(Type, Msg, D) ->
252
+ handle_common(Type, Msg, D).
253
+
254
+ %% ---------- State: closing ----------
255
+
256
+ closing({call, From}, _Msg, _D) ->
257
+ {keep_state_and_data, [{reply, From, {error, closing}}]};
258
+
259
+ closing(Type, Msg, D) ->
260
+ handle_common(Type, Msg, D).
261
+
262
+ %% ---------- State: failed ----------
263
+
264
+ failed({call, From}, get_state_name, _D) ->
265
+ {keep_state_and_data, [{reply, From, failed}]};
266
+
267
+ failed({call, From}, conn_id, #conn{id = Id}) ->
268
+ {keep_state_and_data, [{reply, From, Id}]};
269
+
270
+ failed({call, From}, reconnect, D) ->
271
+ {next_state, reconnecting, D, [{next_event, internal, {From, reconnect}}]};
272
+
273
+ failed(Type, Msg, D) ->
274
+ handle_common(Type, Msg, D).
275
+
276
+ %% ---------- Common handler ----------
277
+
278
+ handle_common({call, From}, get_state_name, _D) ->
279
+ {keep_state_and_data, [{reply, From, unknown}]};
280
+
281
+ handle_common({call, From}, conn_id, #conn{id = Id}) ->
282
+ {keep_state_and_data, [{reply, From, Id}]};
283
+
284
+ handle_common(_Type, _Msg, D) ->
285
+ {keep_state, D}.
286
+
287
+ %% ---------- Internal: SSH options ----------
288
+
289
+ %% @doc Build ssh:connect options from the connect spec.
290
+ build_ssh_options(Spec) ->
291
+ ConnectTimeout = maps:get(<<"connect_timeout_ms">>, Spec, ?CONNECT_TIMEOUT_MS),
292
+ Auth = maps:get(<<"auth">>, Spec, #{}),
293
+ [
294
+ {user, binary_to_list(maps:get(<<"user">>, Spec))},
295
+ {silently_accept_hosts, false},
296
+ {key_cb, {ssh_auth_engine, [{auth_map, Auth}]}},
297
+ {user_dir, binary_to_list(maps:get(<<"key_dir">>, Spec, <<".">>))},
298
+ {preferred_algorithms, build_algorithms(maps:get(<<"algorithms">>, Spec, undefined))},
299
+ {connect_timeout, ConnectTimeout},
300
+ {user_interaction, false}
301
+ ]
302
+ ++ build_auth_options(maps:get(<<"auth">>, Spec, #{}))
303
+ ++ build_proxy_options(maps:get(<<"proxy">>, Spec, undefined))
304
+ ++ build_jump_options(maps:get(<<"jumps">>, Spec, [])).
305
+
306
+ %% @doc Build preferred_algorithms option.
307
+ %% Returns a keyword list (not a map) for OTP 17 compatibility.
308
+ %% OTP 17 expects cipher and mac as [{client2server,[...]}, {server2client,[...]}] pairs,
309
+ %% while kex and public_key use flat lists.
310
+ %%
311
+ %% If user provides custom algorithms in spec, merge them with defaults:
312
+ %% - User-specified algorithms are prepended (higher priority)
313
+ %% - Default algorithms fill in any categories not specified
314
+ %% - Within a category, user list takes priority, defaults appended for fallback
315
+ %% If no custom algorithms, use defaults.
316
+ -spec build_algorithms(undefined | map()) -> [{atom(), term()}].
317
+ build_algorithms(undefined) ->
318
+ preferred_algs();
319
+ build_algorithms(UserAlgs) when is_map(UserAlgs) ->
320
+ Defaults = preferred_algs(),
321
+ %% Convert Defaults to a map for easy merging
322
+ DefaultMap = #{
323
+ kex => proplists:get_value(kex, Defaults),
324
+ public_key => proplists:get_value(public_key, Defaults),
325
+ cipher => proplists:get_value(cipher, Defaults),
326
+ mac => proplists:get_value(mac, Defaults)
327
+ },
328
+ %% Merge each category: user first, then defaults not already included
329
+ MergedMap = maps:map(fun(Category, DefaultList) ->
330
+ UserList = case maps:get(Category, UserAlgs, undefined) of
331
+ undefined -> [];
332
+ L when is_list(L) -> [normalize_alg(A) || A <- L];
333
+ _ -> []
334
+ end,
335
+ UserList ++ [A || A <- DefaultList, not lists:member(A, UserList)]
336
+ end, DefaultMap),
337
+ %% Convert back to keyword list format
338
+ [
339
+ {kex, maps:get(kex, MergedMap)},
340
+ {public_key, maps:get(public_key, MergedMap)},
341
+ {cipher, maps:get(cipher, MergedMap)},
342
+ {mac, maps:get(mac, MergedMap)}
343
+ ].
344
+
345
+ %% @doc Normalize algorithm atom from binary or atom.
346
+ normalize_alg(Bin) when is_binary(Bin) ->
347
+ try binary_to_existing_atom(Bin, utf8) catch _:_ -> binary_to_atom(Bin, utf8) end;
348
+ normalize_alg(Atom) when is_atom(Atom) ->
349
+ Atom.
350
+
351
+ %% @doc Preferred algorithms — meets FR-CONN-001 quantitative targets.
352
+ %% Returns keyword list format for OTP 17 compatibility:
353
+ %% kex, public_key => flat list
354
+ %% cipher, mac => [{client2server,[...]}, {server2client,[...]}] pairs
355
+ preferred_algs() ->
356
+ Ciphers = ['aes256-gcm@openssh.com',
357
+ 'aes128-gcm@openssh.com',
358
+ 'chacha20-poly1305@openssh.com',
359
+ 'aes256-ctr', 'aes192-ctr', 'aes128-ctr'],
360
+ Macs = ['hmac-sha2-256',
361
+ 'hmac-sha2-512',
362
+ 'hmac-sha2-256-etm@openssh.com',
363
+ 'hmac-sha2-512-etm@openssh.com'],
364
+ [
365
+ {kex, ['curve25519-sha256@libssh.org',
366
+ 'curve25519-sha256',
367
+ 'ecdh-sha2-nistp256',
368
+ 'ecdh-sha2-nistp384',
369
+ 'ecdh-sha2-nistp521',
370
+ 'diffie-hellman-group16-sha512',
371
+ 'diffie-hellman-group14-sha256']},
372
+ {public_key, ['ssh-ed25519',
373
+ 'rsa-sha2-512',
374
+ 'rsa-sha2-256',
375
+ 'ecdsa-sha2-nistp256',
376
+ 'ecdsa-sha2-nistp384',
377
+ 'ecdsa-sha2-nistp521']},
378
+ {cipher, [{client2server, Ciphers}, {server2client, Ciphers}]},
379
+ {mac, [{client2server, Macs}, {server2client, Macs}]}
380
+ ].
381
+
382
+ %% @doc Build auth options from the auth map.
383
+ build_auth_options(#{<<"type">> := <<"password">>} = Auth) ->
384
+ [{password, binary_to_list(maps:get(<<"password">>, Auth))}];
385
+ build_auth_options(#{<<"type">> := <<"publickey">>}) ->
386
+ %% Key loading handled by ssh_auth_engine key_cb callbacks
387
+ [];
388
+ build_auth_options(#{<<"type">> := <<"keyboard_interactive">>} = Auth) ->
389
+ [{keyboard_interactive, fun(_Prompts, _Ssh) -> handle_kbi_prompts(Auth) end}];
390
+ build_auth_options(_) ->
391
+ [].
392
+
393
+ %% @doc Handle keyboard-interactive prompts.
394
+ handle_kbi_prompts(Auth) ->
395
+ %% Return responses from auth map
396
+ Responses = maps:get(<<"responses">>, Auth, []),
397
+ {ok, [binary_to_list(R) || R <- Responses]}.
398
+
399
+ %% @doc Build proxy options (SOCKS5/HTTP).
400
+ build_proxy_options(undefined) -> [];
401
+ build_proxy_options(#{<<"type">> := _, <<"host">> := _, <<"port">> := _} = Proxy) ->
402
+ [{sock_fun, make_proxy_sock_fun(Proxy)}].
403
+
404
+ %% @doc Build jump chain options.
405
+ %% Jumps are not handled as ssh options — they require a separate connection
406
+ %% sequence via ssh_jump_chain:build/2, called from connecting/reconnecting states.
407
+ %% Returns [] so build_ssh_options skips them.
408
+ build_jump_options(_Jumps) -> [].
409
+
410
+ %% @doc Make proxy sock_fun — supports SOCKS5 and HTTP CONNECT.
411
+ %% The returned fun must implement the OTP ssh sock_fun contract:
412
+ %% (connect, Host, Port, Timeout) -> {ok, Sock} | {error, _}
413
+ %% (close, Sock, _, _) -> ok
414
+ %% (send, Sock, Data, _) -> ok | {error,_}
415
+ %% (recv, Sock, Len, Timeout) -> {ok, Data} | {error,_}
416
+ make_proxy_sock_fun(Proxy) ->
417
+ ProxyHost = binary_to_list(maps:get(<<"host">>, Proxy)),
418
+ ProxyPort = maps:get(<<"port">>, Proxy),
419
+ ProxyType = binary_to_list(maps:get(<<"type">>, Proxy, <<"socks5">>)),
420
+ Username = case maps:get(<<"username">>, Proxy, undefined) of
421
+ undefined -> undefined;
422
+ UBin -> UBin
423
+ end,
424
+ Password = case maps:get(<<"password">>, Proxy, undefined) of
425
+ undefined -> undefined;
426
+ PBin -> PBin
427
+ end,
428
+ fun
429
+ (connect, TargetHost, TargetPort, Timeout) ->
430
+ case gen_tcp:connect(ProxyHost, ProxyPort, [binary, {active, false}, {packet, raw}], Timeout) of
431
+ {error, _} = E -> E;
432
+ {ok, Sock} ->
433
+ case do_proxy_handshake(Sock, ProxyType, TargetHost, TargetPort, Username, Password, Timeout) of
434
+ ok ->
435
+ %% Switch to active mode for async recv
436
+ inet:setopts(Sock, [{active, true}]),
437
+ {ok, {proxy_sock, Sock}};
438
+ {error, _Reason} ->
439
+ gen_tcp:close(Sock),
440
+ {error, proxy_handshake_failed}
441
+ end
442
+ end;
443
+ (close, {proxy_sock, Sock}, _A3, _A4) ->
444
+ gen_tcp:close(Sock),
445
+ ok;
446
+ (send, {proxy_sock, Sock}, Data, _A4) ->
447
+ gen_tcp:send(Sock, Data);
448
+ (recv, {proxy_sock, Sock}, _Len, _Timeout) ->
449
+ receive
450
+ {tcp, Sock, Data} -> {ok, Data};
451
+ {tcp_closed, Sock} -> {error, closed};
452
+ {tcp_error, Sock, _} -> {error, tcp_error}
453
+ after ?CONNECT_TIMEOUT_MS -> {error, timeout}
454
+ end
455
+ end.
456
+
457
+ %% @doc SOCKS5 handshake — RFC 1928/1929
458
+ do_proxy_handshake(Sock, "socks5", TargetHost, TargetPort, Username, Password, Timeout) ->
459
+ %% Step 1: greeting
460
+ AuthMethod = case {Username, Password} of
461
+ {undefined, _} -> 0; % no auth
462
+ _ -> 2 % username/password
463
+ end,
464
+ ok = gen_tcp:send(Sock, <<5, 1, AuthMethod>>),
465
+ case gen_tcp:recv(Sock, 0, Timeout) of
466
+ {ok, <<5, 0>>} when AuthMethod =:= 0 -> ok; % no auth needed
467
+ {ok, <<5, 2>>} when AuthMethod =:= 2 ->
468
+ %% Step 2: username/password auth (RFC 1929)
469
+ ULen = byte_size(Username),
470
+ PLen = byte_size(Password),
471
+ ok = gen_tcp:send(Sock, <<1, ULen, Username/binary, PLen, Password/binary>>),
472
+ case gen_tcp:recv(Sock, 0, Timeout) of
473
+ {ok, <<1, 0>>} -> ok;
474
+ _ -> {error, socks5_auth_failed}
475
+ end;
476
+ _ ->
477
+ {error, socks5_greeting_failed}
478
+ end,
479
+ %% Step 3: connect request
480
+ TargetHostBin = list_to_binary(TargetHost),
481
+ HostBytes = case inet:parse_address(TargetHost) of
482
+ {ok, {A, B, C, D}} -> <<1, A:8, B:8, C:8, D:8>>;
483
+ _ -> <<3, (byte_size(TargetHostBin)):8, TargetHostBin/binary>>
484
+ end,
485
+ ok = gen_tcp:send(Sock, <<5, 1, 0, HostBytes/binary, TargetPort:16>>),
486
+ case gen_tcp:recv(Sock, 0, Timeout) of
487
+ {ok, <<5, 0, _Rest/binary>>} -> ok;
488
+ {ok, <<5, ReplyCode, _Rest/binary>>} -> {error, {socks5_connect_rejected, ReplyCode}};
489
+ _ -> {error, socks5_connect_failed}
490
+ end;
491
+
492
+ %% @doc HTTP CONNECT handshake — RFC 7231
493
+ do_proxy_handshake(Sock, "http", TargetHost, TargetPort, Username, Password, Timeout) ->
494
+ ConnectLine = io_lib:format("CONNECT ~s:~p HTTP/1.1\r\nHost: ~s:~p\r\n",
495
+ [TargetHost, TargetPort, TargetHost, TargetPort]),
496
+ AuthHeader = case {Username, Password} of
497
+ {undefined, _} -> "";
498
+ _ ->
499
+ Creds = base64:encode(<<Username/binary, ":", Password/binary>>),
500
+ io_lib:format("Proxy-Authorization: Basic ~s\r\n", [Creds])
501
+ end,
502
+ Request = iolist_to_binary([ConnectLine, AuthHeader, "\r\n"]),
503
+ ok = gen_tcp:send(Sock, Request),
504
+ case gen_tcp:recv(Sock, 0, Timeout) of
505
+ {ok, Data} ->
506
+ case binary:match(Data, <<"200">>) of
507
+ nomatch -> {error, http_connect_failed};
508
+ _ -> ok
509
+ end;
510
+ _ -> {error, http_connect_timeout}
511
+ end;
512
+
513
+ do_proxy_handshake(_Sock, Unknown, _Host, _Port, _User, _Pass, _Timeout) ->
514
+ {error, {unsupported_proxy_type, Unknown}}.
515
+
516
+ %% ---------- Internal: Helpers ----------
517
+
518
+ %% @doc Get host fingerprint from a connected ssh_ref.
519
+ %% Extracts the peer public key and computes SHA-256 fingerprint.
520
+ get_host_fingerprint(SshRef) ->
521
+ case ssh_connection:hostkey(SshRef) of
522
+ {ok, Key} ->
523
+ ssh_codec:fingerprint(Key);
524
+ {error, _} ->
525
+ <<"SHA256:unknown">>
526
+ end.
527
+
528
+ %% @doc Find a worker by conn_id (scans conn_sup children).
529
+ find_by_conn_id(ConnId) ->
530
+ Match = [Pid || Pid <- ssh_conn_sup:all_workers(),
531
+ conn_id(Pid) =:= ConnId],
532
+ case Match of
533
+ [Pid | _] -> {ok, Pid};
534
+ [] -> {error, not_found}
535
+ end.
@@ -0,0 +1,36 @@
1
+ {application, ssh_core, [
2
+ {description, "SSH Core Engine - Erlang/OTP ssh application wrapper"},
3
+ {vsn, "1.0.0"},
4
+ {registered, [ssh_core_sup]},
5
+ {mod, {ssh_core_app, []}},
6
+ {applications, [
7
+ kernel,
8
+ stdlib,
9
+ ssh,
10
+ public_key,
11
+ crypto,
12
+ inets
13
+ ]},
14
+ {env, []},
15
+ {modules, [
16
+ ssh_core_app,
17
+ ssh_core_sup,
18
+ ssh_infra_sup,
19
+ ssh_conn_sup,
20
+ ssh_sftp_sup,
21
+ ssh_ipc_gateway,
22
+ ssh_ipc_proto,
23
+ ssh_ipc_coalesce,
24
+ ssh_codec,
25
+ ssh_conn_worker,
26
+ ssh_channel_stm,
27
+ ssh_auth_engine,
28
+ ssh_jump_chain,
29
+ ssh_port_fwd,
30
+ ssh_sftp_session,
31
+ ssh_keepalive_mgr,
32
+ ssh_known_hosts_proxy
33
+ ]},
34
+ {licenses, ["AGPL-3.0-or-later"]},
35
+ {links, []}
36
+ ]}.
@@ -0,0 +1,11 @@
1
+ -module(ssh_core_app).
2
+ -behaviour(application).
3
+
4
+ -export([start/2, stop/1]).
5
+
6
+ %% @doc Application entry point — starts the top-level supervisor.
7
+ start(_StartType, _StartArgs) ->
8
+ ssh_core_sup:start_link().
9
+
10
+ stop(_State) ->
11
+ ok.
@@ -0,0 +1,46 @@
1
+ -module(ssh_core_sup).
2
+ -behaviour(supervisor).
3
+
4
+ -export([start_link/0, stop/0]).
5
+ -export([init/1]).
6
+
7
+ %% @doc Start the top-level supervisor.
8
+ start_link() ->
9
+ supervisor:start_link({local, ?MODULE}, ?MODULE, []).
10
+
11
+ %% @doc Graceful shutdown of the entire application.
12
+ stop() ->
13
+ gen_server:stop(?MODULE).
14
+
15
+ %% @doc Top-level supervisor: one_for_one, intensity 5/60s.
16
+ %% Children:
17
+ %% 1. ssh_infra_sup — infrastructure layer (gateway, keepalive, known_hosts)
18
+ %% 2. ssh_conn_sup — dynamic connection workers (simple_one_for_one)
19
+ %% 3. ssh_sftp_sup — dynamic SFTP sessions (simple_one_for_one)
20
+ init([]) ->
21
+ SupFlags = #{
22
+ strategy => one_for_one,
23
+ intensity => 5,
24
+ period => 60
25
+ },
26
+ Children = [
27
+ #{
28
+ id => ssh_infra_sup,
29
+ start => {ssh_infra_sup, start_link, []},
30
+ restart => permanent,
31
+ type => supervisor
32
+ },
33
+ #{
34
+ id => ssh_conn_sup,
35
+ start => {ssh_conn_sup, start_link, []},
36
+ restart => permanent,
37
+ type => supervisor
38
+ },
39
+ #{
40
+ id => ssh_sftp_sup,
41
+ start => {ssh_sftp_sup, start_link, []},
42
+ restart => permanent,
43
+ type => supervisor
44
+ }
45
+ ],
46
+ {ok, {SupFlags, Children}}.