ferrule_sql/tunnel.rs
1//! SSH tunnel support — types and lifecycle.
2//!
3//! [`SshConfig`] is the validated output of merging profile keys and
4//! CLI flags. It is the type backends consume to set up a tunnel
5//! before opening their underlying connection.
6//!
7//! The russh-backed transport (session, channel, port forwarding,
8//! `TunneledConnection` wrapper) lives behind the `ssh` Cargo
9//! feature. The hybrid transport architecture is documented inline at
10//! `TunnelTransport`:
11//!
12//! - **`LocalListener`** — binds `127.0.0.1:0`, pumps bytes through
13//! an SSH direct-tcpip channel. Used by every backend whose driver
14//! does not expose a custom-stream injection API
15//! (`mysql_async`, `tiberius`, `rusqlite`, `oracle`).
16//! - **`Stream`** — hands back a `TunnelStream` suitable for
17//! `tokio_postgres::Config::connect_raw`. Avoids the local TCP hop
18//! for Postgres specifically.
19
20/// Resolved SSH tunnel configuration.
21///
22/// All fields have their defaults filled in by the merge step in
23/// `ferrule-cli`, so consumers do not need to handle `Option`s or
24/// env-var lookups when this value reaches the tunnel layer.
25#[derive(Debug, Clone)]
26pub struct SshConfig {
27 /// SSH bastion hostname or IP.
28 pub host: String,
29 /// SSH server port. Defaulted to 22 by the merger when omitted.
30 pub port: u16,
31 /// SSH login username. Defaulted to `$USER` by the merger.
32 pub user: String,
33 /// Path to the SSH private key. `None` means resolve through the
34 /// key stack (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, then
35 /// `SSH_AUTH_SOCK`) at connect time.
36 pub key_path: Option<String>,
37}
38
39#[cfg(feature = "ssh")]
40mod ssh_impl {
41 use super::SshConfig;
42 use secrecy::{ExposeSecret, SecretString};
43 use std::io;
44 use std::path::PathBuf;
45 use std::pin::Pin;
46 use std::sync::Arc;
47 use std::task::{Context, Poll};
48
49 /// Where the SSH session sources its private key from. The CLI's
50 /// resolution stack collapses `--ssh-key`, profile entries,
51 /// `FERRULE_<NAME>_SSH_KEY`, default identity files, and
52 /// `SSH_AUTH_SOCK` into one of these variants before reaching
53 /// `setup_tunnel`.
54 #[derive(Debug, Clone)]
55 pub enum KeySource {
56 /// A private key file on disk. The russh layer loads and (if
57 /// encrypted) decrypts it via [`russh::keys::load_secret_key`].
58 /// `None` means probe first and error if encrypted;
59 /// `Some` means attempt decryption with the provided passphrase.
60 File(PathBuf, Option<SecretString>),
61 /// SSH agent socket. The russh layer routes signing requests
62 /// through the agent at this socket path.
63 Agent(PathBuf),
64 }
65
66 /// Selects which transport `setup_tunnel` returns. See the
67 /// module-level docs for when to pick each.
68 #[derive(Debug, Clone, Copy)]
69 pub enum TunnelTransport {
70 /// Bind a local TCP listener; pump bytes through SSH.
71 LocalListener,
72 /// Hand back a [`TunnelStream`] for direct injection into a
73 /// driver that exposes a custom-stream API (Postgres only
74 /// today via `tokio_postgres::Config::connect_raw`).
75 Stream,
76 }
77
78 /// Errors raised by the tunnel layer.
79 ///
80 /// `From<russh::Error>` is required by the `russh::client::Handler`
81 /// associated `Error` bound, so the dedicated `Russh` variant is
82 /// the conversion target — `Session`/`Auth`/`Key`/`Channel` are
83 /// for diagnostics the tunnel layer raises itself.
84 #[derive(Debug, thiserror::Error)]
85 pub enum TunnelError {
86 /// Host key on file matches the server's advertised key.
87 #[error("The server key has changed at line {line}")]
88 HostKeyMismatch {
89 host: String,
90 port: u16,
91 line: usize,
92 },
93 /// Host not present in known_hosts — TOFU prompt required.
94 #[error(
95 "The authenticity of host '{host}:{port}' can't be established.\n\
96 {algorithm} key fingerprint is {fingerprint}."
97 )]
98 UnknownHost {
99 host: String,
100 port: u16,
101 algorithm: String,
102 fingerprint: String,
103 /// Boxed to keep `TunnelError` (and the `Result`s that carry
104 /// it) small now that the tunnel setup path is synchronous.
105 key: Box<russh::keys::ssh_key::PublicKey>,
106 },
107 #[error("SSH session error: {0}")]
108 Session(String),
109 #[error("SSH authentication failed: {0}")]
110 Auth(String),
111 #[error("SSH key load error: {0}")]
112 Key(String),
113 #[error("SSH channel error: {0}")]
114 Channel(String),
115 #[error("russh error: {0}")]
116 Russh(#[from] russh::Error),
117 #[error("I/O error: {0}")]
118 Io(#[from] io::Error),
119 }
120
121 /// Outcome of comparing a server public key against
122 /// `~/.ssh/known_hosts`.
123 pub enum HostKeyStatus {
124 /// Key matches an existing entry.
125 Match,
126 /// Host is present but the key differs (possible MITM).
127 Mismatch { line: usize },
128 /// Host is not present in known_hosts.
129 Unknown,
130 }
131
132 /// Check `host:port` against the user's `~/.ssh/known_hosts`.
133 pub fn check_host_key(
134 host: &str,
135 port: u16,
136 pubkey: &russh::keys::ssh_key::PublicKey,
137 ) -> Result<HostKeyStatus, TunnelError> {
138 match russh::keys::check_known_hosts(host, port, pubkey) {
139 Ok(true) => Ok(HostKeyStatus::Match),
140 Ok(false) => Ok(HostKeyStatus::Unknown),
141 Err(russh::keys::Error::KeyChanged { line }) => Ok(HostKeyStatus::Mismatch { line }),
142 Err(e) => Err(TunnelError::Session(format!(
143 "known_hosts check for {host}:{port}: {e}"
144 ))),
145 }
146 }
147
148 /// Write a host's public key into `~/.ssh/known_hosts` (TOFU).
149 pub fn learn_host_key(
150 host: &str,
151 port: u16,
152 pubkey: &russh::keys::ssh_key::PublicKey,
153 ) -> Result<(), TunnelError> {
154 russh::keys::known_hosts::learn_known_hosts(host, port, pubkey).map_err(|e| {
155 TunnelError::Session(format!(
156 "failed to write host key to ~/.ssh/known_hosts: {e}"
157 ))
158 })
159 }
160
161 /// `AsyncRead + AsyncWrite` wrapper around a russh direct-tcpip
162 /// channel. Suitable for feeding into
163 /// `tokio_postgres::Config::connect_raw`.
164 pub struct TunnelStream {
165 pub inner: russh::ChannelStream<russh::client::Msg>,
166 }
167
168 impl tokio::io::AsyncRead for TunnelStream {
169 fn poll_read(
170 self: Pin<&mut Self>,
171 cx: &mut Context<'_>,
172 buf: &mut tokio::io::ReadBuf<'_>,
173 ) -> Poll<io::Result<()>> {
174 Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
175 }
176 }
177
178 impl tokio::io::AsyncWrite for TunnelStream {
179 fn poll_write(
180 self: Pin<&mut Self>,
181 cx: &mut Context<'_>,
182 buf: &[u8],
183 ) -> Poll<io::Result<usize>> {
184 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
185 }
186
187 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
188 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
189 }
190
191 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
192 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
193 }
194 }
195
196 /// Holds the russh session for the tunnel's lifetime. Dropping
197 /// this terminates the session and tears down all channels using
198 /// it — standard Rust ownership instead of an explicit close
199 /// protocol.
200 pub struct SshSession {
201 pub handle: std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
202 }
203
204 /// Outcome of `setup_tunnel`. The session is held alongside
205 /// the transport-specific resources so callers only need to
206 /// keep one value alive — when [`TunnelHandle`] drops, the SSH
207 /// session and (for path a) the forwarder task drop with it.
208 ///
209 /// [`SshSession`] is hoisted out of [`TunnelTransport`] (which
210 /// would otherwise carry it in both variants) to keep the
211 /// transport enum's variants small — `russh::client::Handle`
212 /// is hundreds of bytes and would trip
213 /// `clippy::large_enum_variant` if duplicated per variant.
214 pub struct TunnelHandle {
215 pub session: SshSession,
216 pub transport: TunnelTransportResult,
217 }
218
219 /// Transport-specific resources returned alongside the SSH
220 /// session.
221 pub enum TunnelTransportResult {
222 /// (a) Local TCP listener path. Point the existing driver at
223 /// `127.0.0.1:port`; `forwarder` pumps bytes between the
224 /// listener and a russh direct-tcpip channel.
225 LocalPort {
226 port: u16,
227 forwarder: tokio::task::JoinHandle<()>,
228 },
229 /// (b) Direct stream path. Hand `stream` to a driver that
230 /// accepts a pre-built `AsyncRead + AsyncWrite + Unpin + Send
231 /// + 'static` (Postgres via `connect_raw`).
232 ///
233 /// Boxed so the enum's variants stay roughly the same size —
234 /// `TunnelStream` wraps a `russh::ChannelStream` whose
235 /// internals (channels, JoinHandles) make it large compared
236 /// to the `LocalPort` variant.
237 Stream { stream: Box<TunnelStream> },
238 }
239
240 /// Wraps a backend `AsyncConnection` (the crate-private async driver
241 /// trait) plus the
242 /// SSH session (and, for the LocalListener transport, the
243 /// forwarder task) so the entire stack drops together.
244 ///
245 /// Why this is non-generic: the connect dispatcher returns
246 /// `Box<dyn AsyncConnection>` regardless of backend, so an outer
247 /// wrapper that already holds the inner as `Box<dyn
248 /// AsyncConnection>` saves us from adding a blanket `impl<C:
249 /// AsyncConnection> AsyncConnection for TunneledConnection<C>` and
250 /// the matching `impl<C> AsyncConnection for Box<C>` (which
251 /// `async_trait` doesn't synthesize).
252 pub struct TunneledConnection {
253 pub(crate) inner: Box<dyn crate::connection::AsyncConnection>,
254 /// Held for `Drop` only — lifetime guard for the SSH session.
255 pub(crate) session: SshSession,
256 /// `Some` for the LocalListener transport, `None` for the
257 /// Stream transport (Postgres feeds the stream directly into
258 /// `tokio_postgres::Connection`'s task, no separate
259 /// forwarder needed).
260 pub(crate) forwarder: Option<tokio::task::JoinHandle<()>>,
261 }
262
263 #[async_trait::async_trait]
264 impl crate::connection::AsyncConnection for TunneledConnection {
265 async fn execute(&mut self, sql: &str) -> Result<crate::ExecutionSummary, crate::SqlError> {
266 self.inner.execute(sql).await
267 }
268
269 async fn query(&mut self, sql: &str) -> Result<crate::QueryResult, crate::SqlError> {
270 self.inner.query(sql).await
271 }
272
273 /// Forward the streaming cursor to the inner (tunneled)
274 /// connection; the tunnel adds no row buffering of its own.
275 async fn query_stream(
276 &mut self,
277 sql: &str,
278 ) -> Result<(Vec<crate::ColumnInfo>, crate::BoxRowStream<'_>), crate::SqlError> {
279 self.inner.query_stream(sql).await
280 }
281
282 async fn execute_multi(
283 &mut self,
284 sql: &str,
285 ) -> Result<Vec<crate::StatementResult>, crate::SqlError> {
286 self.inner.execute_multi(sql).await
287 }
288
289 async fn ping(&mut self) -> Result<(), crate::SqlError> {
290 self.inner.ping().await
291 }
292
293 async fn list_tables(
294 &mut self,
295 schema: Option<&str>,
296 ) -> Result<Vec<String>, crate::SqlError> {
297 self.inner.list_tables(schema).await
298 }
299
300 async fn list_schemas(
301 &mut self,
302 ) -> Result<Vec<crate::connection::SchemaInfo>, crate::SqlError> {
303 self.inner.list_schemas().await
304 }
305
306 async fn describe_table(
307 &mut self,
308 schema: Option<&str>,
309 table: &str,
310 ) -> Result<crate::QueryResult, crate::SqlError> {
311 self.inner.describe_table(schema, table).await
312 }
313
314 async fn primary_key(
315 &mut self,
316 schema: Option<&str>,
317 table: &str,
318 ) -> Result<Vec<String>, crate::SqlError> {
319 self.inner.primary_key(schema, table).await
320 }
321
322 async fn list_foreign_keys(
323 &mut self,
324 schema: Option<&str>,
325 ) -> Result<Vec<crate::ForeignKey>, crate::SqlError> {
326 self.inner.list_foreign_keys(schema).await
327 }
328
329 async fn bulk_insert_rows(
330 &mut self,
331 target: crate::connection::BulkInsert<'_>,
332 ) -> Result<usize, crate::SqlError> {
333 self.inner.bulk_insert_rows(target).await
334 }
335 }
336
337 /// russh client handler.
338 ///
339 /// `check_server_key` compares the server's public key against
340 /// the user's `~/.ssh/known_hosts` via russh's native parser.
341 /// Match → silent accept; mismatch → fatal error; unknown →
342 /// `Err(TunnelError::UnknownHost)` so the CLI layer can prompt
343 /// for TOFU and retry.
344 pub struct ClientHandler {
345 pub host: String,
346 pub port: u16,
347 }
348
349 impl russh::client::Handler for ClientHandler {
350 type Error = TunnelError;
351
352 async fn check_server_key(
353 &mut self,
354 server_public_key: &russh::keys::ssh_key::PublicKey,
355 ) -> Result<bool, Self::Error> {
356 match check_host_key(&self.host, self.port, server_public_key)? {
357 HostKeyStatus::Match => Ok(true),
358 HostKeyStatus::Mismatch { line } => Err(TunnelError::HostKeyMismatch {
359 host: self.host.clone(),
360 port: self.port,
361 line,
362 }),
363 HostKeyStatus::Unknown => {
364 let fingerprint = server_public_key
365 .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
366 .to_string();
367 Err(TunnelError::UnknownHost {
368 host: self.host.clone(),
369 port: self.port,
370 algorithm: server_public_key.algorithm().to_string(),
371 fingerprint,
372 key: Box::new(server_public_key.clone()),
373 })
374 }
375 }
376 }
377 }
378
379 /// Establish an SSH session and a direct-tcpip channel to
380 /// `target_host:target_port`. Returns a [`TunnelHandle`] whose
381 /// shape depends on `transport`.
382 ///
383 /// Auth flow:
384 /// - [`KeySource::File`] — load via [`russh::keys::load_secret_key`]
385 /// (no passphrase support yet — encrypted keys error out with a
386 /// diagnostic), then `authenticate_publickey`. RSA hash
387 /// algorithm is auto-negotiated via
388 /// [`russh::client::Handle::best_supported_rsa_hash`] and
389 /// defaults to SHA-256 when the server doesn't advertise.
390 /// - [`KeySource::Agent`] — connect to the agent socket, request
391 /// identities, try `authenticate_publickey_with` against each
392 /// public key (skipping certificate identities for now) until
393 /// one succeeds.
394 pub async fn setup_tunnel(
395 config: &SshConfig,
396 key_source: &KeySource,
397 target_host: &str,
398 target_port: u16,
399 transport: TunnelTransport,
400 proxy: Option<&crate::proxy::ProxyConfig>,
401 ) -> Result<TunnelHandle, TunnelError> {
402 use russh::client;
403 use russh::client::AuthResult;
404 use russh::keys::agent::AgentIdentity;
405 use russh::keys::agent::client::AgentClient;
406 use russh::keys::{HashAlg, PrivateKeyWithHashAlg, load_secret_key};
407
408 let cfg = Arc::new(client::Config::default());
409 let mut handle = if let Some(proxy) = proxy {
410 let proxy_stream = crate::proxy::http_connect(proxy, &config.host, config.port)
411 .await
412 .map_err(|e| TunnelError::Session(format!("proxy: {e}")))?;
413 client::connect_stream(
414 cfg,
415 proxy_stream,
416 ClientHandler {
417 host: config.host.clone(),
418 port: config.port,
419 },
420 )
421 .await?
422 } else {
423 client::connect(
424 cfg,
425 (config.host.as_str(), config.port),
426 ClientHandler {
427 host: config.host.clone(),
428 port: config.port,
429 },
430 )
431 .await
432 .map_err(|e| match e {
433 TunnelError::HostKeyMismatch { .. } | TunnelError::UnknownHost { .. } => e,
434 other => TunnelError::Session(format!(
435 "connect to {}:{}: {}",
436 config.host, config.port, other
437 )),
438 })?
439 };
440
441 // RSA hash auto-negotiation. Server's advertised value wins;
442 // fall back to SHA-256 (modern default) if the server didn't
443 // send `server-sig-algs`. ed25519 / ecdsa keys ignore this.
444 let rsa_hash = match handle.best_supported_rsa_hash().await {
445 Ok(Some(advertised)) => advertised,
446 Ok(None) | Err(_) => Some(HashAlg::Sha256),
447 };
448
449 match key_source {
450 KeySource::File(path, passphrase) => {
451 let key = load_secret_key(path, passphrase.as_ref().map(|s| s.expose_secret()))
452 .map_err(|e| {
453 TunnelError::Key(format!("load SSH key from {}: {}", path.display(), e))
454 })?;
455 let auth = handle
456 .authenticate_publickey(
457 &config.user,
458 PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash),
459 )
460 .await?;
461 if !auth.success() {
462 return Err(TunnelError::Auth(format!(
463 "publickey auth failed for user '{}' (server rejected key {})",
464 config.user,
465 path.display()
466 )));
467 }
468 }
469 KeySource::Agent(sock_path) => {
470 let mut agent = AgentClient::connect_uds(sock_path).await.map_err(|e| {
471 TunnelError::Auth(format!(
472 "connect to SSH agent at {}: {}",
473 sock_path.display(),
474 e
475 ))
476 })?;
477 let identities = agent
478 .request_identities()
479 .await
480 .map_err(|e| TunnelError::Auth(format!("agent request_identities: {}", e)))?;
481 if identities.is_empty() {
482 return Err(TunnelError::Auth(format!(
483 "SSH agent at {} has no identities loaded",
484 sock_path.display()
485 )));
486 }
487 let mut authed = false;
488 let mut last_err: Option<String> = None;
489 for ident in &identities {
490 let pk = match ident {
491 AgentIdentity::PublicKey { key, .. } => key.clone(),
492 // Certificate identities require a different
493 // auth call (`authenticate_certificate_with`);
494 // skip for now to keep commit B narrow.
495 AgentIdentity::Certificate { .. } => continue,
496 };
497 match handle
498 .authenticate_publickey_with(&config.user, pk, rsa_hash, &mut agent)
499 .await
500 {
501 Ok(AuthResult::Success) => {
502 authed = true;
503 break;
504 }
505 Ok(AuthResult::Failure { .. }) => continue,
506 Err(e) => {
507 last_err = Some(format!("{:?}", e));
508 }
509 }
510 }
511 if !authed {
512 return Err(TunnelError::Auth(format!(
513 "agent publickey auth failed for user '{}' \
514 (all {} identit{} rejected{})",
515 config.user,
516 identities.len(),
517 if identities.len() == 1 { "y" } else { "ies" },
518 last_err
519 .map(|e| format!(": last error: {e}"))
520 .unwrap_or_default(),
521 )));
522 }
523 }
524 }
525
526 // Wrap the authenticated handle in Arc<Mutex<>> so the
527 // LocalListener forwarder can open fresh direct-tcpip
528 // channels for each accepted connection while the Stream
529 // path opens a single channel upfront.
530 let handle = Arc::new(tokio::sync::Mutex::new(handle));
531 let session = SshSession {
532 handle: Arc::clone(&handle),
533 };
534
535 match transport {
536 TunnelTransport::Stream => {
537 let channel = handle
538 .lock()
539 .await
540 .channel_open_direct_tcpip(target_host, u32::from(target_port), "127.0.0.1", 0)
541 .await
542 .map_err(|e| {
543 TunnelError::Channel(format!(
544 "direct-tcpip to {}:{}: {}",
545 target_host, target_port, e
546 ))
547 })?;
548 Ok(TunnelHandle {
549 session,
550 transport: TunnelTransportResult::Stream {
551 stream: Box::new(TunnelStream {
552 inner: channel.into_stream(),
553 }),
554 },
555 })
556 }
557 TunnelTransport::LocalListener => {
558 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
559 let port = listener.local_addr()?.port();
560 let target_host = target_host.to_string();
561 let handle = Arc::clone(&handle);
562 let forwarder = tokio::spawn(async move {
563 loop {
564 let (mut tcp, _addr) = match listener.accept().await {
565 Ok(pair) => pair,
566 Err(e) => {
567 eprintln!("[ferrule] SSH tunnel listener accept failed: {}", e);
568 return;
569 }
570 };
571 let handle = Arc::clone(&handle);
572 let target_host = target_host.clone();
573 tokio::spawn(async move {
574 let guard = handle.lock().await;
575 let channel = match guard
576 .channel_open_direct_tcpip(
577 &target_host,
578 u32::from(target_port),
579 "127.0.0.1",
580 0,
581 )
582 .await
583 {
584 Ok(ch) => ch,
585 Err(e) => {
586 eprintln!("[ferrule] SSH tunnel direct-tcpip failed: {}", e);
587 return;
588 }
589 };
590 drop(guard);
591 let mut ssh = channel.into_stream();
592 if let Err(e) = tokio::io::copy_bidirectional(&mut tcp, &mut ssh).await
593 {
594 // Normal close is expected; don't spam stderr.
595 let _ = e;
596 }
597 });
598 }
599 });
600 Ok(TunnelHandle {
601 session,
602 transport: TunnelTransportResult::LocalPort { port, forwarder },
603 })
604 }
605 }
606 }
607
608 /// Probe whether an SSH private key file requires a passphrase.
609 ///
610 /// Returns `Ok(true)` if the key is encrypted, `Ok(false)` if it
611 /// loads without a passphrase, and `Err(TunnelError::Key(...))`
612 /// for I/O or parse errors.
613 pub fn ssh_key_needs_passphrase(
614 path: impl AsRef<std::path::Path>,
615 ) -> Result<bool, TunnelError> {
616 match russh::keys::load_secret_key(path.as_ref(), None) {
617 Ok(_) => Ok(false),
618 Err(russh::keys::Error::KeyIsEncrypted) => Ok(true),
619 Err(e) => Err(TunnelError::Key(format!(
620 "load SSH key from {}: {}",
621 path.as_ref().display(),
622 e
623 ))),
624 }
625 }
626}
627
628// Async tunnel-transport primitives are crate-internal: the public,
629// blocking connection API (`connect_with_tunnel`) is the only sanctioned
630// entry point, so embedders never await `setup_tunnel` directly.
631#[cfg(feature = "ssh")]
632pub(crate) use ssh_impl::setup_tunnel;
633#[cfg(feature = "ssh")]
634pub use ssh_impl::{
635 ClientHandler, KeySource, SshSession, TunnelError, TunnelHandle, TunnelStream, TunnelTransport,
636 TunnelTransportResult, TunneledConnection, check_host_key, learn_host_key,
637 ssh_key_needs_passphrase,
638};
639
640#[cfg(feature = "ssh")]
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn ssh_key_needs_passphrase_unencrypted() {
647 let path = std::path::PathBuf::from("/tmp/ferrule-test-unencrypted");
648 if path.exists() {
649 assert!(!ssh_key_needs_passphrase(&path).unwrap());
650 }
651 }
652
653 #[test]
654 fn ssh_key_needs_passphrase_encrypted() {
655 let path = std::path::PathBuf::from("/tmp/ferrule-test-encrypted");
656 if path.exists() {
657 assert!(ssh_key_needs_passphrase(&path).unwrap());
658 }
659 }
660}