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.
- checksums.yaml +4 -4
- data/.gitignore +21 -0
- data/CHANGELOG.md +31 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +70 -0
- data/Rakefile +1 -1
- data/bin/dns-query +834 -0
- data/bin/geo-doc +135 -0
- data/bin/geo-get +1 -1
- data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
- data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
- 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
- data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
- data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
- 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
- data/network-infra-utility.gemspec +4 -2
- data/network.rb +3 -1
- data/service/geodb/GeoAPI.md +1 -0
- data/service/geodb/geodb.rb +278 -1
- data/service/ssh/README.md +942 -0
- data/service/ssh/bin/ssh-client +198 -0
- 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
- 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
- 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
- data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
- data/service/ssh/ext/ssh_core/config/sys.config +0 -0
- data/service/ssh/ext/ssh_core/config/vm.args +0 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
- data/service/ssh/ext/ssh_core/rebar.config +24 -0
- data/service/ssh/ext/ssh_core/rebar.lock +1 -0
- data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
- data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
- data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
- data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
- data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
- data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
- data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
- data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
- data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +104 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
- data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
- data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
- data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
- data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
- data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
- data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
- data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
- data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
- data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
- data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
- data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
- data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
- data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
- data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
- data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
- data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
- data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
- data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
- data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
- data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
- data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
- data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
- data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
- data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
- data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
- data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
- data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
- data/support/basic/as_num.rb +221 -0
- data/support/basic/mac_address.rb +281 -0
- data/version.rb +1 -1
- 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.
|