Skip to main content

futu_server/
telnet.rs

1// Telnet 管理接口 — 运维调试用
2//
3// 支持的命令:
4//   help          — 显示帮助
5//   ping          — 连通测试
6//   version       — 版本信息
7//   show_subinfo  — 显示订阅状态
8//   set_loglevel  — 设置日志级别
9//   exit          — 关闭网关
10//
11// C++ 对齐: APIServer_Telnet.cpp (最大 5 个并发连接)
12
13use std::sync::Arc;
14
15use dashmap::DashMap;
16use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
17use tokio::net::TcpListener;
18use tokio::sync::watch;
19
20use crate::conn::ClientConn;
21use crate::metrics::GatewayMetrics;
22use crate::subscription::SubscriptionManager;
23
24/// v1.4.97 P1-D-F: telnet `relogin` 命令的回调.
25///
26/// 对齐 C++ `GTWCmd_ReLogin` (FTGateway/FTGTW_Define_Key.h:5 +
27/// GTWCmdAndPushReply.cpp:1780-1799) 命令名 — 不创新 `force_reconnect_backend`
28/// (per CLAUDE.md 坑 #51 "对齐 C++ = 减法").
29///
30/// `main.rs` wires 一个 closure 清 `bridge.caches.login_cache` (LoginCache::clear),
31/// 让下个 30s P1-D health tick 看到 `qot_logined=false` → 自动 trigger
32/// AuthRefresher relogin (走现有 v1.4.92 P1-D ladder 路径).
33///
34/// `Arc<dyn Fn>` 而非 trait object: 不需要 mutable state, 多 telnet client
35/// 并发调时是 idempotent (clear 已 cleared 的 cache 仍 OK).
36pub type ReloginFn = Arc<dyn Fn() + Send + Sync>;
37
38/// Telnet 管理服务
39pub struct TelnetServer {
40    listen_addr: String,
41    connections: Arc<DashMap<u64, ClientConn>>,
42    subscriptions: Option<Arc<SubscriptionManager>>,
43    metrics: Option<Arc<GatewayMetrics>>,
44    shutdown_tx: watch::Sender<bool>,
45    /// v1.4.97 P1-D-F: optional relogin callback (None = command unavailable
46    /// with helpful error msg).
47    relogin_fn: Option<ReloginFn>,
48}
49
50const MAX_TELNET_CONNECTIONS: usize = 5;
51const VERSION: &str = env!("CARGO_PKG_VERSION");
52
53async fn write_telnet<W>(
54    writer: &mut W,
55    peer_addr: std::net::SocketAddr,
56    context: &'static str,
57    bytes: &[u8],
58) -> bool
59where
60    W: AsyncWrite + Unpin,
61{
62    match writer.write_all(bytes).await {
63        Ok(()) => true,
64        Err(err) => {
65            tracing::debug!(
66                peer = %peer_addr,
67                context,
68                error = %err,
69                "telnet write failed; client likely disconnected"
70            );
71            false
72        }
73    }
74}
75
76impl TelnetServer {
77    pub fn new(
78        listen_addr: String,
79        connections: Arc<DashMap<u64, ClientConn>>,
80        subscriptions: Option<Arc<SubscriptionManager>>,
81        metrics: Option<Arc<GatewayMetrics>>,
82        shutdown_tx: watch::Sender<bool>,
83    ) -> Self {
84        Self {
85            listen_addr,
86            connections,
87            subscriptions,
88            metrics,
89            shutdown_tx,
90            relogin_fn: None,
91        }
92    }
93
94    /// v1.4.97 P1-D-F: builder-style setter for relogin callback.
95    pub fn with_relogin_fn(mut self, relogin_fn: ReloginFn) -> Self {
96        self.relogin_fn = Some(relogin_fn);
97        self
98    }
99
100    pub async fn run(&self) -> anyhow::Result<()> {
101        let (_shutdown_tx, shutdown_rx) = watch::channel(false);
102        self.run_until_shutdown(shutdown_rx).await
103    }
104
105    pub async fn run_until_shutdown(
106        &self,
107        mut shutdown_rx: watch::Receiver<bool>,
108    ) -> anyhow::Result<()> {
109        let listener = TcpListener::bind(&self.listen_addr)
110            .await
111            .map_err(|error| {
112                anyhow::anyhow!(
113                    "{}",
114                    crate::bind_hint::bind_error_message(
115                        "Telnet",
116                        "--telnet-port",
117                        &self.listen_addr,
118                        error
119                    )
120                )
121            })?;
122        tracing::info!(addr = %self.listen_addr, "Telnet server listening");
123
124        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
125
126        loop {
127            let (stream, peer_addr) = tokio::select! {
128                _ = crate::listener::shutdown_requested(&mut shutdown_rx) => {
129                    tracing::info!("Telnet server accept loop stopped by shutdown signal");
130                    break;
131                }
132                accepted = listener.accept() => accepted?,
133            };
134            let count = active.load(std::sync::atomic::Ordering::Relaxed);
135            if count >= MAX_TELNET_CONNECTIONS {
136                tracing::warn!(peer = %peer_addr, "telnet max connections reached");
137                drop(stream);
138                continue;
139            }
140
141            active.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
142            let active_clone = Arc::clone(&active);
143            let connections = Arc::clone(&self.connections);
144            let subscriptions = self.subscriptions.clone();
145            let metrics = self.metrics.clone();
146            let shutdown_tx = self.shutdown_tx.clone();
147            // v1.4.97 P1-D-F: clone relogin callback into spawned task.
148            let relogin_fn = self.relogin_fn.clone();
149
150            tokio::spawn(async move {
151                tracing::info!(peer = %peer_addr, "telnet client connected");
152                let (reader, mut writer) = stream.into_split();
153                let mut reader = BufReader::new(reader);
154
155                if !write_telnet(
156                    &mut writer,
157                    peer_addr,
158                    "greeting",
159                    b"FutuOpenD Rust Gateway Telnet Console\r\nType 'help' for commands.\r\n> ",
160                )
161                .await
162                {
163                    active_clone.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
164                    tracing::info!(peer = %peer_addr, "telnet client disconnected");
165                    return;
166                }
167
168                let mut line = String::new();
169                loop {
170                    line.clear();
171                    match reader.read_line(&mut line).await {
172                        Ok(0) => break, // EOF
173                        Ok(_) => {}
174                        Err(_) => break,
175                    }
176
177                    let cmd = line.trim().to_lowercase();
178                    let response = match cmd.as_str() {
179                        "help" => "Available commands:\r\n\
180                             help           — show this help\r\n\
181                             ping           — connectivity test\r\n\
182                             version        — show version\r\n\
183                             show_subinfo   — show subscription info\r\n\
184                             show_conn      — show active connections\r\n\
185                             show_metrics   — show gateway metrics\r\n\
186                             set_loglevel   — change log level (debug/info/warn/error)\r\n\
187                             relogin        — clear login_cache; next P1-D tick triggers relogin (v1.4.97)\r\n\
188                             exit           — shutdown gateway\r\n"
189                            .to_string(),
190                        "ping" => "pong\r\n".to_string(),
191                        "version" => format!("futu-opend-rs v{VERSION}\r\n"),
192                        "show_subinfo" => {
193                            if let Some(ref subs) = subscriptions {
194                                let total_quota = subs.get_total_used_quota();
195                                format!("Total subscription quota used: {total_quota}/4000\r\n")
196                            } else {
197                                "Subscription manager not available\r\n".to_string()
198                            }
199                        }
200                        "show_conn" => {
201                            let count = connections.len();
202                            let mut info = format!("Active connections: {count}\r\n");
203                            for entry in connections.iter() {
204                                let c = entry.value();
205                                info += &format!("  conn_id={} state={:?}\r\n", c.conn_id, c.state);
206                            }
207                            info
208                        }
209                        "show_metrics" => {
210                            if let Some(ref m) = metrics {
211                                m.report()
212                            } else {
213                                "Metrics not available\r\n".to_string()
214                            }
215                        }
216                        s if s.starts_with("set_loglevel") => {
217                            let parts: Vec<&str> = s.split_whitespace().collect();
218                            if parts.len() < 2 {
219                                "Usage: set_loglevel <debug|info|warn|error>\r\n".to_string()
220                            } else {
221                                // tracing 动态级别调整需要 reload handle,
222                                // 简化实现:只记录意图
223                                let level = parts[1];
224                                tracing::info!(level, "log level change requested via telnet");
225                                format!(
226                                    "Log level change to '{level}' noted (restart for full effect)\r\n"
227                                )
228                            }
229                        }
230                        "relogin" => {
231                            // v1.4.97 P1-D-F: align with C++ GTWCmd_ReLogin
232                            // (FTGateway/FTGTW_Define_Key.h:5).
233                            //
234                            // Clear login_cache → next 30s health-loop tick
235                            // sees qot_logined=false → triggers AuthRefresher
236                            // relogin via existing v1.4.92 P1-D ladder path.
237                            // Tester: wait up to ~30s, see daemon log
238                            // "P1-D loop alive" + "relogin succeeded".
239                            if let Some(ref f) = relogin_fn {
240                                tracing::info!(peer = %peer_addr,
241                                    "v1.4.97 P1-D-F: relogin requested via telnet");
242                                f();
243                                "Relogin triggered. Watch daemon log for \
244                                 \"P1-D ladder fired\" + \"relogin succeeded\" \
245                                 in next 30s tick.\r\n"
246                                    .to_string()
247                            } else {
248                                "Relogin callback not wired (daemon was started \
249                                 without bridge handle). Relogin requires daemon \
250                                 restart in this configuration.\r\n"
251                                    .to_string()
252                            }
253                        }
254                        "exit" => {
255                            write_telnet(
256                                &mut writer,
257                                peer_addr,
258                                "shutdown_notice",
259                                b"Shutting down...\r\n",
260                            )
261                            .await;
262                            if shutdown_tx.send(true).is_err() {
263                                tracing::warn!(
264                                    peer = %peer_addr,
265                                    "telnet exit requested but shutdown receiver dropped"
266                                );
267                            }
268                            break;
269                        }
270                        "" => String::new(),
271                        _ => format!(
272                            "Unknown command: '{cmd}'. Type 'help' for available commands.\r\n"
273                        ),
274                    };
275
276                    if !response.is_empty()
277                        && !write_telnet(&mut writer, peer_addr, "response", response.as_bytes())
278                            .await
279                    {
280                        break;
281                    }
282                    if !write_telnet(&mut writer, peer_addr, "prompt", b"> ").await {
283                        break;
284                    }
285                }
286
287                active_clone.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
288                tracing::info!(peer = %peer_addr, "telnet client disconnected");
289            });
290        }
291
292        Ok(())
293    }
294}
295
296#[cfg(test)]
297mod tests;