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,294 @@
1
+ //! SFTP 管理器 — 管理所有 SFTP 会话的打开、列目录、上传、下载、创建目录、删除、stat。
2
+ //! 对应 Erlang 端的 ssh_sftp_session.erl。
3
+ //!
4
+ //! 核心设计:
5
+ //! - 每个 SFTP 会话在 SSH 连接上打开一个 subsystem("sftp") 通道
6
+ //! - 通道通过 into_stream() 转为 AsyncRead+AsyncWrite 流
7
+ //! - 使用 russh-sftp crate 的 SftpSession 高层 API
8
+ //! - 会话状态由 SftpManager 管理(HashMap<sftp_id, SftpEntry>)
9
+ //! - SftpEntry 内部持有 Arc<SftpSession>,避免长时间持有 RwLock 读锁
10
+ //! - 连接断开时自动清理该连接下的所有 SFTP 会话
11
+
12
+ use std::collections::HashMap;
13
+ use std::sync::Arc;
14
+
15
+ use russh_sftp::client::SftpSession;
16
+ use serde_json::{json, Value};
17
+ use tokio::sync::RwLock;
18
+ use tracing::{debug, info, warn};
19
+
20
+ use crate::codec;
21
+ use crate::conn::ConnManager;
22
+ use crate::proto;
23
+
24
+ /// SFTP 管理器
25
+ pub struct SftpManager {
26
+ /// sftp_id → 会话状态
27
+ sessions: RwLock<HashMap<String, SftpEntry>>,
28
+ /// 连接管理器引用
29
+ conn_mgr: Arc<ConnManager>,
30
+ }
31
+
32
+ /// 单个 SFTP 会话的状态
33
+ struct SftpEntry {
34
+ conn_id: String,
35
+ /// 使用 Arc 以便在释放 RwLock 读锁后仍可安全调用异步操作
36
+ session: Arc<SftpSession>,
37
+ }
38
+
39
+ impl SftpManager {
40
+ pub fn new(conn_mgr: Arc<ConnManager>) -> Arc<Self> {
41
+ Arc::new(Self {
42
+ sessions: RwLock::new(HashMap::new()),
43
+ conn_mgr,
44
+ })
45
+ }
46
+
47
+ /// 获取 SFTP session 的 Arc clone,释放 RwLock 读锁后仍可安全使用
48
+ async fn get_session(&self, sftp_id: &str) -> Result<Arc<SftpSession>, String> {
49
+ let sessions = self.sessions.read().await;
50
+ let entry = sessions.get(sftp_id).ok_or("sftp_not_found")?;
51
+ Ok(Arc::clone(&entry.session))
52
+ }
53
+
54
+ /// RPC: sftp.open — 在指定连接上打开 SFTP 会话
55
+ pub async fn rpc_open(&self, params: Value) -> Result<Value, String> {
56
+ let conn_id = proto::get_str(&params, "conn_id")
57
+ .ok_or("missing conn_id")?
58
+ .to_string();
59
+
60
+ // 获取连接的 handle
61
+ let handle = self
62
+ .conn_mgr
63
+ .get_handle(&conn_id)
64
+ .await
65
+ .ok_or("conn_not_found")?;
66
+
67
+ // 打开 session 通道
68
+ let channel = handle
69
+ .channel_open_session()
70
+ .await
71
+ .map_err(|e| format!("channel_open_failed: {}", e))?;
72
+
73
+ // 请求 sftp subsystem
74
+ channel
75
+ .request_subsystem(true, "sftp")
76
+ .await
77
+ .map_err(|e| format!("subsystem_failed: {}", e))?;
78
+
79
+ // 将通道转为 AsyncRead+AsyncWrite 流
80
+ let stream = channel.into_stream();
81
+
82
+ // 创建 SftpSession
83
+ let sftp_session = SftpSession::new(stream)
84
+ .await
85
+ .map_err(|e| format!("sftp_init_failed: {}", e))?;
86
+
87
+ let sftp_id = codec::gen_sftp_id(&conn_id);
88
+
89
+ let entry = SftpEntry {
90
+ conn_id: conn_id.clone(),
91
+ session: Arc::new(sftp_session),
92
+ };
93
+ self.sessions
94
+ .write()
95
+ .await
96
+ .insert(sftp_id.clone(), entry);
97
+
98
+ info!("sftp.open: conn={} sftp_id={}", conn_id, sftp_id);
99
+
100
+ Ok(json!({
101
+ "sftp_id": sftp_id
102
+ }))
103
+ }
104
+
105
+ /// RPC: sftp.list_dir — 列出远程目录内容
106
+ pub async fn rpc_list_dir(&self, params: Value) -> Result<Value, String> {
107
+ let sftp_id = proto::get_str(&params, "sftp_id")
108
+ .ok_or("missing sftp_id")?
109
+ .to_string();
110
+ let path = proto::get_str(&params, "path")
111
+ .ok_or("missing path")?;
112
+
113
+ let sftp = self.get_session(&sftp_id).await?;
114
+ let read_dir = sftp
115
+ .read_dir(path.to_string())
116
+ .await
117
+ .map_err(|e| format!("list_dir_failed: {}", e))?;
118
+
119
+ let mut entries = vec![];
120
+ for entry in read_dir {
121
+ // DirEntry::file_name() 返回 String
122
+ let name = entry.file_name();
123
+ // DirEntry::file_type() 返回 FileType 枚举(Dir/File/Symlink/Other)
124
+ let ftype = entry.file_type();
125
+ // DirEntry::metadata() 返回 FileAttributes
126
+ let metadata = entry.metadata();
127
+
128
+ let item = json!({
129
+ "name": name,
130
+ "size": metadata.len(), // FileAttributes::len() → u64
131
+ "is_dir": ftype.is_dir(),
132
+ "is_file": ftype.is_file(),
133
+ "is_symlink": ftype.is_symlink(),
134
+ "modified": metadata.mtime.unwrap_or(0),
135
+ });
136
+ entries.push(item);
137
+ }
138
+
139
+ debug!("sftp.list_dir: sftp={} path={} entries={}", sftp_id, path, entries.len());
140
+ Ok(json!({
141
+ "entries": Value::Array(entries)
142
+ }))
143
+ }
144
+
145
+ /// RPC: sftp.download — 从远程下载文件到本地
146
+ pub async fn rpc_download(&self, params: Value) -> Result<Value, String> {
147
+ let sftp_id = proto::get_str(&params, "sftp_id")
148
+ .ok_or("missing sftp_id")?
149
+ .to_string();
150
+ let remote = proto::get_str(&params, "remote")
151
+ .ok_or("missing remote")?;
152
+ let local = proto::get_str(&params, "local")
153
+ .ok_or("missing local")?;
154
+
155
+ let sftp = self.get_session(&sftp_id).await?;
156
+ let data = sftp
157
+ .read(remote.to_string())
158
+ .await
159
+ .map_err(|e| format!("read_failed: {}", e))?;
160
+
161
+ let transferred = data.len() as u64;
162
+
163
+ // 写入本地文件
164
+ tokio::fs::write(local, &data)
165
+ .await
166
+ .map_err(|e| format!("local_write_failed: {}", e))?;
167
+
168
+ info!("sftp.download: sftp={} remote={} local={} bytes={}",
169
+ sftp_id, remote, local, transferred);
170
+
171
+ Ok(json!({
172
+ "transferred": transferred,
173
+ "total": transferred
174
+ }))
175
+ }
176
+
177
+ /// RPC: sftp.upload — 从本地上传文件到远程
178
+ pub async fn rpc_upload(&self, params: Value) -> Result<Value, String> {
179
+ let sftp_id = proto::get_str(&params, "sftp_id")
180
+ .ok_or("missing sftp_id")?
181
+ .to_string();
182
+ let local = proto::get_str(&params, "local")
183
+ .ok_or("missing local")?;
184
+ let remote = proto::get_str(&params, "remote")
185
+ .ok_or("missing remote")?;
186
+
187
+ // 读取本地文件
188
+ let data = tokio::fs::read(local)
189
+ .await
190
+ .map_err(|e| format!("local_read_failed: {}", e))?;
191
+
192
+ let transferred = data.len() as u64;
193
+
194
+ let sftp = self.get_session(&sftp_id).await?;
195
+ sftp.write(remote.to_string(), &data)
196
+ .await
197
+ .map_err(|e| format!("write_failed: {}", e))?;
198
+
199
+ info!("sftp.upload: sftp={} local={} remote={} bytes={}",
200
+ sftp_id, local, remote, transferred);
201
+
202
+ Ok(json!({
203
+ "transferred": transferred,
204
+ "total": transferred
205
+ }))
206
+ }
207
+
208
+ /// RPC: sftp.mkdir — 创建远程目录
209
+ pub async fn rpc_mkdir(&self, params: Value) -> Result<Value, String> {
210
+ let sftp_id = proto::get_str(&params, "sftp_id")
211
+ .ok_or("missing sftp_id")?
212
+ .to_string();
213
+ let path = proto::get_str(&params, "path")
214
+ .ok_or("missing path")?;
215
+
216
+ let sftp = self.get_session(&sftp_id).await?;
217
+ sftp.create_dir(path.to_string())
218
+ .await
219
+ .map_err(|e| format!("mkdir_failed: {}", e))?;
220
+
221
+ debug!("sftp.mkdir: sftp={} path={}", sftp_id, path);
222
+ Ok(json!({"ok": true}))
223
+ }
224
+
225
+ /// RPC: sftp.remove — 删除远程文件或目录
226
+ pub async fn rpc_remove(&self, params: Value) -> Result<Value, String> {
227
+ let sftp_id = proto::get_str(&params, "sftp_id")
228
+ .ok_or("missing sftp_id")?
229
+ .to_string();
230
+ let path = proto::get_str(&params, "path")
231
+ .ok_or("missing path")?;
232
+
233
+ let sftp = self.get_session(&sftp_id).await?;
234
+
235
+ // 先尝试当文件删除,失败再当目录删除
236
+ let result = sftp.remove_file(path.to_string()).await;
237
+ if result.is_err() {
238
+ sftp.remove_dir(path.to_string())
239
+ .await
240
+ .map_err(|e| format!("remove_failed: {}", e))?;
241
+ }
242
+
243
+ debug!("sftp.remove: sftp={} path={}", sftp_id, path);
244
+ Ok(json!({"ok": true}))
245
+ }
246
+
247
+ /// RPC: sftp.stat — 获取远程文件/目录的元数据
248
+ pub async fn rpc_stat(&self, params: Value) -> Result<Value, String> {
249
+ let sftp_id = proto::get_str(&params, "sftp_id")
250
+ .ok_or("missing sftp_id")?
251
+ .to_string();
252
+ let path = proto::get_str(&params, "path")
253
+ .ok_or("missing path")?;
254
+
255
+ let sftp = self.get_session(&sftp_id).await?;
256
+ let metadata = sftp
257
+ .metadata(path.to_string())
258
+ .await
259
+ .map_err(|e| format!("stat_failed: {}", e))?;
260
+
261
+ // FileAttributes: 公开字段结构体 + 便捷方法
262
+ // len() → u64(从 size 字段展开)
263
+ // is_dir() → bool(检查 permissions 中的 FileMode::DIR)
264
+ // is_regular() → bool(FileMode::REG,注意不是 is_file())
265
+ // is_symlink() → bool(FileMode::LNK)
266
+ // mtime → Option<u32>(Unix 时间戳秒)
267
+ // permissions → Option<u32>(原始 POSIX 权限位)
268
+ Ok(json!({
269
+ "size": metadata.len(),
270
+ "is_dir": metadata.is_dir(),
271
+ "is_file": metadata.is_regular(),
272
+ "is_symlink": metadata.is_symlink(),
273
+ "modified": metadata.mtime.unwrap_or(0),
274
+ "permissions": format!("{:o}", metadata.permissions.unwrap_or(0)),
275
+ }))
276
+ }
277
+
278
+ /// 关闭并移除指定连接的所有 SFTP 会话(连接断开时调用)
279
+ pub async fn cleanup_conn(&self, conn_id: &str) {
280
+ let mut sessions = self.sessions.write().await;
281
+ let to_remove: Vec<String> = sessions
282
+ .iter()
283
+ .filter(|(_, e)| e.conn_id == conn_id)
284
+ .map(|(k, _)| k.clone())
285
+ .collect();
286
+
287
+ for id in &to_remove {
288
+ if let Some(entry) = sessions.remove(id) {
289
+ let _ = entry.session.close().await;
290
+ warn!("sftp session {} cleaned up (conn {} disconnected)", id, conn_id);
291
+ }
292
+ }
293
+ }
294
+ }
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module NetworkInfraUtility
6
+ module SSH
7
+ module Automation
8
+ # 登录宏引擎,连接后自动执行预设命令序列。
9
+ # 对应需求 FR-AUTO-001。
10
+ #
11
+ # LLD §7.9 修正:
12
+ # 1. wait_for 不轮询 buffer.last_line,改为订阅 channel.data 事件,在事件到达时进行匹配
13
+ # 2. on_fail: :ask 通过外部传入的 on_ask 回调处理,不依赖 block_given
14
+ #
15
+ # DoD(LLD §11.2):50 步宏执行;wait_pattern 匹配;on_fail 三分支
16
+ #
17
+ # on_fail 三分支:
18
+ # :continue — 超时后继续执行下一步
19
+ # :abort — 超时后中止整个宏
20
+ # :ask — 超时后调用 on_ask 回调询问用户
21
+ class MacroEngine
22
+ MAX_STEPS = 50
23
+
24
+ # 单步定义
25
+ # @!attribute action
26
+ # @return [String] 要发送的命令
27
+ # @!attribute wait_pattern
28
+ # @return [String, Regexp, nil] 等待匹配的文本/正则,nil 不等待
29
+ # @!attribute delay
30
+ # @return [Float] 发送前延迟(秒)
31
+ # @!attribute on_fail
32
+ # @return [Symbol] :continue / :abort / :ask
33
+ Step = Struct.new(:action, :wait_pattern, :delay, :on_fail, keyword_init: true)
34
+
35
+ attr_reader :steps, :running, :current_step_index
36
+
37
+ # @param session [Session::Session] 关联的会话
38
+ def initialize(session)
39
+ @session = session
40
+ @steps = []
41
+ @running = false
42
+ @abort = false
43
+ @current_step_index = nil
44
+ @data_subscription_sid = nil
45
+ @on_ask_callback = nil
46
+ end
47
+
48
+ # 设置 on_fail=:ask 时的回调
49
+ # 回调签名: ->(step, step_index) { :continue | :abort }
50
+ def on_ask(&block)
51
+ @on_ask_callback = block
52
+ end
53
+
54
+ # 添加一步。
55
+ # @param action [String] 要发送的命令
56
+ # @param wait_pattern [String, Regexp, nil] 等待匹配的文本/正则
57
+ # @param delay [Float] 发送前延迟(秒),默认 0
58
+ # @param on_fail [Symbol] 失败时行为::continue / :abort / :ask
59
+ def add_step(action:, wait_pattern: nil, delay: 0, on_fail: :continue)
60
+ raise "Too many steps (max #{MAX_STEPS})" if @steps.size >= MAX_STEPS
61
+
62
+ @steps << Step.new(action: action, wait_pattern: wait_pattern,
63
+ delay: delay, on_fail: on_fail)
64
+ end
65
+
66
+ # 从 YAML 配置加载步骤(sessions.yml macro.steps)
67
+ # @param config [Hash] { enabled: bool, steps: [{action, wait_pattern, delay, on_fail}] }
68
+ def load_from_config(config)
69
+ return unless config && config[:enabled]
70
+
71
+ Array(config[:steps]).each do |step_cfg|
72
+ add_step(
73
+ action: step_cfg[:action],
74
+ wait_pattern: step_cfg[:wait_pattern],
75
+ delay: step_cfg[:delay] || 0,
76
+ on_fail: (step_cfg[:on_fail] || :continue).to_sym
77
+ )
78
+ end
79
+ end
80
+
81
+ # 执行宏。
82
+ # @param on_progress [Proc, nil] 进度回调 ->(step, index, total) {}
83
+ # @return [Symbol] :completed | :aborted
84
+ def run(on_progress: nil)
85
+ raise "Macro already running" if @running
86
+ return :completed if @steps.empty?
87
+
88
+ @running = true
89
+ @abort = false
90
+
91
+ @steps.each_with_index do |step, i|
92
+ break if @abort
93
+ @current_step_index = i
94
+ on_progress&.call(step, i + 1, @steps.size)
95
+
96
+ sleep step.delay if step.delay > 0
97
+
98
+ @session.terminal.send(step.action)
99
+
100
+ if step.wait_pattern
101
+ result = wait_for(step.wait_pattern, timeout: 30)
102
+ handle_step_result(step, i, result)
103
+ end
104
+ end
105
+
106
+ @abort ? :aborted : :completed
107
+ ensure
108
+ @running = false
109
+ @current_step_index = nil
110
+ cleanup_subscription
111
+ end
112
+
113
+ # 中止执行中的宏
114
+ def abort!
115
+ @abort = true
116
+ end
117
+
118
+ # 清空步骤
119
+ def clear
120
+ raise "Cannot clear while running" if @running
121
+ @steps.clear
122
+ end
123
+
124
+ # 步骤数
125
+ def step_count
126
+ @steps.size
127
+ end
128
+
129
+ private
130
+
131
+ # LLD §7.9 修正1:wait_for 不轮询 buffer.last_line,
132
+ # 改为订阅 channel.data 事件,在事件到达时匹配。
133
+ # @param pattern [String, Regexp] 等待匹配的文本/正则
134
+ # @param timeout [Integer] 超时秒数
135
+ # @return [Symbol] :matched | :timeout
136
+ def wait_for(pattern, timeout:)
137
+ regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern.to_s))
138
+ deadline = Time.now + timeout
139
+ accumulated = +""
140
+ matched = false
141
+ queue = Queue.new
142
+
143
+ # 订阅 channel.data 推送
144
+ @data_subscription_sid = @session.client.ipc.subscribe("channel.data") do |params|
145
+ next unless params[:conn_id] == @session.conn_id &&
146
+ params[:channel_id] == @session.terminal.channel_id
147
+
148
+ data = decode_data(params[:data])
149
+ accumulated << data
150
+ queue << :data if accumulated.match?(regex)
151
+ end
152
+
153
+ deadline
154
+ loop do
155
+ remaining = deadline - Time.now
156
+ return :timeout if remaining <= 0
157
+ return :matched if accumulated.match?(regex)
158
+
159
+ # 等待新数据到达或超时
160
+ begin
161
+ Timeout.timeout(remaining) { queue.pop }
162
+ return :matched if accumulated.match?(regex)
163
+ rescue Timeout::Error
164
+ return :timeout
165
+ end
166
+ end
167
+ ensure
168
+ cleanup_subscription
169
+ end
170
+
171
+ # LLD §7.9 修正2:on_fail=:ask 通过外部回调处理
172
+ # @param step [Step] 当前步骤
173
+ # @param index [Integer] 步骤索引
174
+ # @param result [Symbol] :matched | :timeout
175
+ def handle_step_result(step, index, result)
176
+ return if result == :matched
177
+
178
+ case step.on_fail
179
+ when :continue
180
+ # 超时后继续
181
+ when :abort
182
+ @abort = true
183
+ when :ask
184
+ if @on_ask_callback
185
+ user_decision = @on_ask_callback.call(step, index)
186
+ @abort = true if user_decision == :abort
187
+ else
188
+ # 无回调时默认 abort
189
+ @abort = true
190
+ end
191
+ end
192
+ end
193
+
194
+ def cleanup_subscription
195
+ return unless @data_subscription_sid
196
+
197
+ @session.client.ipc.unsubscribe("channel.data", @data_subscription_sid)
198
+ @data_subscription_sid = nil
199
+ end
200
+
201
+ # 解码 Base64 通道数据
202
+ def decode_data(encoded)
203
+ return "" unless encoded
204
+
205
+ require "base64"
206
+ Base64.strict_decode64(encoded)
207
+ rescue StandardError
208
+ ""
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end