network-infra-utility 0.3.0 → 0.6.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/GUIDE.md +212 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +70 -0
  6. data/Rakefile +1 -1
  7. data/bin/dns-query +836 -0
  8. data/bin/geo-doc +135 -0
  9. data/bin/geo-get +1 -1
  10. data/bin/geo-update +429 -0
  11. data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
  12. data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
  13. 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
  14. data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
  15. data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
  16. 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
  17. data/network-infra-utility.gemspec +4 -2
  18. data/network.rb +3 -1
  19. data/service/geodb/geodb.rb +278 -1
  20. data/service/ssh/README.md +955 -0
  21. data/service/ssh/bin/ssh-client +198 -0
  22. 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
  23. 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
  24. 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
  25. data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
  26. data/service/ssh/ext/ssh_core/config/sys.config +0 -0
  27. data/service/ssh/ext/ssh_core/config/vm.args +0 -0
  28. data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
  29. data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
  30. data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
  31. data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
  32. data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
  33. data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
  34. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
  35. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
  36. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
  37. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
  38. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
  39. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
  40. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
  41. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
  42. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
  43. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
  44. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
  45. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
  46. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
  47. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
  48. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
  49. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
  50. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
  51. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
  52. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
  53. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
  54. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
  55. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
  56. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
  57. data/service/ssh/ext/ssh_core/rebar.config +24 -0
  58. data/service/ssh/ext/ssh_core/rebar.lock +1 -0
  59. data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
  60. data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
  61. data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
  62. data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
  63. data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
  64. data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
  65. data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
  66. data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
  67. data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +117 -0
  68. data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
  69. data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
  70. data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
  71. data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
  72. data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
  73. data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
  74. data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
  75. data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
  76. data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
  77. data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
  78. data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
  79. data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
  80. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
  81. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
  82. data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
  83. data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
  84. data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
  85. data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
  86. data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
  87. data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
  88. data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
  89. data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
  90. data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
  91. data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
  92. data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
  93. data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
  94. data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
  95. data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
  96. data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
  97. data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
  98. data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
  99. data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
  100. data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
  101. data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
  102. data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
  103. data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
  104. data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
  105. data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
  106. data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
  107. data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
  108. data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
  109. data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
  110. data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
  111. data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
  112. data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
  113. data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
  114. data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
  115. data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
  116. data/support/basic/as_num.rb +221 -0
  117. data/support/basic/mac_address.rb +281 -0
  118. data/version.rb +1 -1
  119. metadata +142 -1
@@ -0,0 +1,467 @@
1
+ -module(ssh_ipc_gateway).
2
+ -behaviour(gen_server).
3
+
4
+ %% IPC Gateway: listens on Unix Socket/TCP, accepts Ruby connections,
5
+ %% authenticates via token, routes JSON-RPC requests, and pushes events.
6
+ %%
7
+ %% Key design:
8
+ %% - Routes are immutable after init
9
+ %% - push_event/2 writes to coalesce buffer or direct push
10
+ %% - push_batch/1 sends already-batched data directly
11
+ %% - synchronous_push/2 blocks for Erlang→Ruby reverse RPC (hostkey.resolve)
12
+ %% - Backpressure: when client send_q exceeds threshold, flow pauses
13
+
14
+ -export([
15
+ start_link/1,
16
+ push_event/2,
17
+ push_batch/1,
18
+ synchronous_push/2
19
+ ]).
20
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
21
+
22
+ %% Internal RPC handlers
23
+ -export([
24
+ handle_bye/1,
25
+ handle_ping/1,
26
+ handle_stats/1,
27
+ handle_shutdown/1
28
+ ]).
29
+
30
+ -include("ssh_ipc.hrl").
31
+
32
+ %% ---------- Public API ----------
33
+
34
+ %% @doc Start the gateway. EndpointToken contains auth token and endpoint file path.
35
+ start_link(#{token := Token} = _EndpointToken) ->
36
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [Token], []).
37
+
38
+ %% @doc Push a single event through coalesce.
39
+ push_event(Method, Params) ->
40
+ gen_server:cast(?MODULE, {push_event, Method, Params}).
41
+
42
+ %% @doc Push an already-batched notification directly.
43
+ push_batch(Params) ->
44
+ gen_server:cast(?MODULE, {push_batch, Params}).
45
+
46
+ %% @doc Synchronous reverse RPC (Erlang→Ruby with id). Blocks until response.
47
+ %% Used by ssh_known_hosts_proxy for hostkey.resolve.
48
+ synchronous_push(Req, TimeoutMs) ->
49
+ gen_server:call(?MODULE, {sync_push, Req, TimeoutMs}, TimeoutMs + 1000).
50
+
51
+ %% ---------- Callbacks ----------
52
+
53
+ init([Token]) ->
54
+ {ok, ListenSock} = start_listener(),
55
+ Routes = build_routes(),
56
+ %% Store auth token in process dictionary for handle_hello/1 access
57
+ put(auth_token, Token),
58
+ %% Start async accept loop
59
+ prim_inet:async_accept(ListenSock, -1),
60
+ {ok, #gw_state{
61
+ listen_sock = ListenSock,
62
+ auth_token = Token,
63
+ routes = Routes
64
+ }}.
65
+
66
+ handle_call({sync_push, Req, TimeoutMs}, From, S) ->
67
+ %% Send a reverse request (with id) to Ruby and wait for response
68
+ case maps:size(S#gw_state.clients) of
69
+ 0 ->
70
+ {reply, {error, no_clients}, S};
71
+ _ ->
72
+ ReverseId = generate_reverse_id(),
73
+ Msg = ssh_ipc_proto:frame(
74
+ ssh_ipc_proto:encode_request(ReverseId,
75
+ maps:get(<<"method">>, Req),
76
+ maps:get(<<"params">>, Req))
77
+ ),
78
+ case send_to_all_clients(S, Msg) of
79
+ ok ->
80
+ %% Store pending for reverse response matching
81
+ Ref = erlang:make_ref(),
82
+ erlang:send_after(TimeoutMs, self(), {sync_push_timeout, Ref}),
83
+ {noreply, store_reverse_pending(S, ReverseId, From, Ref)};
84
+ {error, no_clients} ->
85
+ {reply, {error, no_clients}, S}
86
+ end
87
+ end;
88
+
89
+ handle_call(_Req, _From, S) ->
90
+ {reply, {error, not_implemented}, S}.
91
+
92
+ handle_cast({push_event, Method, Params}, S) ->
93
+ send_notification(S, Method, Params),
94
+ {noreply, S};
95
+
96
+ handle_cast({push_batch, Params}, S) ->
97
+ send_notification(S, <<"channel.data.batch">>, Params),
98
+ {noreply, S};
99
+
100
+ handle_cast(_Msg, S) ->
101
+ {noreply, S}.
102
+
103
+ handle_info({tcp, Socket, Data}, S) ->
104
+ {Frames, Rest} = ssh_ipc_proto:unframe(Data),
105
+ S1 = lists:foldl(fun(Frame, State) -> handle_frame(Socket, Frame, State) end, S, Frames),
106
+ %% Keep remaining buffer per-socket if partial frame
107
+ case Rest of
108
+ <<>> -> ok;
109
+ _ -> ok %% Partial data stored on next recv — simplification
110
+ end,
111
+ inet:setopts(Socket, [{active, once}]),
112
+ {noreply, S1};
113
+
114
+ handle_info({tcp_closed, Socket}, S) ->
115
+ S1 = remove_client(S, Socket),
116
+ {noreply, S1};
117
+
118
+ handle_info({tcp_error, Socket, _Reason}, S) ->
119
+ S1 = remove_client(S, Socket),
120
+ {noreply, S1};
121
+
122
+ handle_info({sync_push_timeout, Ref}, S) ->
123
+ S1 = handle_reverse_timeout(S, Ref),
124
+ {noreply, S1};
125
+
126
+ handle_info({inet_async, ListenSock, _Ref, {ok, ClientSock}}, #gw_state{listen_sock = ListenSock} = S) ->
127
+ %% Accept new client connection
128
+ inet_db:register_socket(ClientSock, inet_tcp),
129
+ inet:setopts(ClientSock, [{active, once}, {packet, 0}, binary]),
130
+ Ref = make_ref(),
131
+ Client = #client{socket = ClientSock, authed = false},
132
+ Clients = maps:put(Ref, Client, S#gw_state.clients),
133
+ %% Continue accepting
134
+ prim_inet:async_accept(ListenSock, -1),
135
+ {noreply, S#gw_state{clients = Clients}};
136
+
137
+ handle_info({mark_authed, Socket}, S) ->
138
+ %% Mark the client with matching socket as authenticated
139
+ Clients = maps:map(fun(_Ref, #client{socket = Sock} = C) ->
140
+ case Sock =:= Socket of
141
+ true -> C#client{authed = true};
142
+ false -> C
143
+ end
144
+ end, S#gw_state.clients),
145
+ {noreply, S#gw_state{clients = Clients}};
146
+
147
+ handle_info({inet_async, ListenSock, _Ref, {error, _Reason}}, #gw_state{listen_sock = ListenSock} = S) ->
148
+ %% Accept failed, retry
149
+ prim_inet:async_accept(ListenSock, -1),
150
+ {noreply, S};
151
+
152
+ handle_info(_Info, S) ->
153
+ {noreply, S}.
154
+
155
+ terminate(_Reason, #gw_state{listen_sock = ListenSock}) ->
156
+ _ = close_listener(ListenSock),
157
+ ok.
158
+
159
+ %% ---------- Internal: Listener ----------
160
+
161
+ start_listener() ->
162
+ EndpointFile = ssh_infra_sup:generate_endpoint_file(),
163
+ case os:type() of
164
+ {win32, _} ->
165
+ %% On Windows: listen on port 0 (OS assigns free port), then
166
+ %% rewrite endpoint file with the actual port assigned.
167
+ {ok, ListenSock} = gen_tcp:listen(0, [
168
+ {ip, {127, 0, 0, 1}},
169
+ {active, false},
170
+ {packet, 0},
171
+ {reuseaddr, true},
172
+ binary
173
+ ]),
174
+ {ok, Port} = inet:port(ListenSock),
175
+ %% Rewrite endpoint file with the actual port
176
+ {ok, EndpointContent} = file:read_file(EndpointFile),
177
+ [_FirstLine, TokenBin] = binary:split(EndpointContent, <<"\n">>),
178
+ NewEndpoint = <<"tcp://127.0.0.1:", (integer_to_binary(Port))/binary>>,
179
+ file:write_file(EndpointFile, <<NewEndpoint/binary, "\n", TokenBin/binary>>),
180
+ {ok, ListenSock};
181
+ _ ->
182
+ Path = read_socket_path_from_file(EndpointFile),
183
+ _ = file:delete(Path),
184
+ %% On Unix, use gen_tcp on loopback as portable fallback
185
+ {ok, ListenSock} = gen_tcp:listen(0, [{ip, {127, 0, 0, 1}}, {active, false}, {packet, 0}, binary]),
186
+ {ok, Port} = inet:port(ListenSock),
187
+ %% Rewrite endpoint file with TCP port for Ruby to pick up
188
+ {ok, Endpoint} = file:read_file(EndpointFile),
189
+ [_, TokenBin] = binary:split(Endpoint, <<"\n">>),
190
+ file:write_file(EndpointFile, <<"tcp://127.0.0.1:", (integer_to_binary(Port))/binary, "\n", TokenBin/binary>>),
191
+ {ok, ListenSock}
192
+ end.
193
+
194
+ select_free_port() ->
195
+ {ok, Sock} = gen_tcp:listen(0, [{ip, {127, 0, 0, 1}}]),
196
+ {ok, Port} = inet:port(Sock),
197
+ gen_tcp:close(Sock),
198
+ {ok, Port}.
199
+
200
+ close_listener(Sock) ->
201
+ _ = (catch gen_tcp:close(Sock)),
202
+ ok.
203
+
204
+ %% ---------- Internal: Routing ----------
205
+
206
+ build_routes() ->
207
+ #{
208
+ <<"bye">> => {?MODULE, handle_bye},
209
+ <<"engine.ping">> => {?MODULE, handle_ping},
210
+ <<"engine.stats">> => {ssh_ipc_gateway, handle_stats},
211
+ <<"engine.shutdown">> => {?MODULE, handle_shutdown},
212
+ <<"conn.connect">> => {ssh_conn_sup, rpc_connect},
213
+ <<"conn.disconnect">> => {ssh_conn_worker, rpc_disconnect},
214
+ <<"conn.list">> => {ssh_conn_sup, rpc_list},
215
+ <<"conn.reconnect">> => {ssh_conn_worker, rpc_reconnect},
216
+ <<"channel.open">> => {ssh_conn_worker, rpc_channel_open},
217
+ <<"channel.send">> => {ssh_channel_stm, rpc_send},
218
+ <<"channel.close">> => {ssh_channel_stm, rpc_close},
219
+ <<"channel.window_change">> => {ssh_channel_stm, rpc_window_change},
220
+ <<"sftp.open">> => {ssh_sftp_sup, rpc_open},
221
+ <<"sftp.list_dir">> => {ssh_sftp_session, rpc_list_dir},
222
+ <<"sftp.download">> => {ssh_sftp_session, rpc_download},
223
+ <<"sftp.upload">> => {ssh_sftp_session, rpc_upload},
224
+ <<"sftp.mkdir">> => {ssh_sftp_session, rpc_mkdir},
225
+ <<"sftp.remove">> => {ssh_sftp_session, rpc_remove},
226
+ <<"sftp.stat">> => {ssh_sftp_session, rpc_stat},
227
+ <<"portfwd.add">> => {ssh_port_fwd, rpc_add},
228
+ <<"portfwd.remove">> => {ssh_port_fwd, rpc_remove},
229
+ <<"portfwd.list">> => {ssh_port_fwd, rpc_list},
230
+ <<"keepalive.set_interval">> => {ssh_keepalive_mgr, rpc_set_interval},
231
+ <<"keepalive.get_interval">> => {ssh_keepalive_mgr, rpc_get_interval},
232
+ <<"keepalive.get_status">> => {ssh_keepalive_mgr, rpc_get_status}
233
+ }.
234
+
235
+ %% ---------- Internal: Frame handling ----------
236
+
237
+ handle_frame(Socket, FrameBin, S) ->
238
+ case ssh_ipc_proto:decode(FrameBin) of
239
+ {ok, {request_or_response, Msg}} ->
240
+ handle_request_or_response(Socket, Msg, S);
241
+ {ok, {notification, Msg}} ->
242
+ handle_push_msg(Msg, S);
243
+ {error, _Reason} ->
244
+ S
245
+ end.
246
+
247
+ handle_request_or_response(Socket, #{<<"id">> := Id} = Msg, S) ->
248
+ case maps:is_key(<<"method">>, Msg) of
249
+ true ->
250
+ %% This is a Ruby→Erlang request
251
+ Method = maps:get(<<"method">>, Msg),
252
+ case Method of
253
+ <<"hello">> ->
254
+ %% Special: hello authenticates the client socket
255
+ %% Pass auth_token explicitly since spawn doesn't inherit process dict
256
+ AuthToken = S#gw_state.auth_token,
257
+ spawn(fun() ->
258
+ Result = try
259
+ RawResult = handle_hello(AuthToken, maps:get(<<"params">>, Msg)),
260
+ normalize_result(RawResult)
261
+ catch
262
+ Class:Reason:Stacktrace ->
263
+ {error, #{class => atom_to_binary(Class, utf8),
264
+ reason => format_reason(Reason),
265
+ stacktrace => format_stacktrace(Stacktrace)}}
266
+ end,
267
+ send_rpc_response(Socket, Id, Result),
268
+ case Result of
269
+ {ok, _} ->
270
+ %% Signal gateway to mark this client as authed
271
+ ?MODULE ! {mark_authed, Socket};
272
+ _ ->
273
+ ok
274
+ end
275
+ end),
276
+ S;
277
+ _ ->
278
+ dispatch_rpc(Socket, Id, Msg, S)
279
+ end;
280
+ false ->
281
+ %% This is a response to a reverse RPC (Erlang→Ruby)
282
+ handle_reverse_response(Id, Msg, S)
283
+ end.
284
+
285
+ %% @doc Dispatch a Ruby→Erlang RPC request.
286
+ %% Params is optional: if the client omits it, default to an empty map.
287
+ dispatch_rpc(Socket, Id, #{<<"method">> := Method} = Msg, S) ->
288
+ Params = maps:get(<<"params">>, Msg, #{}),
289
+ case maps:get(Method, S#gw_state.routes, undefined) of
290
+ {Module, Fun} ->
291
+ %% Spawn to avoid blocking gateway
292
+ spawn(fun() ->
293
+ Result = try
294
+ RawResult = erlang:apply(Module, Fun, [Params]),
295
+ normalize_result(RawResult)
296
+ catch
297
+ Class:Reason:Stacktrace ->
298
+ {error, #{class => atom_to_binary(Class, utf8),
299
+ reason => format_reason(Reason),
300
+ stacktrace => format_stacktrace(Stacktrace)}}
301
+ end,
302
+ send_rpc_response(Socket, Id, Result)
303
+ end),
304
+ S;
305
+ undefined ->
306
+ send_rpc_error(Socket, Id, -32601, #{message => <<"Method not found">>}),
307
+ S
308
+ end.
309
+
310
+ %% @doc Handle hello — first message must authenticate with token.
311
+ %% AuthToken is passed explicitly from gateway state (not process dict).
312
+ handle_hello(AuthToken, #{<<"auth_token">> := Token, <<"ver">> := _Ver, <<"client_id">> := _ClientId}) ->
313
+ case Token =:= AuthToken of
314
+ true ->
315
+ {ok, #{capabilities => [<<"coalesce">>], ver => ?PROTOCOL_VER}};
316
+ false ->
317
+ {error, auth_failed}
318
+ end.
319
+
320
+ %% @doc Handle bye — clean up.
321
+ handle_bye(_Params) ->
322
+ {ok, #{ok => true}}.
323
+
324
+ %% @doc Handle engine.ping.
325
+ handle_ping(_Params) ->
326
+ {ok, #{ok => true, timestamp => ssh_codec:unix_ms()}}.
327
+
328
+ %% @doc Handle engine.stats.
329
+ handle_stats(_Params) ->
330
+ {Total, _} = erlang:statistics(runtime),
331
+ {ok, #{
332
+ connections => length(ssh_conn_sup:all_workers()),
333
+ uptime_ms => Total
334
+ }}.
335
+
336
+ %% @doc Handle engine.shutdown.
337
+ handle_shutdown(_Params) ->
338
+ spawn(fun() -> timer:sleep(100), init:stop() end),
339
+ {ok, #{ok => true}}.
340
+
341
+ %% ---------- Internal: Send ----------
342
+
343
+ send_notification(S, Method, Params) ->
344
+ Msg = ssh_ipc_proto:frame(ssh_ipc_proto:encode_push(Method, Params)),
345
+ send_to_all_clients(S, Msg).
346
+
347
+ send_rpc_response(Socket, Id, {ok, Result}) ->
348
+ Msg = ssh_ipc_proto:frame(ssh_ipc_proto:encode_response(Id, Result)),
349
+ gen_tcp:send(Socket, Msg);
350
+
351
+ send_rpc_response(Socket, Id, {error, ErrorObj}) ->
352
+ Code = case is_map(ErrorObj) of
353
+ true -> maps:get(<<"code">>, ErrorObj, -32603);
354
+ false -> -32603
355
+ end,
356
+ Msg = ssh_ipc_proto:frame(ssh_ipc_proto:encode_error(Id, Code, ErrorObj)),
357
+ gen_tcp:send(Socket, Msg).
358
+
359
+ send_rpc_error(Socket, Id, Code, ErrorObj) ->
360
+ Msg = ssh_ipc_proto:frame(ssh_ipc_proto:encode_error(Id, Code, ErrorObj)),
361
+ gen_tcp:send(Socket, Msg).
362
+
363
+ send_to_all_clients(S, Msg) ->
364
+ case maps:size(S#gw_state.clients) of
365
+ 0 -> {error, no_clients};
366
+ _ ->
367
+ maps:fold(fun(_Ref, #client{socket = Sock}, _) ->
368
+ gen_tcp:send(Sock, Msg)
369
+ end, ok, S#gw_state.clients),
370
+ ok
371
+ end.
372
+
373
+ %% ---------- Internal: Client management ----------
374
+
375
+ %% @doc Remove a client by socket from the clients map.
376
+ remove_client(S, Socket) ->
377
+ NewClients = maps:filter(fun(_Ref, #client{socket = Sock}) -> Sock =/= Socket end,
378
+ S#gw_state.clients),
379
+ S#gw_state{clients = NewClients}.
380
+
381
+ %% ---------- Internal: Reverse RPC ----------
382
+
383
+ generate_reverse_id() ->
384
+ erlang:unique_integer([positive]).
385
+
386
+ %% @doc Store a pending reverse RPC for response matching.
387
+ %% Uses process dictionary: {reverse_pending, ReverseId} => {From, Ref}
388
+ store_reverse_pending(_S, ReverseId, From, Ref) ->
389
+ put({reverse_pending, ReverseId}, {From, Ref}),
390
+ _S.
391
+
392
+ %% @doc Handle reverse RPC timeout.
393
+ handle_reverse_timeout(S, Ref) ->
394
+ %% Find the pending entry by Ref and reply timeout
395
+ Keys = [K || K <- get_keys_starting_with(reverse_pending), element(2, get(K)) =:= Ref],
396
+ lists:foreach(fun(K) ->
397
+ case get(K) of
398
+ {From, Ref} ->
399
+ gen_server:reply(From, {error, timeout}),
400
+ erase(K)
401
+ end
402
+ end, Keys),
403
+ S.
404
+
405
+ %% @doc Handle reverse RPC response from Ruby.
406
+ handle_reverse_response(Id, Msg, S) ->
407
+ case get({reverse_pending, Id}) of
408
+ {From, _Ref} ->
409
+ Result = case maps:get(<<"result">>, Msg, undefined) of
410
+ undefined -> {error, maps:get(<<"error">>, Msg, #{})};
411
+ R -> {ok, R}
412
+ end,
413
+ gen_server:reply(From, Result),
414
+ erase({reverse_pending, Id}),
415
+ S;
416
+ undefined ->
417
+ S
418
+ end.
419
+
420
+ handle_push_msg(_Msg, S) ->
421
+ S.
422
+
423
+ %% ---------- Internal: Result normalization ----------
424
+
425
+ %% @doc Normalize Erlang results to JSON-encodable maps.
426
+ normalize_result({ok, Result}) when is_map(Result) ->
427
+ {ok, Result};
428
+ normalize_result({ok, true}) ->
429
+ {ok, #{ok => true}};
430
+ normalize_result({ok, Value}) ->
431
+ {ok, #{ok => Value}};
432
+ normalize_result({error, Reason}) ->
433
+ {error, #{message => format_reason(Reason)}};
434
+ normalize_result(ok) ->
435
+ {ok, #{ok => true}};
436
+ normalize_result(Other) ->
437
+ {ok, #{ok => Other}}.
438
+
439
+ format_reason(Reason) when is_atom(Reason) ->
440
+ atom_to_binary(Reason, utf8);
441
+ format_reason(Reason) when is_binary(Reason) ->
442
+ Reason;
443
+ format_reason(Reason) ->
444
+ iolist_to_binary(io_lib:format("~p", [Reason])).
445
+
446
+ format_stacktrace(Stacktrace) ->
447
+ [iolist_to_binary(io_lib:format("~p", [Entry])) || Entry <- Stacktrace].
448
+
449
+ %% ---------- Internal: Helpers ----------
450
+ read_port_from_file(File) ->
451
+ {ok, Content} = file:read_file(File),
452
+ [<<"tcp://127.0.0.1:", Port/binary>> | _] = binary:split(Content, <<"\n">>),
453
+ {ok, binary_to_integer(Port)}.
454
+
455
+ read_socket_path_from_file(File) ->
456
+ {ok, Content} = file:read_file(File),
457
+ [EndpointLine | _] = binary:split(Content, <<"\n">>),
458
+ %% Extract path from "unix:/path" format
459
+ case EndpointLine of
460
+ <<"unix:", Path/binary>> -> Path;
461
+ _ -> <<"/tmp/ssh_core.sock">>
462
+ end.
463
+
464
+ %% @doc Get all process dictionary keys starting with given prefix atom.
465
+ get_keys_starting_with(PrefixAtom) ->
466
+ {dictionary, Dict} = process_info(self(), dictionary),
467
+ [K || K <- Dict, is_tuple(K), element(1, K) =:= PrefixAtom].
@@ -0,0 +1,95 @@
1
+ -module(ssh_ipc_proto).
2
+ %% JSON-RPC 2.0 protocol encode/decode and framing.
3
+ %% Pure module — no network I/O, no state.
4
+
5
+ -export([
6
+ encode_request/3,
7
+ encode_response/2,
8
+ encode_error/3,
9
+ encode_push/2,
10
+ decode/1,
11
+ frame/1,
12
+ unframe/1
13
+ ]).
14
+
15
+ %% ---------- Encoding ----------
16
+
17
+ %% @doc Encode a JSON-RPC request.
18
+ -spec encode_request(integer(), binary(), map()) -> binary().
19
+ encode_request(Id, Method, Params) ->
20
+ ssh_codec:encode_json(#{
21
+ <<"jsonrpc">> => <<"2.0">>,
22
+ <<"id">> => Id,
23
+ <<"method">> => Method,
24
+ <<"params">> => Params
25
+ }).
26
+
27
+ %% @doc Encode a successful response.
28
+ -spec encode_response(integer(), map()) -> binary().
29
+ encode_response(Id, Result) ->
30
+ ssh_codec:encode_json(#{
31
+ <<"jsonrpc">> => <<"2.0">>,
32
+ <<"id">> => Id,
33
+ <<"result">> => Result
34
+ }).
35
+
36
+ %% @doc Encode an error response.
37
+ -spec encode_error(integer(), integer(), map()) -> binary().
38
+ encode_error(Id, Code, ErrorObj) ->
39
+ ssh_codec:encode_json(#{
40
+ <<"jsonrpc">> => <<"2.0">>,
41
+ <<"id">> => Id,
42
+ <<"error">> => ErrorObj#{<<"code">> => Code}
43
+ }).
44
+
45
+ %% @doc Encode a push notification (no id).
46
+ -spec encode_push(binary(), map()) -> binary().
47
+ encode_push(Method, Params) ->
48
+ ssh_codec:encode_json(#{
49
+ <<"jsonrpc">> => <<"2.0">>,
50
+ <<"method">> => Method,
51
+ <<"params">> => Params
52
+ }).
53
+
54
+ %% ---------- Decoding ----------
55
+
56
+ %% @doc Decode a single JSON-RPC message (binary, no delimiter).
57
+ %% Returns {ok, Map} | {error, Reason}.
58
+ -spec decode(binary()) -> {ok, map()} | {error, term()}.
59
+ decode(Bin) ->
60
+ case ssh_codec:decode_json(Bin) of
61
+ {ok, Map} when is_map(Map) -> validate_msg(Map);
62
+ {error, _} = E -> E
63
+ end.
64
+
65
+ %% ---------- Framing ----------
66
+
67
+ %% @doc Append newline delimiter to a message.
68
+ -spec frame(binary()) -> binary().
69
+ frame(Msg) ->
70
+ <<Msg/binary, "\n">>.
71
+
72
+ %% @doc Split a buffer on newline delimiters.
73
+ %% Returns {[MessageBinaries], RemainingBuffer}.
74
+ -spec unframe(binary()) -> {[binary()], binary()}.
75
+ unframe(Buffer) ->
76
+ split_frames(Buffer, []).
77
+
78
+ %% ---------- Internal ----------
79
+
80
+ validate_msg(#{<<"jsonrpc">> := <<"2.0">>} = Msg) ->
81
+ case maps:is_key(<<"id">>, Msg) of
82
+ true -> {ok, {request_or_response, Msg}};
83
+ false -> {ok, {notification, Msg}}
84
+ end;
85
+ validate_msg(_) ->
86
+ {error, invalid_jsonrpc}.
87
+
88
+ split_frames(Buffer, Acc) ->
89
+ case binary:match(Buffer, <<"\n">>) of
90
+ {Pos, _} ->
91
+ <<Frame:Pos/binary, _:8, Rest/binary>> = Buffer,
92
+ split_frames(Rest, [Frame | Acc]);
93
+ nomatch ->
94
+ {lists:reverse(Acc), Buffer}
95
+ end.
@@ -0,0 +1,101 @@
1
+ -module(ssh_jump_chain).
2
+
3
+ %% Jump chain builder — constructs multi-level ProxyJump SSH connections.
4
+ %%
5
+ %% Key design (LLD §6.7, E3/E6 fixes):
6
+ %% - OTP ssh has no ssh:connect_via/2 — cannot stack SSH on existing channel
7
+ %% - V1.0 uses {sock_fun, Fun} option for tunneled connections
8
+ %% - Each jump opens a direct_tcpip channel on the previous SSH connection
9
+ %% - The channel is wrapped as a socket-like Fun for the next ssh:connect
10
+ %% - V1.0 priority: single-hop jump (1 level); multi-hop is V2.0
11
+ %%
12
+ %% Risk: {sock_fun, Fun} is semi-official in OTP 26. Signature:
13
+ %% Fun(connect, Host, Port, Timeout) -> {ok, Sock} | {error, _}
14
+ %% Fun(close, Sock) -> ok
15
+ %% Fun(send, Sock, Data) -> ok | {error, _}
16
+ %% Fun(recv, Sock, Len, Timeout) -> {ok, Data} | {error, _}
17
+
18
+ -export([
19
+ build/2,
20
+ direct_connect/1,
21
+ tunneled_connect/2,
22
+ make_sock_fun_from_channel/2
23
+ ]).
24
+
25
+ -include("ssh_ipc.hrl").
26
+
27
+ %% ---------- Public API ----------
28
+
29
+ %% @doc Build a multi-level jump chain connection.
30
+ %% Jumps = [J1, J2, ...] from nearest to farthest.
31
+ %% Algorithm:
32
+ %% 1. Connect to J1 directly
33
+ %% 2. For J2..Jn: open direct_tcpip on previous SSH ref, wrap as sock_fun
34
+ %% 3. On final jump: direct_tcpip to target, ssh:connect via sock_fun
35
+ %%
36
+ %% Returns {ok, FinalSshRef} | {error, Reason}
37
+ -spec build(map(), [map()]) -> {ok, reference()} | {error, term()}.
38
+ build(TargetSpec, []) ->
39
+ %% No jumps — direct connect
40
+ direct_connect(TargetSpec);
41
+ build(TargetSpec, Jumps) ->
42
+ case lists:foldl(fun(Jump, {ok, PrevRef}) ->
43
+ tunneled_connect(PrevRef, Jump);
44
+ (_, {error, _} = E) -> E
45
+ end, direct_connect(hd(Jumps)), tl(Jumps)) of
46
+ {ok, LastJumpRef} -> tunneled_connect(LastJumpRef, TargetSpec);
47
+ {error, _} = E -> E
48
+ end.
49
+
50
+ %% @doc Direct SSH connection (no tunnel).
51
+ -spec direct_connect(map()) -> {ok, reference()} | {error, term()}.
52
+ direct_connect(Spec) ->
53
+ Host = binary_to_list(maps:get(<<"host">>, Spec)),
54
+ Port = maps:get(<<"port">>, Spec, 22),
55
+ Opts = ssh_conn_worker:build_ssh_options(Spec),
56
+ ssh:connect(Host, Port, Opts, ?CONNECT_TIMEOUT_MS).
57
+
58
+ %% @doc Connect through an existing SSH tunnel (previous jump).
59
+ %% Opens a direct_tcpip channel on PrevRef, then runs ssh:connect
60
+ %% with a custom {sock_fun} that wraps the channel.
61
+ -spec tunneled_connect(reference(), map()) -> {ok, reference()} | {error, term()}.
62
+ tunneled_connect(PrevRef, Spec) ->
63
+ Host = binary_to_list(maps:get(<<"host">>, Spec)),
64
+ Port = maps:get(<<"port">>, Spec, 22),
65
+ %% Open direct_tcpip channel on the previous SSH connection
66
+ case ssh_connection:direct_tcpip(PrevRef, Host, Port, "127.0.0.1", 0, ?CONNECT_TIMEOUT_MS) of
67
+ {ok, Chan} ->
68
+ SockFun = make_sock_fun_from_channel(PrevRef, Chan),
69
+ Opts = ssh_conn_worker:build_ssh_options(Spec) ++ [{sock_fun, SockFun}],
70
+ ssh:connect(Host, Port, Opts, ?CONNECT_TIMEOUT_MS);
71
+ {error, Reason} ->
72
+ {error, {direct_tcpip_failed, Reason}}
73
+ end.
74
+
75
+ %% @doc Wrap an SSH direct_tcpip channel as a sock_fun for ssh:connect.
76
+ %% This is the highest-risk component in V1.0 — the channel must emulate
77
+ %% a TCP socket's connect/close/send/recv semantics.
78
+ -spec make_sock_fun_from_channel(reference(), integer()) -> function().
79
+ make_sock_fun_from_channel(SshRef, ChanId) ->
80
+ %% OTP ssh sock_fun calls different operations with different arities.
81
+ %% We wrap them all through a single 4-arg dispatch:
82
+ %% (connect, Host, Port, Timeout) -> {ok, Sock}
83
+ %% (close, Sock, _, _) -> ok
84
+ %% (send, Sock, Data, _) -> ok | {error,_}
85
+ %% (recv, Sock, Len, Timeout) -> {ok, Data} | {error,_}
86
+ fun
87
+ (connect, _Host, _Port, _Timeout) ->
88
+ {ok, {SshRef, ChanId}};
89
+ (close, _Sock, _A3, _A4) ->
90
+ ssh_connection:close(SshRef, ChanId),
91
+ ok;
92
+ (send, _Sock, Data, _A4) ->
93
+ ssh_connection:send(SshRef, ChanId, Data);
94
+ (recv, _Sock, _Len, Timeout) ->
95
+ receive
96
+ {ssh_cm, SshRef, {data, ChanId, _Type, Data}} ->
97
+ {ok, Data}
98
+ after Timeout ->
99
+ {error, timeout}
100
+ end
101
+ end.