1use std::net::IpAddr;
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use crate::api::{CorsConfig, SecurityConfig, ServerConfig};
15use crate::cli::Args;
16use crate::security::{AuthConfig, CapabilitySet, RateLimitConfig};
17use crate::tunnel::{Cloudflared, CustomCommand, TunnelProvider};
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct Config {
23 pub server: ServerSection,
25 pub security: SecuritySection,
27 pub transport: TransportSection,
29 pub logging: LoggingSection,
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum TransportMode {
41 #[default]
43 None,
44 Cloudflared,
46 Command,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(default)]
53pub struct TransportSection {
54 pub mode: TransportMode,
56 pub command: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct ServerSection {
64 pub host: String,
66 pub port: u16,
68 pub graceful_shutdown: bool,
70}
71
72impl Default for ServerSection {
73 fn default() -> Self {
74 Self {
75 host: "127.0.0.1".to_string(),
76 port: 3000,
77 graceful_shutdown: true,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(default)]
85pub struct SecuritySection {
86 pub auth: AuthSection,
88 pub rate_limit: RateLimitSection,
90 pub cors: CorsSection,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct CorsSection {
98 pub allow_any: bool,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(default)]
106pub struct AuthSection {
107 pub enabled: bool,
109 pub api_keys: Vec<String>,
111 pub capabilities: Vec<String>,
113 pub preset: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct RateLimitSection {
121 pub enabled: bool,
123 pub requests_per_window: u32,
125 pub window_secs: u64,
127}
128
129impl Default for RateLimitSection {
130 fn default() -> Self {
131 Self {
132 enabled: true,
133 requests_per_window: 100,
134 window_secs: 60,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct LoggingSection {
143 pub level: String,
145}
146
147impl Default for LoggingSection {
148 fn default() -> Self {
149 Self {
150 level: "info".to_string(),
151 }
152 }
153}
154
155impl Config {
156 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
158 let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
159 serde_json::from_str(&content).map_err(ConfigError::Json)
160 }
161
162 pub fn apply_env(&mut self) {
164 if let Ok(host) = std::env::var("SHELL_TUNNEL_HOST") {
165 self.server.host = host;
166 }
167
168 if let Ok(port) = std::env::var("SHELL_TUNNEL_PORT") {
169 if let Ok(port) = port.parse() {
170 self.server.port = port;
171 }
172 }
173
174 if let Ok(key) = std::env::var("SHELL_TUNNEL_API_KEY") {
175 if !key.is_empty() {
176 self.security.auth.enabled = true;
177 if !self.security.auth.api_keys.contains(&key) {
178 self.security.auth.api_keys.push(key);
179 }
180 }
181 }
182
183 if let Ok(level) = std::env::var("SHELL_TUNNEL_LOG_LEVEL") {
184 self.logging.level = level;
185 } else if let Ok(level) = std::env::var("RUST_LOG") {
186 self.logging.level = level;
187 }
188 }
189
190 pub fn apply_args(&mut self, args: &Args) {
192 self.server.host = args.host.to_string();
193 self.server.port = args.port;
194
195 if let Some(ref key) = args.api_key {
196 self.security.auth.enabled = true;
197 if !self.security.auth.api_keys.contains(key) {
198 self.security.auth.api_keys.push(key.clone());
199 }
200 }
201
202 if args.require_auth {
205 self.security.auth.enabled = true;
206 }
207
208 if !args.capabilities.is_empty() {
213 self.security.auth.capabilities = args.capabilities.clone();
214 self.security.auth.enabled = true;
215 }
216 if let Some(ref preset) = args.preset {
217 self.security.auth.preset = Some(preset.clone());
218 self.security.auth.enabled = true;
219 }
220
221 if args.no_auth {
222 self.security.auth.enabled = false;
223 }
224
225 if let Some(ref command) = args.tunnel_command {
228 self.transport.mode = TransportMode::Command;
229 self.transport.command = Some(command.clone());
230 } else if args.tunnel {
231 self.transport.mode = TransportMode::Cloudflared;
232 }
233
234 if args.no_rate_limit {
235 self.security.rate_limit.enabled = false;
236 }
237
238 if args.cors_allow_any {
239 self.security.cors.allow_any = true;
240 }
241
242 if let Some(ref level) = args.log_level {
243 self.logging.level = level.clone();
244 }
245 }
246
247 pub fn load(args: &Args) -> Result<Self, ConfigError> {
251 let mut config = Config::default();
253
254 if let Some(ref path) = args.config {
256 config = Config::from_file(path)?;
257 }
258
259 config.apply_env();
261
262 config.apply_args(args);
264
265 Ok(config)
266 }
267
268 pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
270 match self.transport.mode {
271 TransportMode::None => Ok(None),
272 TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
273 TransportMode::Command => {
274 let command = self
275 .transport
276 .command
277 .as_deref()
278 .filter(|c| !c.trim().is_empty())
279 .ok_or(ConfigError::MissingTunnelCommand)?;
280 Ok(Some(Box::new(CustomCommand::new(command))))
281 }
282 }
283 }
284
285 pub fn harden_for_public_exposure(
298 &mut self,
299 args: &Args,
300 ) -> Result<PublicExposure, ConfigError> {
301 if args.no_auth {
302 return Err(ConfigError::RemoteWithoutAuth);
303 }
304
305 self.security.auth.enabled = true;
306
307 let generated_key = if self.security.auth.api_keys.is_empty() {
308 let key = crate::security::generate_api_key();
309 self.security.auth.api_keys.push(key.clone());
310 Some(key)
311 } else {
312 None
313 };
314
315 let mut warnings = Vec::new();
316 if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
317 warnings.push(
318 "the issued token has full control over this machine and is reachable from the internet; scope it with --preset operator or --capabilities"
319 .to_string(),
320 );
321 }
322 if !self.security.rate_limit.enabled {
323 warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
324 }
325 if let Ok(ip) = self.server.host.parse::<IpAddr>() {
326 if !ip.is_loopback() {
327 warnings.push(format!(
328 "binding {} exposes the server directly in addition to the tunnel; 127.0.0.1 is enough when a tunnel provides reachability",
329 ip
330 ));
331 }
332 }
333
334 Ok(PublicExposure {
335 generated_key,
336 warnings,
337 })
338 }
339
340 pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
342 let host: IpAddr = self
343 .server
344 .host
345 .parse()
346 .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
347
348 let mut security = if self.security.auth.enabled {
349 SecurityConfig::secure()
350 } else {
351 SecurityConfig::development()
352 };
353
354 security.auth = AuthConfig {
356 enabled: self.security.auth.enabled,
357 ..AuthConfig::default()
358 };
359
360 security.rate_limit = RateLimitConfig {
362 enabled: self.security.rate_limit.enabled,
363 max_requests: self.security.rate_limit.requests_per_window,
364 window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
365 max_tracked_ips: 10000,
366 };
367
368 security.cors = CorsConfig {
370 allow_any: self.security.cors.allow_any,
371 };
372
373 if let Some(capabilities) = resolve_capabilities(
375 self.security.auth.preset.as_deref(),
376 &self.security.auth.capabilities,
377 )? {
378 security = security.with_capabilities(capabilities);
379 }
380
381 for key in &self.security.auth.api_keys {
383 security = security.with_api_key(key);
384 }
385
386 let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
387 server_config = server_config.with_security(security);
388
389 if !self.server.graceful_shutdown {
390 server_config = server_config.without_graceful_shutdown();
391 }
392
393 Ok(server_config)
394 }
395
396 pub fn log_filter(&self) -> &str {
398 &self.logging.level
399 }
400}
401
402fn resolve_capabilities(
408 preset: Option<&str>,
409 capabilities: &[String],
410) -> Result<Option<CapabilitySet>, ConfigError> {
411 if preset.is_none() && capabilities.is_empty() {
412 return Ok(None); }
414
415 let mut set = match preset {
416 Some(name) => crate::security::preset(name)
417 .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
418 None => CapabilitySet::new(),
419 };
420 for capability in capabilities {
421 set.insert(capability.clone());
422 }
423 Ok(Some(set))
424}
425
426#[derive(Debug, Clone, Default)]
428pub struct PublicExposure {
429 pub generated_key: Option<String>,
431 pub warnings: Vec<String>,
433}
434
435#[derive(Debug)]
437pub enum ConfigError {
438 Io(std::io::Error),
440 Json(serde_json::Error),
442 InvalidHost(String),
444 InvalidPreset(String),
446 RemoteWithoutAuth,
448 MissingTunnelCommand,
450}
451
452impl std::fmt::Display for ConfigError {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 match self {
455 Self::Io(e) => write!(f, "failed to read config file: {}", e),
456 Self::Json(e) => write!(f, "failed to parse config file: {}", e),
457 Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
458 Self::InvalidPreset(name) => write!(
459 f,
460 "unknown role preset: '{}' (expected operator, read-only, or full-control)",
461 name
462 ),
463 Self::MissingTunnelCommand => write!(
464 f,
465 "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
466 ),
467 Self::RemoteWithoutAuth => write!(
468 f,
469 "--no-auth cannot be combined with a public tunnel: that would expose an unauthenticated shell to the internet. Drop --no-auth (a key is generated for you), or drop the tunnel"
470 ),
471 }
472 }
473}
474
475impl std::error::Error for ConfigError {}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use std::io::Write;
481 use tempfile::NamedTempFile;
482
483 #[test]
484 fn test_default_config() {
485 let config = Config::default();
486 assert_eq!(config.server.host, "127.0.0.1");
487 assert_eq!(config.server.port, 3000);
488 assert!(!config.security.auth.enabled);
489 assert!(config.security.rate_limit.enabled);
490 }
491
492 #[test]
493 fn test_config_from_json() {
494 let json = r#"{
495 "server": {
496 "host": "0.0.0.0",
497 "port": 8080
498 },
499 "security": {
500 "auth": {
501 "enabled": true,
502 "api_keys": ["key1", "key2"]
503 }
504 }
505 }"#;
506
507 let mut file = NamedTempFile::new().unwrap();
508 file.write_all(json.as_bytes()).unwrap();
509
510 let config = Config::from_file(file.path()).unwrap();
511 assert_eq!(config.server.host, "0.0.0.0");
512 assert_eq!(config.server.port, 8080);
513 assert!(config.security.auth.enabled);
514 assert_eq!(config.security.auth.api_keys.len(), 2);
515 }
516
517 #[test]
518 fn test_config_partial_json() {
519 let json = r#"{
520 "server": {
521 "port": 9000
522 }
523 }"#;
524
525 let mut file = NamedTempFile::new().unwrap();
526 file.write_all(json.as_bytes()).unwrap();
527
528 let config = Config::from_file(file.path()).unwrap();
529 assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000);
531 }
532
533 #[test]
534 fn test_apply_args() {
535 let mut config = Config::default();
536 let args = Args {
537 host: "192.168.1.1".parse().unwrap(),
538 port: 5000,
539 api_key: Some("test-key".to_string()),
540 no_rate_limit: true,
541 ..Args::default()
542 };
543
544 config.apply_args(&args);
545
546 assert_eq!(config.server.host, "192.168.1.1");
547 assert_eq!(config.server.port, 5000);
548 assert!(config.security.auth.enabled);
549 assert!(config
550 .security
551 .auth
552 .api_keys
553 .contains(&"test-key".to_string()));
554 assert!(!config.security.rate_limit.enabled);
555 }
556
557 #[test]
558 fn test_apply_no_auth() {
559 let mut config = Config::default();
560 config.security.auth.enabled = true;
561
562 let args = Args {
563 no_auth: true,
564 ..Args::default()
565 };
566
567 config.apply_args(&args);
568 assert!(!config.security.auth.enabled);
569 }
570
571 #[test]
572 fn test_apply_require_auth() {
573 let mut config = Config::default();
574 assert!(!config.security.auth.enabled); config.apply_args(&Args {
577 require_auth: true,
578 ..Args::default()
579 });
580 assert!(config.security.auth.enabled);
581 }
582
583 #[test]
584 fn test_no_auth_overrides_require_auth() {
585 let mut config = Config::default();
586
587 config.apply_args(&Args {
589 require_auth: true,
590 no_auth: true,
591 ..Args::default()
592 });
593 assert!(!config.security.auth.enabled);
594 }
595
596 #[test]
597 fn test_to_server_config() {
598 let config = Config::default();
599 let server_config = config.to_server_config().unwrap();
600
601 assert_eq!(server_config.host, "127.0.0.1");
602 assert_eq!(server_config.port, 3000);
603 }
604
605 #[test]
606 fn test_apply_args_capabilities_and_preset() {
607 let mut config = Config::default();
608 config.apply_args(&Args {
609 capabilities: vec!["exec".to_string(), "session.read".to_string()],
610 preset: Some("operator".to_string()),
611 ..Args::default()
612 });
613 assert_eq!(
614 config.security.auth.capabilities,
615 vec!["exec", "session.read"]
616 );
617 assert_eq!(config.security.auth.preset, Some("operator".to_string()));
618 }
619
620 #[test]
621 fn test_scope_implies_auth_on() {
622 let mut by_preset = Config::default();
625 by_preset.apply_args(&Args {
626 preset: Some("read-only".to_string()),
627 ..Args::default()
628 });
629 assert!(by_preset.security.auth.enabled);
630
631 let mut by_caps = Config::default();
632 by_caps.apply_args(&Args {
633 capabilities: vec!["session.read".to_string()],
634 ..Args::default()
635 });
636 assert!(by_caps.security.auth.enabled);
637 }
638
639 #[test]
640 fn test_no_auth_overrides_scope_implied_auth() {
641 let mut config = Config::default();
643 config.apply_args(&Args {
644 preset: Some("read-only".to_string()),
645 no_auth: true,
646 ..Args::default()
647 });
648 assert!(!config.security.auth.enabled);
649 }
650
651 #[test]
652 fn test_config_from_json_with_capabilities_and_preset() {
653 let json = r#"{
656 "security": {
657 "auth": {
658 "enabled": true,
659 "api_keys": ["scoped"],
660 "preset": "read-only",
661 "capabilities": ["exec"]
662 }
663 }
664 }"#;
665 let mut file = NamedTempFile::new().unwrap();
666 file.write_all(json.as_bytes()).unwrap();
667
668 let config = Config::from_file(file.path()).unwrap();
669 assert_eq!(config.security.auth.preset, Some("read-only".to_string()));
670 assert_eq!(config.security.auth.capabilities, vec!["exec"]);
671
672 let server_config = config.to_server_config().unwrap();
673 let caps = server_config
674 .security
675 .capabilities
676 .expect("capabilities scoped from file");
677 assert!(caps.satisfies("session.read")); assert!(caps.satisfies("exec")); assert!(!caps.satisfies("session.manage"));
680 }
681
682 #[test]
683 fn test_resolve_capabilities_none_by_default() {
684 assert!(resolve_capabilities(None, &[]).unwrap().is_none());
686 }
687
688 #[test]
689 fn test_resolve_capabilities_preset_plus_extra() {
690 let set = resolve_capabilities(Some("read-only"), &["exec".to_string()])
692 .unwrap()
693 .unwrap();
694 assert!(set.satisfies("session.read"));
695 assert!(set.satisfies("exec"));
696 assert!(!set.satisfies("session.manage"));
697 }
698
699 #[test]
700 fn test_resolve_capabilities_invalid_preset_errors() {
701 let err = resolve_capabilities(Some("superuser"), &[]);
702 assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
703 }
704
705 #[test]
706 fn test_to_server_config_scopes_capabilities() {
707 let mut config = Config::default();
708 config.security.auth.enabled = true;
709 config.security.auth.api_keys = vec!["scoped".to_string()];
710 config.security.auth.preset = Some("read-only".to_string());
711
712 let server_config = config.to_server_config().unwrap();
713 let caps = server_config
714 .security
715 .capabilities
716 .expect("capabilities scoped");
717 assert!(caps.satisfies("session.read"));
718 assert!(!caps.satisfies("exec"));
719 }
720
721 #[test]
722 fn test_to_server_config_invalid_preset_errors() {
723 let mut config = Config::default();
724 config.security.auth.preset = Some("root".to_string());
725 assert!(matches!(
726 config.to_server_config(),
727 Err(ConfigError::InvalidPreset(_))
728 ));
729 }
730
731 #[test]
732 fn test_invalid_host() {
733 let mut config = Config::default();
734 config.server.host = "not-an-ip".to_string();
735
736 let result = config.to_server_config();
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn test_config_serialization() {
742 let config = Config::default();
743 let json = serde_json::to_string_pretty(&config).unwrap();
744 assert!(json.contains("\"host\""));
745 assert!(json.contains("\"port\""));
746 }
747
748 fn tunnel_args() -> Args {
749 Args {
750 tunnel: true,
751 ..Default::default()
752 }
753 }
754
755 #[test]
756 fn test_public_exposure_refuses_no_auth() {
757 let mut config = Config::default();
758 let args = Args {
759 no_auth: true,
760 ..tunnel_args()
761 };
762 let err = config.harden_for_public_exposure(&args).unwrap_err();
763 assert!(matches!(err, ConfigError::RemoteWithoutAuth));
764 assert!(err.to_string().contains("unauthenticated shell"));
765 }
766
767 #[test]
768 fn test_public_exposure_enables_auth_and_generates_a_key() {
769 let mut config = Config::default();
770 assert!(!config.security.auth.enabled);
771
772 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
773
774 assert!(config.security.auth.enabled);
775 let key = exposure.generated_key.expect("a key must be generated");
776 assert!(key.starts_with("st_"));
777 assert_eq!(config.security.auth.api_keys, vec![key]);
778 }
779
780 #[test]
781 fn test_public_exposure_keeps_a_supplied_key() {
782 let mut config = Config::default();
783 config.security.auth.api_keys.push("my-key".to_string());
784
785 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
786
787 assert!(exposure.generated_key.is_none());
788 assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
789 }
790
791 #[test]
792 fn test_public_exposure_warns_about_an_unscoped_token() {
793 let mut config = Config::default();
794 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
795 assert!(
796 exposure.warnings.iter().any(|w| w.contains("full control")),
797 "{:?}",
798 exposure.warnings
799 );
800 }
801
802 #[test]
803 fn test_public_exposure_does_not_warn_about_a_scoped_token() {
804 let mut config = Config::default();
805 config.security.auth.preset = Some("operator".to_string());
806 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
807 assert!(
808 !exposure.warnings.iter().any(|w| w.contains("full control")),
809 "{:?}",
810 exposure.warnings
811 );
812 }
813
814 #[test]
815 fn test_public_exposure_warns_about_disabled_rate_limit_and_public_bind() {
816 let mut config = Config::default();
817 config.security.rate_limit.enabled = false;
818 config.server.host = "0.0.0.0".to_string();
819
820 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
821
822 assert!(exposure
823 .warnings
824 .iter()
825 .any(|w| w.contains("rate limiting")));
826 assert!(exposure.warnings.iter().any(|w| w.contains("0.0.0.0")));
827 }
828
829 #[test]
830 fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
831 let mut config = Config::default();
832 config.security.auth.preset = Some("operator".to_string());
833 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
834 assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
835 }
836
837 #[test]
838 fn test_transport_defaults_to_local_only() {
839 let config = Config::default();
840 assert_eq!(config.transport.mode, TransportMode::None);
841 assert!(config.tunnel_provider().unwrap().is_none());
842 }
843
844 #[test]
845 fn test_transport_mode_from_config_file() {
846 let json = r#"{"transport":{"mode":"cloudflared"}}"#;
847 let config: Config = serde_json::from_str(json).unwrap();
848 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
849 let provider = config.tunnel_provider().unwrap().expect("a provider");
850 assert_eq!(provider.name(), "cloudflared");
851 }
852
853 #[test]
854 fn test_transport_command_from_config_file() {
855 let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
856 let config: Config = serde_json::from_str(json).unwrap();
857 let provider = config.tunnel_provider().unwrap().expect("a provider");
858 assert_eq!(provider.name(), "tunnel-command");
859 }
860
861 #[test]
862 fn test_transport_command_mode_requires_a_command() {
863 let json = r#"{"transport":{"mode":"command"}}"#;
864 let config: Config = serde_json::from_str(json).unwrap();
865 let err = config.tunnel_provider().unwrap_err();
866 assert!(matches!(err, ConfigError::MissingTunnelCommand));
867 assert!(err.to_string().contains("transport.command"));
868 }
869
870 #[test]
871 fn test_cli_tunnel_overrides_config_file() {
872 let mut config: Config =
873 serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
874 config.apply_args(&Args {
875 tunnel: true,
876 ..Default::default()
877 });
878 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
879 }
880
881 #[test]
882 fn test_cli_tunnel_command_overrides_config_file() {
883 let mut config: Config =
884 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
885 config.apply_args(&Args {
886 tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
887 ..Default::default()
888 });
889 assert_eq!(config.transport.mode, TransportMode::Command);
890 assert_eq!(
891 config.transport.command.as_deref(),
892 Some("bore local 3000 --to bore.pub")
893 );
894 }
895
896 #[test]
897 fn test_config_file_transport_survives_unrelated_args() {
898 let mut config: Config =
899 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
900 config.apply_args(&Args::default());
901 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
902 }
903}