network-infra-utility 0.2.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/CHANGELOG.md +31 -4
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +70 -0
  6. data/Rakefile +1 -1
  7. data/bin/dns-query +834 -0
  8. data/bin/geo-doc +135 -0
  9. data/bin/geo-get +1 -1
  10. data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
  11. data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
  12. 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
  13. data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
  14. data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
  15. 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
  16. data/network-infra-utility.gemspec +4 -2
  17. data/network.rb +3 -1
  18. data/service/geodb/GeoAPI.md +1 -0
  19. data/service/geodb/geodb.rb +278 -1
  20. data/service/ssh/README.md +942 -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 +104 -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 +138 -1
@@ -0,0 +1,222 @@
1
+ -module(ssh_keepalive_mgr).
2
+ -behaviour(gen_server).
3
+
4
+ %% Keepalive manager — periodically checks all active connections.
5
+ %%
6
+ %% Key design (LLD §6.9):
7
+ %% - Periodically sends SSH keepalive to all ready connections
8
+ %% - 3 consecutive failures → push conn.closed (NOT autonomous reconnection)
9
+ %% - Reconnect decision is Ruby's, via conn.reconnect RPC
10
+ %% - Runs as permanent worker in ssh_infra_sup
11
+ %%
12
+ %% Activity-aware keepalive (enhancement):
13
+ %% - Tracks last_activity timestamp per connection
14
+ %% - If data flowed since last check, skip keepalive and reset fail count
15
+ %% - notify_activity/1 called by channel_stm on send/recv
16
+ %% - This prevents unnecessary keepalive on active connections
17
+
18
+ -export([start_link/1, set_interval/1, get_interval/0, notify_activity/1, get_status/0,
19
+ rpc_set_interval/1, rpc_get_interval/1, rpc_get_status/1]).
20
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
21
+
22
+ -include("ssh_ipc.hrl").
23
+
24
+ -record(st, {
25
+ interval_ms :: pos_integer(),
26
+ fail_counts = #{} :: #{pid() => non_neg_integer()},
27
+ last_activity = #{} :: #{pid() => erlang:timestamp()},
28
+ last_check = #{} :: #{pid() => erlang:timestamp()}
29
+ }).
30
+
31
+ %% ---------- Public API ----------
32
+
33
+ %% @doc Start the keepalive manager with the given interval (ms).
34
+ start_link(IntervalMs) ->
35
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [IntervalMs], []).
36
+
37
+ %% @doc Update the keepalive interval at runtime.
38
+ set_interval(NewMs) ->
39
+ gen_server:cast(?MODULE, {set_interval, NewMs}).
40
+
41
+ %% @doc Get the current interval.
42
+ get_interval() ->
43
+ gen_server:call(?MODULE, get_interval).
44
+
45
+ %% @doc Notify that activity occurred on a connection (data sent/received).
46
+ %% Resets the fail counter and updates the last_activity timestamp.
47
+ %% Called by ssh_channel_stm when data flows through a channel.
48
+ -spec notify_activity(pid()) -> ok.
49
+ notify_activity(ConnWorkerPid) ->
50
+ gen_server:cast(?MODULE, {notify_activity, ConnWorkerPid}).
51
+
52
+ %% @doc Get keepalive status for all connections (for debugging/monitoring).
53
+ get_status() ->
54
+ gen_server:call(?MODULE, get_status).
55
+
56
+ %% ---------- gen_server callbacks ----------
57
+
58
+ init([IntervalMs]) ->
59
+ schedule_tick(IntervalMs),
60
+ {ok, #st{interval_ms = IntervalMs}}.
61
+
62
+ handle_call(get_interval, _From, #st{interval_ms = Ms} = S) ->
63
+ {reply, Ms, S};
64
+
65
+ handle_call(get_status, _From, #st{fail_counts = Fails, last_activity = Acts} = S) ->
66
+ Workers = ssh_conn_sup:all_workers(),
67
+ Status = lists:map(fun(Pid) ->
68
+ #{
69
+ pid => Pid,
70
+ conn_id => catch ssh_conn_worker:conn_id(Pid),
71
+ state => catch ssh_conn_worker:get_state_name(Pid),
72
+ fail_count => maps:get(Pid, Fails, 0),
73
+ last_activity => maps:get(Pid, Acts, undefined)
74
+ }
75
+ end, Workers),
76
+ {reply, Status, S};
77
+
78
+ handle_call(_Req, _From, S) ->
79
+ {reply, {error, not_implemented}, S}.
80
+
81
+ handle_cast({set_interval, NewMs}, #st{} = S) ->
82
+ {noreply, S#st{interval_ms = NewMs}};
83
+
84
+ handle_cast({notify_activity, ConnWorkerPid}, #st{last_activity = Acts, fail_counts = Fails} = S) ->
85
+ %% Record activity timestamp and reset fail count for this connection
86
+ NewActs = maps:put(ConnWorkerPid, os:timestamp(), Acts),
87
+ NewFails = maps:remove(ConnWorkerPid, Fails),
88
+ {noreply, S#st{last_activity = NewActs, fail_counts = NewFails}};
89
+
90
+ handle_cast(_Msg, S) ->
91
+ {noreply, S}.
92
+
93
+ handle_info(tick, #st{interval_ms = Ms, fail_counts = Fails, last_activity = Acts, last_check = Checks} = S) ->
94
+ %% Scan all connection workers
95
+ Workers = ssh_conn_sup:all_workers(),
96
+ %% Record check time for each worker
97
+ Now = os:timestamp(),
98
+ Checks1 = lists:foldl(fun(Pid, Acc) -> maps:put(Pid, Now, Acc) end, Checks, Workers),
99
+ %% Check each worker, passing the *previous* check timestamps for comparison
100
+ {NewFails, NewActs} = lists:foldl(fun(Pid, {FAcc, AAcc}) ->
101
+ check_keepalive(Pid, FAcc, AAcc, Checks)
102
+ end, {Fails, Acts}, Workers),
103
+ %% Clean up dead workers from maps
104
+ {CleanFails, CleanActs} = cleanup_maps(Workers, NewFails, NewActs),
105
+ schedule_tick(Ms),
106
+ {noreply, S#st{fail_counts = CleanFails, last_activity = CleanActs, last_check = Checks1}};
107
+
108
+ handle_info(_Info, S) ->
109
+ {noreply, S}.
110
+
111
+ terminate(_Reason, _S) ->
112
+ ok.
113
+
114
+ %% ---------- Internal ----------
115
+
116
+ schedule_tick(IntervalMs) ->
117
+ erlang:send_after(IntervalMs, self(), tick),
118
+ ok.
119
+
120
+ %% @doc Check a single connection's keepalive.
121
+ %% Activity-aware: if data flowed since last check, skip keepalive and reset fail count.
122
+ %% Args: Pid, fail_counts, last_activity, last_check (previous check timestamps map)
123
+ %% Returns {updated_fail_counts, updated_last_activity}.
124
+ check_keepalive(Pid, Fails, Acts, Checks) ->
125
+ case catch ssh_conn_worker:get_state_name(Pid) of
126
+ ready ->
127
+ LastActivity = maps:get(Pid, Acts, undefined),
128
+ LastCheck = maps:get(Pid, Checks, undefined),
129
+ %% Check if there was activity since we last checked
130
+ case was_active_since(LastActivity, LastCheck) of
131
+ true ->
132
+ %% Connection was active since last check — skip keepalive, reset fails
133
+ {maps:remove(Pid, Fails), Acts};
134
+ false ->
135
+ %% No recent activity — send keepalive
136
+ case send_keepalive(Pid) of
137
+ ok ->
138
+ {maps:remove(Pid, Fails), Acts};
139
+ {error, _} ->
140
+ Count = maps:get(Pid, Fails, 0) + 1,
141
+ case Count >= ?KEEPALIVE_DEFAULT_MAX_FAIL of
142
+ true ->
143
+ %% 3 consecutive failures — notify Ruby
144
+ ConnId = (catch ssh_conn_worker:conn_id(Pid)),
145
+ ssh_ipc_gateway:push_event(<<"conn.closed">>, #{
146
+ <<"conn_id">> => ConnId,
147
+ <<"reason">> => <<"keepalive_failed">>
148
+ }),
149
+ {maps:remove(Pid, Fails), maps:remove(Pid, Acts)};
150
+ false ->
151
+ {maps:put(Pid, Count, Fails), Acts}
152
+ end
153
+ end
154
+ end;
155
+ _ ->
156
+ %% Not in ready state — skip
157
+ {Fails, Acts}
158
+ end.
159
+
160
+ %% @doc Check if last_activity is more recent than last_check.
161
+ %% Returns true if activity occurred since the last keepalive check.
162
+ %% If last_check is undefined (first check), activity doesn't matter — we still send keepalive.
163
+ was_active_since(undefined, _LastCheck) ->
164
+ false;
165
+ was_active_since(_LastActivity, undefined) ->
166
+ %% First check, no previous check time — don't skip
167
+ false;
168
+ was_active_since(LastActivity, LastCheck) ->
169
+ %% Compare timestamps: if LastActivity > LastCheck, there was activity
170
+ try
171
+ Diff = timer:now_diff(LastActivity, LastCheck),
172
+ Diff > 0
173
+ catch
174
+ _:_ -> false
175
+ end.
176
+
177
+ %% @doc Remove entries for workers that no longer exist.
178
+ cleanup_maps(Workers, Fails, Acts) ->
179
+ WorkerSet = sets:from_list(Workers),
180
+ CleanFails = maps:filter(fun(Pid, _) -> sets:is_element(Pid, WorkerSet) end, Fails),
181
+ CleanActs = maps:filter(fun(Pid, _) -> sets:is_element(Pid, WorkerSet) end, Acts),
182
+ {CleanFails, CleanActs}.
183
+
184
+ %% @doc Send a keepalive packet on an SSH connection.
185
+ send_keepalive(Pid) ->
186
+ try
187
+ SshRef = ssh_conn_worker:get_ssh_ref(Pid),
188
+ %% OTP ssh: ssh:send_keepalive/1 — returns ok | {error, Reason}
189
+ ssh:send_keepalive(SshRef)
190
+ catch
191
+ _:_ -> {error, keepalive_exception}
192
+ end.
193
+
194
+ %% ---------- RPC handlers ----------
195
+
196
+ %% @doc RPC handler for keepalive.set_interval
197
+ rpc_set_interval(#{<<"interval_ms">> := IntervalMs}) ->
198
+ set_interval(IntervalMs),
199
+ #{ok => true}.
200
+
201
+ %% @doc RPC handler for keepalive.get_interval
202
+ rpc_get_interval(_Params) ->
203
+ IntervalMs = get_interval(),
204
+ #{interval_ms => IntervalMs}.
205
+
206
+ %% @doc RPC handler for keepalive.get_status
207
+ rpc_get_status(_Params) ->
208
+ Status = get_status(),
209
+ %% Convert records to maps for JSON encoding
210
+ lists:map(fun(Entry) ->
211
+ maps:map(fun
212
+ (pid, Pid) -> list_to_binary(pid_to_list(Pid));
213
+ (conn_id, V) when is_binary(V) -> V;
214
+ (conn_id, _) -> null;
215
+ (state, V) when is_atom(V) -> atom_to_binary(V, utf8);
216
+ (state, _) -> null;
217
+ (fail_count, V) -> V;
218
+ (last_activity, {Mega, Sec, Micro}) ->
219
+ list_to_binary(io_lib:format("~w,~w,~w", [Mega, Sec, Micro]));
220
+ (last_activity, _) -> null
221
+ end, Entry)
222
+ end, Status).
@@ -0,0 +1,67 @@
1
+ -module(ssh_known_hosts_proxy).
2
+ -behaviour(gen_server).
3
+
4
+ %% Known hosts proxy — mediates host key verification between OTP ssh and Ruby.
5
+ %%
6
+ %% Key design (LLD §6.10, E7 fix):
7
+ %% - Erlang does NOT persist known_hosts — Ruby handles storage
8
+ %% - ssh_auth_engine key_cb calls verify/3 which queries Ruby via IPC
9
+ %% - Uses synchronous_push (blocking reverse RPC) with 30s timeout
10
+ %% - If Ruby doesn't respond in 30s → reject (safe default)
11
+ %% - Returns: accepted | accepted_once | rejected
12
+
13
+ -export([
14
+ start_link/0,
15
+ verify/3
16
+ ]).
17
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
18
+
19
+ -include("ssh_ipc.hrl").
20
+
21
+ -record(st, {}).
22
+
23
+ %% ---------- Public API ----------
24
+
25
+ %% @doc Start the known_hosts proxy.
26
+ start_link() ->
27
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
28
+
29
+ %% @doc Verify a host key by querying Ruby via IPC.
30
+ %% Called by ssh_auth_engine's key_cb callbacks.
31
+ %% Blocks until Ruby responds or times out.
32
+ -spec verify(string(), inet:port_number(), public_key:public_key()) ->
33
+ accepted | accepted_once | rejected.
34
+ verify(Host, Port, Key) ->
35
+ Fingerprint = ssh_codec:fingerprint(Key),
36
+ Req = #{
37
+ <<"method">> => <<"hostkey.resolve">>,
38
+ <<"params">> => #{
39
+ <<"host">> => list_to_binary(Host),
40
+ <<"port">> => Port,
41
+ <<"fingerprint">> => Fingerprint
42
+ }
43
+ },
44
+ case ssh_ipc_gateway:synchronous_push(Req, ?HOSTKEY_RESOLVE_TIMEOUT_MS) of
45
+ {ok, #{<<"action">> := <<"accept">>}} -> accepted;
46
+ {ok, #{<<"action">> := <<"once">>}} -> accepted_once;
47
+ {ok, #{<<"action">> := <<"reject">>}} -> rejected;
48
+ {error, timeout} -> rejected;
49
+ {error, _} -> rejected
50
+ end.
51
+
52
+ %% ---------- gen_server callbacks ----------
53
+
54
+ init([]) ->
55
+ {ok, #st{}}.
56
+
57
+ handle_call(_Req, _From, S) ->
58
+ {reply, {error, not_implemented}, S}.
59
+
60
+ handle_cast(_Msg, S) ->
61
+ {noreply, S}.
62
+
63
+ handle_info(_Info, S) ->
64
+ {noreply, S}.
65
+
66
+ terminate(_Reason, _S) ->
67
+ ok.
@@ -0,0 +1,225 @@
1
+ -module(ssh_port_fwd).
2
+ -behaviour(gen_server).
3
+
4
+ %% Port forwarding manager — manages local/remote/dynamic port forward rules.
5
+ %%
6
+ %% Key design (LLD §6.8, E4 fix):
7
+ %% - Local forward: gen_tcp:listen + ssh_connection:direct_tcpip
8
+ %% - Remote forward: ssh_connection:tcpip_forward/3
9
+ %% - Dynamic (SOCKS5): Local forward + self-implemented SOCKS5 negotiation (V2.0)
10
+ %%
11
+ %% One gen_server per connection, manages all rules for that connection.
12
+
13
+ -export([
14
+ start_link/1,
15
+ rpc_add/1,
16
+ rpc_remove/1,
17
+ rpc_list/1
18
+ ]).
19
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
20
+
21
+ -include("ssh_ipc.hrl").
22
+
23
+ -record(fwd_rule, {
24
+ id :: binary(),
25
+ type :: local | remote | dynamic,
26
+ local_port :: pos_integer(),
27
+ remote_host :: binary() | undefined,
28
+ remote_port :: pos_integer() | undefined,
29
+ enabled = true :: boolean(),
30
+ listener_pid :: pid() | undefined
31
+ }).
32
+
33
+ -record(st, {
34
+ conn_id :: binary(),
35
+ ssh_ref :: reference(),
36
+ rules = #{} :: #{binary() => #fwd_rule{}},
37
+ next_seq = 1 :: pos_integer()
38
+ }).
39
+
40
+ %% ---------- Public API ----------
41
+
42
+ %% @doc Start a port forwarding manager for a connection.
43
+ start_link(#{conn_id := ConnId, ssh_ref := SshRef}) ->
44
+ gen_server:start_link(?MODULE, [ConnId, SshRef], []).
45
+
46
+ %% @doc RPC handler for portfwd.add
47
+ rpc_add(#{<<"conn_id">> := ConnId, <<"type">> := TypeBin,
48
+ <<"local_port">> := LocalPort} = Params) ->
49
+ case find_mgr(ConnId) of
50
+ {ok, Pid} ->
51
+ gen_server:call(Pid, {add, #{
52
+ type => binary_to_atom(TypeBin, utf8),
53
+ local_port => LocalPort,
54
+ remote_host => maps:get(<<"remote_host">>, Params, undefined),
55
+ remote_port => maps:get(<<"remote_port">>, Params, undefined)
56
+ }});
57
+ {error, not_found} ->
58
+ {error, conn_not_found}
59
+ end.
60
+
61
+ %% @doc RPC handler for portfwd.list
62
+ rpc_list(#{<<"conn_id">> := ConnId}) ->
63
+ case find_mgr(ConnId) of
64
+ {ok, Pid} ->
65
+ gen_server:call(Pid, list);
66
+ {error, not_found} ->
67
+ %% No forwarding manager started for this connection yet
68
+ {ok, []}
69
+ end.
70
+
71
+ %% @doc RPC handler for portfwd.remove
72
+ rpc_remove(#{<<"conn_id">> := ConnId, <<"rule_id">> := RuleId}) ->
73
+ case find_mgr(ConnId) of
74
+ {ok, Pid} ->
75
+ gen_server:call(Pid, {remove, RuleId});
76
+ {error, not_found} ->
77
+ {error, conn_not_found}
78
+ end.
79
+
80
+ %% ---------- gen_server callbacks ----------
81
+
82
+ init([ConnId, SshRef]) ->
83
+ {ok, #st{conn_id = ConnId, ssh_ref = SshRef}}.
84
+
85
+ handle_call({add, #{type := local, local_port := LocalPort,
86
+ remote_host := RHost, remote_port := RPort}}, _From, S) ->
87
+ RuleId = gen_rule_id(S),
88
+ Rule = #fwd_rule{id = RuleId, type = local, local_port = LocalPort,
89
+ remote_host = RHost, remote_port = RPort},
90
+ case start_local_listener(S#st.ssh_ref, Rule) of
91
+ {ok, ListenerPid} ->
92
+ Rule1 = Rule#fwd_rule{listener_pid = ListenerPid},
93
+ Rules = maps:put(RuleId, Rule1, S#st.rules),
94
+ {reply, {ok, #{rule_id => RuleId}}, S#st{rules = Rules, next_seq = S#st.next_seq + 1}};
95
+ {error, Reason} ->
96
+ {reply, {error, Reason}, S}
97
+ end;
98
+
99
+ handle_call({add, #{type := remote, local_port := RemotePort,
100
+ remote_host := LHost, remote_port := LPort}}, _From, S) ->
101
+ RuleId = gen_rule_id(S),
102
+ case ssh_connection:tcpip_forward(S#st.ssh_ref,
103
+ binary_to_list(LHost), LPort, RemotePort) of
104
+ ok ->
105
+ Rule = #fwd_rule{id = RuleId, type = remote, local_port = RemotePort,
106
+ remote_host = LHost, remote_port = LPort},
107
+ Rules = maps:put(RuleId, Rule, S#st.rules),
108
+ {reply, {ok, #{rule_id => RuleId}}, S#st{rules = Rules, next_seq = S#st.next_seq + 1}};
109
+ {error, Reason} ->
110
+ {reply, {error, Reason}, S}
111
+ end;
112
+
113
+ handle_call({add, #{type := dynamic, local_port := LocalPort}}, _From, S) ->
114
+ %% SOCKS5 dynamic forward — V2.0
115
+ {reply, {error, not_implemented_v1}, S};
116
+
117
+ handle_call({remove, RuleId}, _From, S) ->
118
+ case maps:get(RuleId, S#st.rules, undefined) of
119
+ #fwd_rule{listener_pid = LPid} = _Rule when is_pid(LPid) ->
120
+ ok = stop_local_listener(LPid);
121
+ _ ->
122
+ ok
123
+ end,
124
+ Rules = maps:remove(RuleId, S#st.rules),
125
+ {reply, {ok, true}, S#st{rules = Rules}};
126
+
127
+ handle_call(list, _From, S) ->
128
+ Items = maps:fold(fun(RuleId, R, Acc) ->
129
+ [#{rule_id => RuleId,
130
+ type => atom_to_binary(R#fwd_rule.type, utf8),
131
+ local_port => R#fwd_rule.local_port,
132
+ remote_host => R#fwd_rule.remote_host,
133
+ remote_port => R#fwd_rule.remote_port,
134
+ enabled => R#fwd_rule.enabled} | Acc]
135
+ end, [], S#st.rules),
136
+ {reply, {ok, Items}, S};
137
+
138
+ handle_call(_Req, _From, S) ->
139
+ {reply, {error, not_implemented}, S}.
140
+
141
+ handle_cast(_Msg, S) ->
142
+ {noreply, S}.
143
+
144
+ handle_info(_Info, S) ->
145
+ {noreply, S}.
146
+
147
+ terminate(_Reason, _S) ->
148
+ ok.
149
+
150
+ %% ---------- Internal ----------
151
+
152
+ gen_rule_id(S) ->
153
+ ConnIdShort = binary:part(S#st.conn_id, {0, erlang:min(byte_size(S#st.conn_id), 16)}),
154
+ <<"fwd_", ConnIdShort/binary, "_", (integer_to_binary(S#st.next_seq))/binary>>.
155
+
156
+ %% @doc Start a local TCP listener that forwards to remote via SSH direct_tcpip.
157
+ start_local_listener(SshRef, #fwd_rule{local_port = Port,
158
+ remote_host = RHost,
159
+ remote_port = RPort}) ->
160
+ case gen_tcp:listen(Port, [{ip, {127, 0, 0, 1}}, {active, false},
161
+ {reuseaddr, true}]) of
162
+ {ok, ListenSock} ->
163
+ Pid = spawn_link(fun() ->
164
+ local_listener_loop(ListenSock, SshRef,
165
+ binary_to_list(RHost), RPort)
166
+ end),
167
+ {ok, Pid};
168
+ {error, Reason} ->
169
+ {error, Reason}
170
+ end.
171
+
172
+ local_listener_loop(ListenSock, SshRef, RHost, RPort) ->
173
+ case gen_tcp:accept(ListenSock) of
174
+ {ok, ClientSock} ->
175
+ spawn(fun() -> handle_local_forward(ClientSock, SshRef, RHost, RPort) end),
176
+ local_listener_loop(ListenSock, SshRef, RHost, RPort);
177
+ {error, _} ->
178
+ ok
179
+ end.
180
+
181
+ handle_local_forward(ClientSock, SshRef, RHost, RPort) ->
182
+ case ssh_connection:direct_tcpip(SshRef, RHost, RPort, "127.0.0.1", 0, ?RPC_DEFAULT_TIMEOUT_MS) of
183
+ {ok, ChanId} ->
184
+ inet:setopts(ClientSock, [{active, once}]),
185
+ forward_loop(ClientSock, SshRef, ChanId);
186
+ {error, _} ->
187
+ gen_tcp:close(ClientSock)
188
+ end.
189
+
190
+ forward_loop(ClientSock, SshRef, ChanId) ->
191
+ receive
192
+ {ssh_cm, SshRef, {data, ChanId, _Type, Data}} ->
193
+ gen_tcp:send(ClientSock, Data),
194
+ forward_loop(ClientSock, SshRef, ChanId);
195
+ {ssh_cm, SshRef, {eof, ChanId}} ->
196
+ gen_tcp:close(ClientSock),
197
+ ssh_connection:close(SshRef, ChanId);
198
+ {ssh_cm, SshRef, {closed, ChanId}} ->
199
+ gen_tcp:close(ClientSock);
200
+ {tcp, ClientSock, ClientData} ->
201
+ ssh_connection:send(SshRef, ChanId, ClientData),
202
+ inet:setopts(ClientSock, [{active, once}]),
203
+ forward_loop(ClientSock, SshRef, ChanId);
204
+ {tcp_closed, ClientSock} ->
205
+ ssh_connection:close(SshRef, ChanId);
206
+ {tcp_error, ClientSock, _} ->
207
+ ssh_connection:close(SshRef, ChanId)
208
+ after 300000 ->
209
+ %% 5 minute idle timeout
210
+ gen_tcp:close(ClientSock),
211
+ ssh_connection:close(SshRef, ChanId)
212
+ end.
213
+
214
+ stop_local_listener(Pid) when is_pid(Pid) ->
215
+ _ = (catch exit(Pid, shutdown)),
216
+ ok;
217
+ stop_local_listener(_) ->
218
+ ok.
219
+
220
+ %% @doc Find the port_fwd manager for a connection.
221
+ find_mgr(ConnId) ->
222
+ case ets:lookup(portfwd_index, ConnId) of
223
+ [{_, Pid}] -> {ok, Pid};
224
+ [] -> {error, not_found}
225
+ end.