1use 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
24pub type ReloginFn = Arc<dyn Fn() + Send + Sync>;
37
38pub 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 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 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 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, 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 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 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;