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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "yaml"
6
+ require "securerandom"
7
+ require "fileutils"
8
+
9
+ module NetworkInfraUtility
10
+ module SSH
11
+ module Security
12
+ # 凭据加密存储,AES-256-GCM。
13
+ # 对应需求 FR-SEC-001。
14
+ #
15
+ # 加密参数(HLD §5.2.7 / ADR-004):
16
+ # 算法 AES-256-GCM
17
+ # KDF PBKDF2-HMAC-SHA256
18
+ # 迭代次数 100,000
19
+ # Salt 长度 32 字节(每条凭据独立随机)
20
+ # Key 长度 32 字节
21
+ # Nonce 长度 12 字节(GCM nonce,每次加密随机)
22
+ # Auth Tag GCM 生成,存于 tag 字段
23
+ #
24
+ # vault.yml 每条记录格式:
25
+ # <key>:
26
+ # salt: <Base64>
27
+ # nonce: <Base64>
28
+ # tag: <Base64>
29
+ # data: <Base64>
30
+ #
31
+ # LLD §7.7 修正:
32
+ # - @cache 不在多线程访问(V1.0 主线程唯一),但为 V2.0 batch 预留 @cache_mutex
33
+ # - resolve_credentials 不直接修改传入的 spec,返回新 hash
34
+ class Vault
35
+ PBKDF2_ITERATIONS = 100_000
36
+ SALT_LENGTH = 32
37
+ KEY_LENGTH = 32 # AES-256
38
+ NONCE_LENGTH = 12 # GCM nonce
39
+ REF_PREFIX = "~vault:"
40
+
41
+ attr_reader :store_path
42
+
43
+ # @param master_password [String] 主密码(不落盘,仅内存)
44
+ # @param store_path [String] vault.yml 路径
45
+ def initialize(master_password, store_path = nil)
46
+ raise ArgumentError, "Master password cannot be empty" if master_password.nil? || master_password.empty?
47
+
48
+ @master_password = master_password
49
+ @store_path = store_path || default_store_path
50
+ @cache = {}
51
+ @cache_mutex = Mutex.new # V2.0 batch 预留
52
+ end
53
+
54
+ # 加密凭据并存储到 vault.yml。
55
+ # @param key [String] 凭据标识,如 "sess_001_pass"
56
+ # @param value [String] 明文凭据
57
+ def store(key, value)
58
+ salt = SecureRandom.random_bytes(SALT_LENGTH)
59
+ derived_key = derive_key(@master_password, salt)
60
+ nonce = SecureRandom.random_bytes(NONCE_LENGTH)
61
+
62
+ cipher = OpenSSL::Cipher::AES.new(256, :GCM)
63
+ cipher.encrypt
64
+ cipher.key = derived_key
65
+ cipher.iv = nonce
66
+ encrypted = cipher.update(value) + cipher.final
67
+ tag = cipher.auth_tag
68
+
69
+ entry = {
70
+ "salt" => Base64.strict_encode64(salt),
71
+ "nonce" => Base64.strict_encode64(nonce),
72
+ "tag" => Base64.strict_encode64(tag),
73
+ "data" => Base64.strict_encode64(encrypted)
74
+ }
75
+ write_to_store(key, entry)
76
+ @cache_mutex.synchronize { @cache[key] = value }
77
+ end
78
+
79
+ # 读取并解密凭据。
80
+ # @param key [String] 凭据标识
81
+ # @return [String, nil] 明文凭据,不存在返回 nil
82
+ def load(key)
83
+ cached = @cache_mutex.synchronize { @cache[key] }
84
+ return cached if cached
85
+
86
+ entry = read_from_store(key)
87
+ return nil unless entry
88
+
89
+ plaintext = decrypt_entry(entry)
90
+ @cache_mutex.synchronize { @cache[key] = plaintext }
91
+ plaintext
92
+ end
93
+
94
+ # 删除凭据
95
+ # @param key [String] 凭据标识
96
+ def delete(key)
97
+ store = load_store
98
+ store.delete(key)
99
+ write_store(store)
100
+ @cache_mutex.synchronize { @cache.delete(key) }
101
+ end
102
+
103
+ # 列出全部凭据标识
104
+ # @return [Array<String>]
105
+ def keys
106
+ load_store.keys.map(&:to_s)
107
+ end
108
+
109
+ # 在连接规格中解析凭据引用。
110
+ # spec 中值为 "~vault:<key>" 的字段会被替换为解密后的明文。
111
+ # LLD §7.7 修正:返回新 hash,不修改入参。
112
+ # @param spec [Hash] 连接规格
113
+ # @return [Hash] 新的连接规格,凭据引用已解析为明文
114
+ def resolve_credentials(spec)
115
+ spec.each_with_object({}) do |(k, v), result|
116
+ result[k] = resolve_value(v)
117
+ end
118
+ end
119
+
120
+ # 检查凭据标识是否是 vault 引用
121
+ # @param str [String]
122
+ # @return [Boolean]
123
+ def vault_ref?(str)
124
+ str.is_a?(String) && str.start_with?(REF_PREFIX)
125
+ end
126
+
127
+ private
128
+
129
+ # 递归解析 spec 中的 vault 引用(处理嵌套 hash 和数组)
130
+ def resolve_value(v)
131
+ case v
132
+ when String
133
+ v.start_with?(REF_PREFIX) ? load(v.sub(REF_PREFIX, "")) : v
134
+ when Hash
135
+ v.transform_values { |val| resolve_value(val) }
136
+ when Array
137
+ v.map { |val| resolve_value(val) }
138
+ else
139
+ v
140
+ end
141
+ end
142
+
143
+ def derive_key(password, salt)
144
+ OpenSSL::PKCS5.pbkdf2_hmac(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256")
145
+ end
146
+
147
+ def decrypt_entry(entry)
148
+ salt = Base64.strict_decode64(entry["salt"])
149
+ nonce = Base64.strict_decode64(entry["nonce"])
150
+ tag = Base64.strict_decode64(entry["tag"])
151
+ encrypted = Base64.strict_decode64(entry["data"])
152
+
153
+ derived_key = derive_key(@master_password, salt)
154
+
155
+ cipher = OpenSSL::Cipher::AES.new(256, :GCM)
156
+ cipher.decrypt
157
+ cipher.key = derived_key
158
+ cipher.iv = nonce
159
+ cipher.auth_tag = tag
160
+
161
+ cipher.update(encrypted) + cipher.final
162
+ end
163
+
164
+ def write_to_store(key, entry)
165
+ store = load_store
166
+ store[key] = entry
167
+ write_store(store)
168
+ end
169
+
170
+ def read_from_store(key)
171
+ load_store[key]
172
+ end
173
+
174
+ def load_store
175
+ return {} unless File.exist?(@store_path)
176
+
177
+ # 使用字符串 key 而非 Symbol:safe_load 不允许 Symbol,
178
+ # 消除 permitted_classes: [Symbol] 带来的反序列化攻击面。
179
+ # 写入端 write_store 也已改为字符串 key。
180
+ YAML.safe_load(File.read(@store_path)) || {}
181
+ end
182
+
183
+ def write_store(store)
184
+ FileUtils.mkdir_p(File.dirname(@store_path))
185
+ File.write(@store_path, YAML.dump(store))
186
+ set_file_permissions
187
+ end
188
+
189
+ def default_store_path
190
+ File.join(Dir.home, ".network-infra-utility", "vault.yml")
191
+ end
192
+
193
+ def set_file_permissions
194
+ path = @store_path
195
+ if Gem.win_platform?
196
+ # Windows: ACL 限制当前用户读写
197
+ # 使用 ENV.fetch 避免注入:icacls 的用户名参数不转义特殊字符会导致命令注入
198
+ user = ENV["USERNAME"] || ENV["USER"] || ""
199
+ # 白名单校验用户名(Windows 用户名仅允许字母、数字、连字符、下划线和点)
200
+ unless user.match(/\A[A-Za-z0-9._-]+\z/)
201
+ raise SecurityError, "Invalid username for ACL: #{user.inspect}"
202
+ end
203
+ system("icacls", path, "/inheritance:r", "/grant:r", "#{user}:F")
204
+ else
205
+ File.chmod(0o600, path)
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NetworkInfraUtility
4
+ module SSH
5
+ module Session
6
+ # 最近连接列表与历史记录(FR-SESS-005)。
7
+ # 在内存中维护,可持久化到配置。
8
+ class History
9
+ MAX_ITEMS = 20
10
+
11
+ attr_reader :items, :pinned
12
+
13
+ def initialize
14
+ @items = [] # [Session] 按使用时间倒序
15
+ @pinned = {} # conn_id => true
16
+ end
17
+
18
+ # 记录一次连接
19
+ def record(session)
20
+ @items.delete(session)
21
+ @items.unshift(session)
22
+ trim
23
+ end
24
+
25
+ # 置顶
26
+ def pin(conn_id)
27
+ @pinned[conn_id] = true
28
+ end
29
+
30
+ # 取消置顶
31
+ def unpin(conn_id)
32
+ @pinned.delete(conn_id)
33
+ end
34
+
35
+ # 获取最近列表(置顶在前)
36
+ def recent
37
+ pinned_sessions = @items.select { |s| @pinned[s.conn_id] }
38
+ unpinned = @items.reject { |s| @pinned[s.conn_id] }
39
+ pinned_sessions + unpinned
40
+ end
41
+
42
+ # 清除
43
+ def clear
44
+ @items.clear
45
+ @pinned.clear
46
+ end
47
+
48
+ private
49
+
50
+ def trim
51
+ @items = @items.first(MAX_ITEMS)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "tree"
5
+ require_relative "history"
6
+
7
+ module NetworkInfraUtility
8
+ module SSH
9
+ module Session
10
+ # 会话管理器:维护全部活跃会话的生命周期。
11
+ # 对应需求 FR-SESS-001 ~ FR-SESS-006。
12
+ class Manager
13
+ include Enumerable
14
+
15
+ attr_reader :tree, :history
16
+
17
+ def initialize(client)
18
+ @client = client
19
+ @sessions = {} # conn_id => Session
20
+ @tree = Tree.new
21
+ @history = History.new
22
+ setup_conn_closed_handler
23
+ end
24
+
25
+ # 从已有连接 ID 创建会话对象
26
+ def create(conn_id, spec, fingerprint = nil)
27
+ session = Session.new(@client, conn_id, spec, fingerprint)
28
+ @sessions[conn_id] = session
29
+ @history.record(session)
30
+ session
31
+ end
32
+
33
+ def get(conn_id)
34
+ @sessions[conn_id]
35
+ end
36
+
37
+ def disconnect(conn_id)
38
+ @sessions[conn_id]&.disconnect
39
+ @sessions.delete(conn_id)
40
+ end
41
+
42
+ def disconnect_all
43
+ @sessions.each_value(&:disconnect)
44
+ @sessions.clear
45
+ end
46
+
47
+ def each(&block)
48
+ @sessions.each_value(&block)
49
+ end
50
+
51
+ def count
52
+ @sessions.size
53
+ end
54
+
55
+ # 搜索会话(FR-SESS-004)
56
+ # @param query [String] 搜索关键词
57
+ # @param fields [Array<Symbol>] 搜索字段,默认 :name, :host, :ip, :tags
58
+ def search(query, fields = %i[name host ip tags])
59
+ query_lower = query.downcase
60
+ select do |s|
61
+ fields.any? do |f|
62
+ val = s.send(f)
63
+ if val.is_a?(Array)
64
+ val.any? { |v| v.to_s.downcase.include?(query_lower) }
65
+ else
66
+ val.to_s.downcase.include?(query_lower)
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # 广播输入到多个会话(FR-TERM-008)
73
+ # @param conn_ids [Array<String>] 目标连接 ID
74
+ # @param text [String] 要发送的文本
75
+ def broadcast(conn_ids, text)
76
+ conn_ids.each { |id| @sessions[id]&.terminal&.send(text) }
77
+ end
78
+
79
+ private
80
+
81
+ # 监听 conn.closed 推送,清理会话
82
+ def setup_conn_closed_handler
83
+ @client.ipc.subscribe("conn.closed") do |params|
84
+ conn_id = params[:conn_id]
85
+ session = @sessions.delete(conn_id)
86
+ session&.on_closed(params[:reason])
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../terminal/emulator"
5
+
6
+ module NetworkInfraUtility
7
+ module SSH
8
+ module Session
9
+ # 单个 SSH 会话:封装终端、文件、端口转发等子能力。
10
+ class Session
11
+ attr_reader :session_id, :conn_id, :spec, :name, :host, :port, :user
12
+ attr_reader :fingerprint, :terminal, :file_manager, :port_forwards
13
+ attr_accessor :tags, :group, :status
14
+
15
+ STATUSES = %i[connecting authenticating connected disconnected error].freeze
16
+
17
+ def initialize(client, conn_id, spec, fingerprint = nil)
18
+ @client = client
19
+ @conn_id = conn_id
20
+ @session_id = "sess_#{SecureRandom.uuid.delete('-')}"
21
+ @spec = spec
22
+ @fingerprint = fingerprint
23
+ @name = spec[:name] || "#{spec[:user]}@#{spec[:host]}"
24
+ @host = spec[:host]
25
+ @port = spec[:port] || 22
26
+ @user = spec[:user]
27
+ @tags = spec[:tags] || []
28
+ @group = spec[:group]
29
+ @status = :connected
30
+ @terminal = nil
31
+ @file_manager = nil
32
+ @port_forwards = []
33
+ @coalesce = client.coalesce
34
+ end
35
+
36
+ # 打开终端通道
37
+ # @param term_type [String] 终端类型如 "xterm-256color"
38
+ # @param cols [Integer] 列数
39
+ # @param rows [Integer] 行数
40
+ def open_terminal(term_type: "xterm-256color", cols: 80, rows: 24)
41
+ raise "Terminal already open" if @terminal
42
+
43
+ resp = @client.ipc.call("channel.open", {
44
+ conn_id: @conn_id,
45
+ type: "shell",
46
+ term: term_type,
47
+ cols: cols,
48
+ rows: rows
49
+ })
50
+ ch_id = resp[:channel_id]
51
+ @terminal = Terminal::Emulator.new(@client, @conn_id, ch_id, cols, rows,
52
+ theme: @spec[:terminal]&.dig(:theme))
53
+ @coalesce&.register_channel(@conn_id, ch_id, @terminal)
54
+ @terminal
55
+ end
56
+
57
+ # 关闭终端
58
+ def close_terminal
59
+ return unless @terminal
60
+
61
+ @client.ipc.call("channel.close", { id: @terminal.channel_id })
62
+ @coalesce&.unregister_channel(@conn_id, @terminal.channel_id)
63
+ @terminal = nil
64
+ end
65
+
66
+ # 添加端口转发规则(FR-NET-001)
67
+ def add_port_forward(type, local_port, remote_host, remote_port)
68
+ resp = @client.ipc.call("portfwd.add", {
69
+ conn_id: @conn_id,
70
+ type: type.to_s,
71
+ local_port: local_port,
72
+ remote_host: remote_host,
73
+ remote_port: remote_port
74
+ })
75
+ rule_id = resp[:rule_id]
76
+ @port_forwards << {
77
+ id: rule_id, type: type, local_port: local_port,
78
+ remote_host: remote_host, remote_port: remote_port
79
+ }
80
+ rule_id
81
+ end
82
+
83
+ # 移除端口转发规则
84
+ def remove_port_forward(rule_id)
85
+ @client.ipc.call("portfwd.remove", { conn_id: @conn_id, rule_id: rule_id })
86
+ @port_forwards.reject! { |r| r[:id] == rule_id }
87
+ end
88
+
89
+ # 断开连接
90
+ def disconnect
91
+ close_terminal
92
+ @client.ipc.call("conn.disconnect", { id: @conn_id })
93
+ @status = :disconnected
94
+ end
95
+
96
+ def connected?
97
+ @status == :connected
98
+ end
99
+
100
+ # 连接被远端关闭时的回调
101
+ def on_closed(reason = nil)
102
+ @status = :disconnected
103
+ @terminal = nil
104
+ @file_manager = nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module NetworkInfraUtility
6
+ module SSH
7
+ module Session
8
+ # 会话分组的树形管理(FR-SESS-002)。
9
+ # 支持 ≥ 5 层嵌套、拖拽移动、折叠/展开、分组级批量操作。
10
+ class Tree
11
+ attr_reader :root
12
+
13
+ Node = Struct.new(:id, :name, :parent, :children, :collapsed, keyword_init: true)
14
+
15
+ def initialize
16
+ @root = Node.new(id: "root", name: "Root", parent: nil, children: [], collapsed: false)
17
+ @nodes = { "root" => @root }
18
+ end
19
+
20
+ # 添加分组
21
+ def add_group(name, parent_id: "root")
22
+ id = "grp_#{SecureRandom.hex(4)}"
23
+ parent = @nodes[parent_id]
24
+ raise "Parent group not found: #{parent_id}" unless parent
25
+
26
+ node = Node.new(id: id, name: name, parent: parent, children: [], collapsed: false)
27
+ parent.children << node
28
+ @nodes[id] = node
29
+ id
30
+ end
31
+
32
+ # 删除分组(子分组递归删除)
33
+ def remove_group(group_id)
34
+ node = @nodes.delete(group_id)
35
+ return unless node
36
+
37
+ node.parent&.children&.delete(node)
38
+ node.children.each { |c| remove_group(c.id) }
39
+ end
40
+
41
+ # 重命名分组
42
+ def rename_group(group_id, new_name)
43
+ @nodes[group_id]&.name = new_name
44
+ end
45
+
46
+ # 折叠/展开
47
+ def toggle_collapse(group_id)
48
+ node = @nodes[group_id]
49
+ return unless node
50
+
51
+ node.collapsed = !node.collapsed
52
+ end
53
+
54
+ # 移动分组到新父节点
55
+ def move_group(group_id, new_parent_id)
56
+ node = @nodes[group_id]
57
+ new_parent = @nodes[new_parent_id]
58
+ return unless node && new_parent
59
+
60
+ node.parent&.children&.delete(node)
61
+ node.parent = new_parent
62
+ new_parent.children << node
63
+ end
64
+
65
+ # 查找分组
66
+ def find_group(group_id)
67
+ @nodes[group_id]
68
+ end
69
+
70
+ # 遍历所有分组(深度优先)
71
+ def each_group(&block)
72
+ traverse(@root, &block)
73
+ end
74
+
75
+ # 获取分组的完整路径名
76
+ def group_path(group_id)
77
+ parts = []
78
+ node = @nodes[group_id]
79
+ while node && node.id != "root"
80
+ parts.unshift(node.name)
81
+ node = node.parent
82
+ end
83
+ parts.join(" / ")
84
+ end
85
+
86
+ private
87
+
88
+ def traverse(node, &block)
89
+ block.call(node) unless node.id == "root"
90
+ node.children.each { |c| traverse(c, &block) }
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end