shell-tunnel 0.16.0

Ultra-lightweight remote shell gateway with a REST/WebSocket API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
//! Configuration management for shell-tunnel.
//!
//! Configuration is loaded with the following priority (highest to lowest):
//! 1. Command-line arguments
//! 2. Environment variables
//! 3. Configuration file (JSON)
//! 4. Default values

use std::net::IpAddr;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::api::{CorsConfig, SecurityConfig, ServerConfig};
use crate::cli::Args;
use crate::security::{AuthConfig, CapabilitySet, RateLimitConfig};
use crate::tunnel::{Cloudflared, CustomCommand, TunnelProvider};

/// Application configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Server configuration.
    pub server: ServerSection,
    /// Security configuration.
    pub security: SecuritySection,
    /// How the server is made reachable.
    pub transport: TransportSection,
    /// Logging configuration.
    pub logging: LoggingSection,
}

/// How the server is published to the outside world.
///
/// A single value rather than a set of flags: two reachability paths would each
/// allocate a different public URL for one server, so the configuration is not
/// allowed to express that state at all.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TransportMode {
    /// Bind locally only (default).
    #[default]
    None,
    /// Run a Cloudflare quick tunnel.
    Cloudflared,
    /// Run the tunnel command in [`TransportSection::command`].
    Command,
}

/// Reachability configuration section.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TransportSection {
    /// Which reachability path to use.
    pub mode: TransportMode,
    /// Tunnel command to run when `mode` is `command`.
    pub command: Option<String>,
}

/// Server configuration section.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerSection {
    /// Host address to bind to.
    pub host: String,
    /// Port to listen on.
    pub port: u16,
    /// Enable graceful shutdown.
    pub graceful_shutdown: bool,
}

impl Default for ServerSection {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".to_string(),
            port: 3000,
            graceful_shutdown: true,
        }
    }
}

/// Security configuration section.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecuritySection {
    /// Authentication settings.
    pub auth: AuthSection,
    /// Rate limiting settings.
    pub rate_limit: RateLimitSection,
    /// CORS settings.
    pub cors: CorsSection,
}

/// CORS configuration section.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CorsSection {
    /// Allow any origin (permissive CORS). Off by default; enable only for
    /// trusted browser-based UIs.
    pub allow_any: bool,
}

/// Authentication configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthSection {
    /// Enable authentication.
    pub enabled: bool,
    /// API keys.
    pub api_keys: Vec<String>,
    /// Capability strings scoping the keys (empty = full-control).
    pub capabilities: Vec<String>,
    /// Role preset scoping the keys (operator/file-write/file-read/full-control).
    pub preset: Option<String>,
}

/// Rate limiting configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitSection {
    /// Enable rate limiting.
    pub enabled: bool,
    /// Requests per window.
    pub requests_per_window: u32,
    /// Window size in seconds.
    pub window_secs: u64,
}

impl Default for RateLimitSection {
    fn default() -> Self {
        Self {
            enabled: true,
            requests_per_window: 100,
            window_secs: 60,
        }
    }
}

/// Logging configuration section.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoggingSection {
    /// Log level (error, warn, info, debug, trace).
    pub level: String,
}

impl Default for LoggingSection {
    fn default() -> Self {
        Self {
            level: "info".to_string(),
        }
    }
}

impl Config {
    /// Load configuration from a JSON file.
    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
        let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
        serde_json::from_str(&content).map_err(ConfigError::Json)
    }

    /// Apply environment variable overrides.
    pub fn apply_env(&mut self) {
        if let Ok(host) = std::env::var("SHELL_TUNNEL_HOST") {
            self.server.host = host;
        }

        if let Ok(port) = std::env::var("SHELL_TUNNEL_PORT") {
            if let Ok(port) = port.parse() {
                self.server.port = port;
            }
        }

        if let Ok(key) = std::env::var("SHELL_TUNNEL_API_KEY") {
            if !key.is_empty() {
                self.security.auth.enabled = true;
                if !self.security.auth.api_keys.contains(&key) {
                    self.security.auth.api_keys.push(key);
                }
            }
        }

        if let Ok(level) = std::env::var("SHELL_TUNNEL_LOG_LEVEL") {
            self.logging.level = level;
        } else if let Ok(level) = std::env::var("RUST_LOG") {
            self.logging.level = level;
        }
    }

    /// Apply CLI argument overrides.
    ///
    /// A flag that was passed replaces what the file or the environment said;
    /// a flag that was not passed leaves them alone. That reading is only
    /// possible for arguments that can tell "not passed" from "passed the
    /// default value" — hence `host_explicit`/`port_explicit`, since `Args`
    /// carries `127.0.0.1` and `3000` either way and an unconditional
    /// assignment made a configured bind address unreachable.
    ///
    /// It is not a universal, and the exceptions are not accidents. The
    /// boolean flags below (`--no-auth`, `--require-auth`, `--no-rate-limit`,
    /// `--cors-allow-any`) are one-way: passing one sets it, omitting one
    /// leaves the file's value, and there is no flag that turns rate limiting
    /// back *on* from the command line. Documenting the rule as universal has
    /// been tried three times here and was false each time.
    pub fn apply_args(&mut self, args: &Args) {
        if args.host_explicit {
            self.server.host = args.host.to_string();
        }
        if args.port_explicit {
            self.server.port = args.port;
        }

        if let Some(ref key) = args.api_key {
            self.security.auth.enabled = true;
            if !self.security.auth.api_keys.contains(key) {
                self.security.auth.api_keys.push(key.clone());
            }
        }

        // Enable auth on request (a key is auto-generated at startup if none is set).
        // Applied before `no_auth` so an explicit `--no-auth` still wins.
        if args.require_auth {
            self.security.auth.enabled = true;
        }

        // Token scoping (fine-grained capabilities / preset). Specifying a scope
        // implies auth-on — otherwise the scope would be silently ignored and the
        // server would start open, the opposite of what `--preset file-read` asks
        // for. Applied before `no_auth` so an explicit `--no-auth` still wins.
        //
        // Naming *either* one on the command line clears *both* of the file's
        // scope settings before applying what was named. `resolve_capabilities`
        // unions a preset with an explicit list, which is right within one
        // source — `--preset operator --capabilities fs.read` on one line is
        // plainly a request to add — but across sources it inverted the
        // operator's intent: a file saying `"preset": "operator"` plus a
        // command line saying `--capabilities fs.read` issued a token holding
        // operator's whole set *and* `fs.read`, `exec` still among them, when
        // the command line was narrowing. A scope input that cannot narrow is
        // not a scope input, and this failed in the reassuring direction.
        let scope_named = !args.capabilities.is_empty() || args.preset.is_some();
        if scope_named {
            self.security.auth.capabilities.clear();
            self.security.auth.preset = None;
            self.security.auth.enabled = true;
        }
        if !args.capabilities.is_empty() {
            self.security.auth.capabilities = args.capabilities.clone();
        }
        if let Some(ref preset) = args.preset {
            self.security.auth.preset = Some(preset.clone());
        }

        if args.no_auth {
            self.security.auth.enabled = false;
        }

        // CLI reachability flags override the file; the parser has already
        // rejected asking for two at once.
        if let Some(ref command) = args.tunnel_command {
            self.transport.mode = TransportMode::Command;
            self.transport.command = Some(command.clone());
        } else if args.tunnel {
            self.transport.mode = TransportMode::Cloudflared;
        }

        if args.no_rate_limit {
            self.security.rate_limit.enabled = false;
        }

        if args.cors_allow_any {
            self.security.cors.allow_any = true;
        }

        if let Some(ref level) = args.log_level {
            self.logging.level = level.clone();
        }
    }

    /// Load configuration with full priority chain.
    ///
    /// Priority: CLI args > env vars > config file > defaults
    pub fn load(args: &Args) -> Result<Self, ConfigError> {
        // Start with defaults
        let mut config = Config::default();

        // Load from config file if specified
        if let Some(ref path) = args.config {
            config = Config::from_file(path)?;
        }

        // Apply environment variable overrides
        config.apply_env();

        // Apply CLI argument overrides (highest priority)
        config.apply_args(args);

        Ok(config)
    }

    /// Host names this server should answer to, or `None` to accept any.
    ///
    /// Only a loopback-bound server that is not published gets a list. That is
    /// exactly where DNS rebinding applies: a browser resolves the attacker's
    /// name to `127.0.0.1`, so the request is same-origin and CORS never sees
    /// it, but the `Host` header still says whose name it was. A server reached
    /// through a tunnel or relay is deliberately published under a name we may
    /// not know, so checking would only refuse legitimate traffic.
    pub fn allowed_hosts(&self, args: &Args, published: bool) -> Option<Vec<String>> {
        let host: IpAddr = self.server.host.parse().ok()?;
        if published || !host.is_loopback() {
            return None;
        }

        let mut hosts = vec![
            "localhost".to_string(),
            "127.0.0.1".to_string(),
            "::1".to_string(),
        ];
        hosts.extend(args.allow_hosts.iter().cloned());
        Some(hosts)
    }

    /// Build the tunnel provider this configuration asks for, if any.
    pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
        match self.transport.mode {
            TransportMode::None => Ok(None),
            TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
            TransportMode::Command => {
                let command = self
                    .transport
                    .command
                    .as_deref()
                    .filter(|c| !c.trim().is_empty())
                    .ok_or(ConfigError::MissingTunnelCommand)?;
                Ok(Some(Box::new(CustomCommand::new(command))))
            }
        }
    }

    /// Determine how far this configuration is exposed.
    ///
    /// `tunnel_configured` is a single fact after the CLI (`--tunnel`/`--tunnel-command`)
    /// and config file (`transport.mode`) are merged — this function does not need to know
    /// which input path it came from. `relay_attached` indicates whether `--relay` was given.
    ///
    /// Bind address is judged by `!ip.is_loopback()` alone. This condition is the same one
    /// this file already uses for warnings — no new rules are introduced.
    pub fn posture(&self, tunnel_configured: bool, relay_attached: bool) -> Posture {
        if tunnel_configured || relay_attached {
            return Posture::Exposed;
        }
        match self.server.host.parse::<IpAddr>() {
            Ok(ip) if ip.is_loopback() => Posture::Local,
            // Parse failures are already rejected by `to_server_config` with `InvalidHost`,
            // so this branch is not reached in practice. Even so, we answer Exposed: inability
            // to judge is not evidence of safety.
            _ => Posture::Exposed,
        }
    }

    /// Harden the configuration for a publicly reachable deployment.
    ///
    /// Exposing the server through a tunnel turns every weak default into an
    /// internet-facing one, so this is enforced rather than advised:
    /// authentication is switched on, and a key is generated when none was
    /// supplied (the caller reports it — an unusable server would be worse).
    /// `--no-auth` is refused outright instead of being silently overridden.
    /// An unscoped token is likewise defaulted rather than warned about: it is
    /// scoped to the `operator` preset unless the consumer already chose a
    /// scope.
    ///
    /// The remaining risk is a real but legitimate choice, so it is warned
    /// about rather than blocked: rate limiting turned off.
    pub fn harden_for_public_exposure(
        &mut self,
        args: &Args,
    ) -> Result<PublicExposure, ConfigError> {
        if args.no_auth {
            return Err(ConfigError::RemoteWithoutAuth);
        }

        self.security.auth.enabled = true;

        let generated_key = self.ensure_api_key();

        // A default, not a warning. Warning about it is an admission that the
        // default is wrong for the situation, and here the default can follow
        // the situation instead.
        //
        // The actual reach is the same as `full-control` — `operator` already
        // has `exec`, and `exec` reaches every file this process can reach.
        // Only one thing changes: it does not automatically pick up
        // capabilities added later. That is the wildcard's real danger.
        //
        // An explicit scope is left untouched. If the consumer chose it, that
        // is the answer.
        if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
            self.security.auth.preset = Some("operator".to_string());
        }

        let mut warnings = Vec::new();
        // The one warning left. This is a defense the consumer explicitly
        // turned off, so a default cannot decide it on their behalf, and a
        // warning is right.
        if !self.security.rate_limit.enabled {
            warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
        }

        Ok(PublicExposure {
            generated_key,
            warnings,
        })
    }

    /// Issue the API key this server will serve with, when authentication is
    /// on and nothing supplied one.
    ///
    /// Returns the key that was generated — the only copy anyone gets — so the
    /// caller can put it in front of the operator. `None` means there was
    /// nothing to issue: a key was already supplied, or authentication is off.
    /// Calling it twice is safe for the same reason.
    ///
    /// Issuing it here rather than inside the server is what makes it
    /// printable. `serve_on` has no banner to print on, so a key created there
    /// can only reach the operator as a `tracing` line — and that line is gone
    /// at `-l warn` while the server still starts and still refuses every
    /// request that does not carry the key nobody was told.
    pub fn ensure_api_key(&mut self) -> Option<String> {
        if !self.security.auth.enabled || !self.security.auth.api_keys.is_empty() {
            return None;
        }
        let key = crate::security::generate_api_key();
        self.security.auth.api_keys.push(key.clone());
        Some(key)
    }

    /// Convert to ServerConfig for the API server.
    pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
        let host: IpAddr = self
            .server
            .host
            .parse()
            .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;

        let mut security = if self.security.auth.enabled {
            SecurityConfig::secure()
        } else {
            SecurityConfig::development()
        };

        // Apply auth settings
        security.auth = AuthConfig {
            enabled: self.security.auth.enabled,
            ..AuthConfig::default()
        };

        // Apply rate limit settings
        security.rate_limit = RateLimitConfig {
            enabled: self.security.rate_limit.enabled,
            max_requests: self.security.rate_limit.requests_per_window,
            window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
            max_tracked_ips: 10000,
        };

        // Apply CORS settings (restrictive by default)
        security.cors = CorsConfig {
            allow_any: self.security.cors.allow_any,
        };

        // Resolve fine-grained token scoping (preset + capabilities).
        if let Some(capabilities) = resolve_capabilities(
            self.security.auth.preset.as_deref(),
            &self.security.auth.capabilities,
        )? {
            security = security.with_capabilities(capabilities);
        }

        // Add API keys
        for key in &self.security.auth.api_keys {
            security = security.with_api_key(key);
        }

        let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
        server_config = server_config.with_security(security);

        if !self.server.graceful_shutdown {
            server_config = server_config.without_graceful_shutdown();
        }

        Ok(server_config)
    }

    /// The capability set an issued token will actually carry.
    ///
    /// `None` means nothing narrowed it — the full-control default, which is
    /// the wildcard. Resolved from the same two fields `to_server_config` uses
    /// and through the same function, so a caller that wants to *describe* the
    /// scope cannot drift from the one that enforces it. Call it after
    /// `harden_for_public_exposure`, or the answer predates the promotion.
    pub fn resolved_capabilities(&self) -> Result<Option<CapabilitySet>, ConfigError> {
        resolve_capabilities(
            self.security.auth.preset.as_deref(),
            &self.security.auth.capabilities,
        )
    }

    /// Get the log level filter string.
    pub fn log_filter(&self) -> &str {
        &self.logging.level
    }
}

/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
///
/// Returns `Ok(None)` when neither is given (full-control default). The preset
/// (if any) forms the base set and the explicit capabilities are unioned on top.
/// An unknown preset name is an error.
fn resolve_capabilities(
    preset: Option<&str>,
    capabilities: &[String],
) -> Result<Option<CapabilitySet>, ConfigError> {
    if preset.is_none() && capabilities.is_empty() {
        return Ok(None); // Full-control (legacy-compatible) default.
    }

    let mut set = match preset {
        Some(name) => crate::security::preset(name)
            .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
        None => CapabilitySet::new(),
    };
    for capability in capabilities {
        set.insert(capability.clone());
    }
    Ok(Some(set))
}

/// How far this process is exposed.
///
/// **Derived from arguments and not selectable by the user** — there is no option to choose
/// a posture, and there should not be one. What has already been chosen (tunnel, relay, bind
/// address) determines the posture, and the posture determines the security defaults.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Posture {
    /// Reachable only from this machine. No reason to narrow the defaults.
    Local,
    /// Reachable from other machines — one or more of: tunnel, relay, or non-loopback bind.
    Exposed,
}

/// Outcome of hardening a configuration for public exposure.
#[derive(Debug, Clone, Default)]
pub struct PublicExposure {
    /// Key generated because none was supplied — the only copy the user gets.
    pub generated_key: Option<String>,
    /// Risks that remain legitimate choices, reported rather than blocked.
    pub warnings: Vec<String>,
}

/// Configuration errors.
#[derive(Debug)]
pub enum ConfigError {
    /// IO error reading config file.
    Io(std::io::Error),
    /// JSON parsing error.
    Json(serde_json::Error),
    /// Invalid host address.
    InvalidHost(String),
    /// Unknown role preset name.
    InvalidPreset(String),
    /// A public reachability path was requested together with `--no-auth`.
    RemoteWithoutAuth,
    /// `transport.mode = "command"` without a command to run.
    MissingTunnelCommand,
}

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(e) => write!(f, "failed to read config file: {}", e),
            Self::Json(e) => write!(f, "failed to parse config file: {}", e),
            Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
            Self::InvalidPreset(name) if name == "read-only" => {
                write!(
                    f,
                    // Names the config key as well as the flags: this error is
                    // reached just as readily from `security.auth.preset` in a
                    // config file, where an operator told to change a flag they
                    // never passed has nowhere to look.
                    "the 'read-only' preset was removed: it granted only session.read, so it could not read a file despite its name. Use file-read to read files, or capabilities session.read for the old behaviour — as --preset/--capabilities, or as security.auth.preset/security.auth.capabilities in a config file"
                )
            }
            Self::InvalidPreset(name) => write!(
                f,
                "unknown role preset: '{}' (expected operator, file-write, file-read, or full-control)",
                name
            ),
            Self::MissingTunnelCommand => write!(
                f,
                "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
            ),
            Self::RemoteWithoutAuth => write!(
                f,
                "--no-auth cannot be combined with a publicly reachable server: that would expose an unauthenticated shell. It is refused for a tunnel, a relay, and a non-loopback bind alike. Drop --no-auth (a key is generated for you), or bind loopback and drop the public path"
            ),
        }
    }
}

impl std::error::Error for ConfigError {}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.server.host, "127.0.0.1");
        assert_eq!(config.server.port, 3000);
        assert!(!config.security.auth.enabled);
        assert!(config.security.rate_limit.enabled);
    }

    #[test]
    fn test_config_from_json() {
        let json = r#"{
            "server": {
                "host": "0.0.0.0",
                "port": 8080
            },
            "security": {
                "auth": {
                    "enabled": true,
                    "api_keys": ["key1", "key2"]
                }
            }
        }"#;

        let mut file = NamedTempFile::new().unwrap();
        file.write_all(json.as_bytes()).unwrap();

        let config = Config::from_file(file.path()).unwrap();
        assert_eq!(config.server.host, "0.0.0.0");
        assert_eq!(config.server.port, 8080);
        assert!(config.security.auth.enabled);
        assert_eq!(config.security.auth.api_keys.len(), 2);
    }

    #[test]
    fn test_config_partial_json() {
        let json = r#"{
            "server": {
                "port": 9000
            }
        }"#;

        let mut file = NamedTempFile::new().unwrap();
        file.write_all(json.as_bytes()).unwrap();

        let config = Config::from_file(file.path()).unwrap();
        assert_eq!(config.server.host, "127.0.0.1"); // Default
        assert_eq!(config.server.port, 9000);
    }

    #[test]
    fn test_apply_args() {
        let mut config = Config::default();
        let args = Args {
            host: "192.168.1.1".parse().unwrap(),
            // Both `_explicit` flags are what the parser sets when the flag is
            // actually on the command line; a struct literal that sets only
            // the value is describing a default, not a choice, and the two
            // have to stay distinguishable here for the same reason
            // `apply_args` distinguishes them.
            host_explicit: true,
            port: 5000,
            port_explicit: true,
            api_key: Some("test-key".to_string()),
            no_rate_limit: true,
            ..Args::default()
        };

        config.apply_args(&args);

        assert_eq!(config.server.host, "192.168.1.1");
        assert_eq!(config.server.port, 5000);
        assert!(config.security.auth.enabled);
        assert!(config
            .security
            .auth
            .api_keys
            .contains(&"test-key".to_string()));
        assert!(!config.security.rate_limit.enabled);
    }

    /// A configured bind address and port survive when no flag names them.
    ///
    /// This test was the inverse: it pinned an unconditional assignment from
    /// `Args`, whose defaults are `127.0.0.1` and `3000`, which overwrote a
    /// configured value even when the user passed no flag at all. It was
    /// written to be inverted — the documentation twice described a precedence
    /// that was never implemented, and the only other test to touch a
    /// configured port passed `-p`, which is exactly what hid the behaviour.
    #[test]
    fn a_configured_host_and_port_survive_when_no_flag_names_them() {
        let mut config = Config::default();
        // As a config file or `SHELL_TUNNEL_HOST`/`SHELL_TUNNEL_PORT` would
        // leave it: `Config::load` runs `apply_env` before `apply_args`, so
        // both arrive here indistinguishable from one another.
        config.server.host = "0.0.0.0".to_string();
        config.server.port = 8080;

        let nothing_passed = Args::default();
        assert!(
            !nothing_passed.port_explicit && !nothing_passed.host_explicit,
            "the premise: no flag was given"
        );
        config.apply_args(&nothing_passed);

        assert_eq!(config.server.host, "0.0.0.0");
        assert_eq!(config.server.port, 8080);
    }

    /// The flags still win when they are actually passed — the other half of
    /// the same rule, and the half that was never broken.
    #[test]
    fn a_named_host_and_port_beat_the_configured_ones() {
        let mut config = Config::default();
        config.server.host = "0.0.0.0".to_string();
        config.server.port = 8080;

        config.apply_args(&Args {
            host: "10.0.0.5".parse().expect("addr"),
            host_explicit: true,
            port: 9999,
            port_explicit: true,
            ..Args::default()
        });

        assert_eq!(config.server.host, "10.0.0.5");
        assert_eq!(config.server.port, 9999);
    }

    /// The consequence worth pinning separately. `server.host` is not just a
    /// bind address since 0.14.0 — it decides the security posture, and a
    /// configured `0.0.0.0` that now actually takes effect makes the server
    /// reachable, which forces authentication and an audit trail.
    ///
    /// It also re-checks the fail-closed property the old behaviour had by
    /// accident: `posture()` and `to_server_config()` must read the same
    /// field, so the posture can never describe a bind that did not happen.
    #[test]
    fn a_configured_non_loopback_host_now_decides_the_posture() {
        let mut config = Config::default();
        config.server.host = "0.0.0.0".to_string();
        config.apply_args(&Args::default());

        assert_eq!(
            config.posture(false, false),
            Posture::Exposed,
            "a bind address that now takes effect must also be seen by the posture"
        );
        let server = config.to_server_config().expect("valid config");
        assert_eq!(
            server.host, "0.0.0.0",
            "the posture and the listener must read the same field"
        );
    }

    #[test]
    fn test_apply_no_auth() {
        let mut config = Config::default();
        config.security.auth.enabled = true;

        let args = Args {
            no_auth: true,
            ..Args::default()
        };

        config.apply_args(&args);
        assert!(!config.security.auth.enabled);
    }

    #[test]
    fn test_apply_require_auth() {
        let mut config = Config::default();
        assert!(!config.security.auth.enabled); // disabled by default

        config.apply_args(&Args {
            require_auth: true,
            ..Args::default()
        });
        assert!(config.security.auth.enabled);
    }

    #[test]
    fn test_no_auth_overrides_require_auth() {
        let mut config = Config::default();

        // Contradictory flags: explicit --no-auth wins.
        config.apply_args(&Args {
            require_auth: true,
            no_auth: true,
            ..Args::default()
        });
        assert!(!config.security.auth.enabled);
    }

    #[test]
    fn test_to_server_config() {
        let config = Config::default();
        let server_config = config.to_server_config().unwrap();

        assert_eq!(server_config.host, "127.0.0.1");
        assert_eq!(server_config.port, 3000);
    }

    #[test]
    fn test_apply_args_capabilities_and_preset() {
        let mut config = Config::default();
        config.apply_args(&Args {
            capabilities: vec!["exec".to_string(), "session.read".to_string()],
            preset: Some("operator".to_string()),
            ..Args::default()
        });
        assert_eq!(
            config.security.auth.capabilities,
            vec!["exec", "session.read"]
        );
        assert_eq!(config.security.auth.preset, Some("operator".to_string()));
    }

    #[test]
    fn test_scope_implies_auth_on() {
        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
        // still turns auth on, so the server does not start open with the scope ignored.
        let mut by_preset = Config::default();
        by_preset.apply_args(&Args {
            preset: Some("file-read".to_string()),
            ..Args::default()
        });
        assert!(by_preset.security.auth.enabled);

        let mut by_caps = Config::default();
        by_caps.apply_args(&Args {
            capabilities: vec!["session.read".to_string()],
            ..Args::default()
        });
        assert!(by_caps.security.auth.enabled);
    }

    /// Naming a scope on the command line replaces the file's scope entirely,
    /// rather than being unioned on top of it.
    ///
    /// The union is right *within* one source — `--preset operator
    /// --capabilities fs.read` on one command line is plainly a request to add
    /// — but across sources it inverted the operator's intent: a file saying
    /// `"preset": "operator"` plus a command line saying `--capabilities
    /// fs.read` issued a token holding operator's whole set *and* `fs.read`,
    /// `exec` still among them, when the command line was narrowing. A scope
    /// input that cannot narrow is not a scope input.
    #[test]
    fn a_scope_named_on_the_command_line_replaces_the_files_scope() {
        let mut config = Config::default();
        config.security.auth.preset = Some("operator".to_string());

        config.apply_args(&Args {
            capabilities: vec!["fs.read".to_string()],
            ..Args::default()
        });

        assert_eq!(
            config.security.auth.preset, None,
            "the file's preset must not survive a scope named on the command line"
        );
        assert_eq!(config.security.auth.capabilities, vec!["fs.read"]);
        assert_eq!(
            resolve_capabilities(
                config.security.auth.preset.as_deref(),
                &config.security.auth.capabilities,
            )
            .expect("valid")
            .expect("a scope was named")
            .iter()
            .collect::<Vec<_>>(),
            vec!["fs.read"],
            "and the resolved set is what was asked for, with no exec left in it"
        );
    }

    /// The mirror case: a `capabilities` list in the file does not survive a
    /// `--preset` either. Narrowing with `--preset` has to escape the union
    /// from the same side.
    #[test]
    fn a_preset_named_on_the_command_line_replaces_the_files_capabilities() {
        let mut config = Config::default();
        config.security.auth.capabilities = vec!["exec".to_string()];

        config.apply_args(&Args {
            preset: Some("file-read".to_string()),
            ..Args::default()
        });

        assert!(
            config.security.auth.capabilities.is_empty(),
            "the file's capability list must not survive a preset named on the command line"
        );
        assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
    }

    /// Within one source the union stays: both given on one command line is a
    /// request to add, and this is what keeps the replacement above from being
    /// a blunt instrument.
    #[test]
    fn a_preset_and_capabilities_on_one_command_line_still_union() {
        let mut config = Config::default();
        config.apply_args(&Args {
            preset: Some("file-read".to_string()),
            capabilities: vec!["session.read".to_string()],
            ..Args::default()
        });

        let resolved = resolve_capabilities(
            config.security.auth.preset.as_deref(),
            &config.security.auth.capabilities,
        )
        .expect("valid")
        .expect("a scope was named");
        assert!(resolved.satisfies("fs.read"), "from the preset");
        assert!(resolved.satisfies("session.read"), "from the list");
    }

    #[test]
    fn test_no_auth_overrides_scope_implied_auth() {
        // Explicit --no-auth wins even when a scope is given.
        let mut config = Config::default();
        config.apply_args(&Args {
            preset: Some("file-read".to_string()),
            no_auth: true,
            ..Args::default()
        });
        assert!(!config.security.auth.enabled);
    }

    #[test]
    fn test_config_from_json_with_capabilities_and_preset() {
        // The new AuthSection fields deserialize from a config file and flow
        // through to a scoped SecurityConfig.
        let json = r#"{
            "security": {
                "auth": {
                    "enabled": true,
                    "api_keys": ["scoped"],
                    "preset": "file-read",
                    "capabilities": ["exec"]
                }
            }
        }"#;
        let mut file = NamedTempFile::new().unwrap();
        file.write_all(json.as_bytes()).unwrap();

        let config = Config::from_file(file.path()).unwrap();
        assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
        assert_eq!(config.security.auth.capabilities, vec!["exec"]);

        let server_config = config.to_server_config().unwrap();
        let caps = server_config
            .security
            .capabilities
            .expect("capabilities scoped from file");
        assert!(caps.satisfies("fs.read")); // from file-read preset
        assert!(caps.satisfies("exec")); // unioned explicit capability
        assert!(!caps.satisfies("session.manage"));
    }

    #[test]
    fn test_resolve_capabilities_none_by_default() {
        // No preset, no capabilities -> full-control (None).
        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
    }

    #[test]
    fn test_resolve_capabilities_preset_plus_extra() {
        // file-read preset unioned with an explicit `exec`.
        let set = resolve_capabilities(Some("file-read"), &["exec".to_string()])
            .unwrap()
            .unwrap();
        assert!(set.satisfies("fs.read"));
        assert!(set.satisfies("exec"));
        assert!(!set.satisfies("session.manage"));
    }

    #[test]
    fn test_resolve_capabilities_invalid_preset_errors() {
        let err = resolve_capabilities(Some("superuser"), &[]);
        assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
    }

    #[test]
    fn test_to_server_config_scopes_capabilities() {
        let mut config = Config::default();
        config.security.auth.enabled = true;
        config.security.auth.api_keys = vec!["scoped".to_string()];
        config.security.auth.preset = Some("file-read".to_string());

        let server_config = config.to_server_config().unwrap();
        let caps = server_config
            .security
            .capabilities
            .expect("capabilities scoped");
        assert!(caps.satisfies("fs.read"));
        assert!(!caps.satisfies("exec"));
    }

    #[test]
    fn test_to_server_config_invalid_preset_errors() {
        let mut config = Config::default();
        config.security.auth.preset = Some("root".to_string());
        assert!(matches!(
            config.to_server_config(),
            Err(ConfigError::InvalidPreset(_))
        ));
    }

    #[test]
    fn the_read_only_refusal_names_its_replacement() {
        let err = ConfigError::InvalidPreset("read-only".to_string());
        let message = err.to_string();
        assert!(
            message.contains("file-read"),
            "must point at the replacement: {message}"
        );
        assert!(
            message.contains("session.read"),
            "must offer the exact escape: {message}"
        );
        // `security.auth.preset` reaches this error too, and an operator who
        // set it there never passed a flag to correct.
        assert!(
            message.contains("security.auth.preset"),
            "must name the config key, not only the flags: {message}"
        );
    }

    #[test]
    fn an_unknown_preset_lists_the_valid_ones() {
        let err = ConfigError::InvalidPreset("nonsense".to_string());
        let message = err.to_string();
        for name in ["operator", "file-write", "file-read", "full-control"] {
            assert!(message.contains(name), "must list {name}: {message}");
        }
        assert!(
            !message.contains("read-only"),
            "must not advertise a removed preset: {message}"
        );
    }

    #[test]
    fn test_invalid_host() {
        let mut config = Config::default();
        config.server.host = "not-an-ip".to_string();

        let result = config.to_server_config();
        assert!(result.is_err());
    }

    #[test]
    fn test_config_serialization() {
        let config = Config::default();
        let json = serde_json::to_string_pretty(&config).unwrap();
        assert!(json.contains("\"host\""));
        assert!(json.contains("\"port\""));
    }

    fn tunnel_args() -> Args {
        Args {
            tunnel: true,
            ..Default::default()
        }
    }

    #[test]
    fn test_public_exposure_refuses_no_auth() {
        let mut config = Config::default();
        let args = Args {
            no_auth: true,
            ..tunnel_args()
        };
        let err = config.harden_for_public_exposure(&args).unwrap_err();
        assert!(matches!(err, ConfigError::RemoteWithoutAuth));
        assert!(err.to_string().contains("unauthenticated shell"));
    }

    #[test]
    fn test_public_exposure_enables_auth_and_generates_a_key() {
        let mut config = Config::default();
        assert!(!config.security.auth.enabled);

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(config.security.auth.enabled);
        let key = exposure.generated_key.expect("a key must be generated");
        assert!(key.starts_with("st_"));
        assert_eq!(config.security.auth.api_keys, vec![key]);
    }

    #[test]
    fn test_public_exposure_keeps_a_supplied_key() {
        let mut config = Config::default();
        config.security.auth.api_keys.push("my-key".to_string());

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(exposure.generated_key.is_none());
        assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
    }

    #[test]
    fn test_public_exposure_no_longer_warns_about_an_unscoped_token_because_it_scopes_it() {
        let mut config = Config::default();
        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
        assert!(
            !exposure.warnings.iter().any(|w| w.contains("full control")),
            "{:?}",
            exposure.warnings
        );
    }

    #[test]
    fn test_public_exposure_does_not_warn_about_a_scoped_token() {
        let mut config = Config::default();
        config.security.auth.preset = Some("operator".to_string());
        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
        assert!(
            !exposure.warnings.iter().any(|w| w.contains("full control")),
            "{:?}",
            exposure.warnings
        );
    }

    #[test]
    fn test_public_exposure_warns_about_disabled_rate_limit() {
        let mut config = Config::default();
        config.security.rate_limit.enabled = false;

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(exposure
            .warnings
            .iter()
            .any(|w| w.contains("rate limiting")));
    }

    #[test]
    fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
        let mut config = Config::default();
        config.security.auth.preset = Some("operator".to_string());
        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
        assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
    }

    #[test]
    fn exposure_scopes_the_issued_token_instead_of_warning_about_it() {
        let mut config = Config::default();
        assert!(config.security.auth.preset.is_none());

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        // The default handles the situation, so there is nothing left to warn about.
        assert_eq!(config.security.auth.preset.as_deref(), Some("operator"));
        assert!(
            !exposure.warnings.iter().any(|w| w.contains("full control")),
            "the warning must be gone, not merely reworded: {:?}",
            exposure.warnings
        );
    }

    #[test]
    fn the_exposed_token_is_not_a_wildcard() {
        // The actual reach is unchanged. What changes is one thing: it does not
        // automatically pick up capabilities added later. That is the wildcard's
        // real danger.
        let mut config = Config::default();
        config.harden_for_public_exposure(&tunnel_args()).unwrap();

        let set = resolve_capabilities(
            config.security.auth.preset.as_deref(),
            &config.security.auth.capabilities,
        )
        .unwrap()
        .expect("an exposed token must have an explicit set");
        assert!(!set.is_wildcard());
        assert!(set.satisfies("exec"));
        assert!(set.satisfies("fs.write"));
    }

    #[test]
    fn an_explicit_scope_is_left_alone() {
        let mut config = Config::default();
        config.security.auth.preset = Some("file-read".to_string());

        config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert_eq!(config.security.auth.preset.as_deref(), Some("file-read"));
    }

    #[test]
    fn explicit_capabilities_are_left_alone_too() {
        let mut config = Config::default();
        config.security.auth.capabilities = vec!["exec".to_string()];

        config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(config.security.auth.preset.is_none());
        assert_eq!(config.security.auth.capabilities, vec!["exec".to_string()]);
    }

    #[test]
    fn a_non_loopback_bind_no_longer_warns_because_it_now_decides_the_posture() {
        let mut config = Config::default();
        config.server.host = "0.0.0.0".to_string();

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(
            !exposure.warnings.iter().any(|w| w.contains("binding")),
            "posture covers this now: {:?}",
            exposure.warnings
        );
    }

    #[test]
    fn a_disabled_rate_limit_still_warns() {
        // This is a risk the consumer explicitly chose, so a warning is right —
        // it is not the kind of thing a default can decide on their behalf.
        let mut config = Config::default();
        config.security.rate_limit.enabled = false;

        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();

        assert!(exposure
            .warnings
            .iter()
            .any(|w| w.contains("rate limiting")));
    }

    #[test]
    fn a_loopback_server_answers_only_to_local_names() {
        let config = Config::default();
        let hosts = config
            .allowed_hosts(&Args::default(), false)
            .expect("a loopback server gets a list");

        assert!(hosts.contains(&"localhost".to_string()));
        assert!(hosts.contains(&"127.0.0.1".to_string()));
    }

    #[test]
    fn a_published_server_is_not_host_checked() {
        // Reached under a name we may not know; checking would only refuse
        // legitimate traffic.
        let config = Config::default();
        assert!(config.allowed_hosts(&Args::default(), true).is_none());
    }

    #[test]
    fn a_non_loopback_bind_is_not_host_checked() {
        let mut config = Config::default();
        config.server.host = "0.0.0.0".to_string();
        assert!(config.allowed_hosts(&Args::default(), false).is_none());
    }

    #[test]
    fn extra_allowed_hosts_join_the_defaults() {
        let config = Config::default();
        let args = Args {
            allow_hosts: vec!["myapp.internal".to_string()],
            ..Default::default()
        };
        let hosts = config.allowed_hosts(&args, false).unwrap();

        assert!(hosts.contains(&"myapp.internal".to_string()));
        assert!(hosts.contains(&"localhost".to_string()));
    }

    #[test]
    fn test_transport_defaults_to_local_only() {
        let config = Config::default();
        assert_eq!(config.transport.mode, TransportMode::None);
        assert!(config.tunnel_provider().unwrap().is_none());
    }

    #[test]
    fn test_transport_mode_from_config_file() {
        let json = r#"{"transport":{"mode":"cloudflared"}}"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
        let provider = config.tunnel_provider().unwrap().expect("a provider");
        assert_eq!(provider.name(), "cloudflared");
    }

    #[test]
    fn test_transport_command_from_config_file() {
        let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
        let config: Config = serde_json::from_str(json).unwrap();
        let provider = config.tunnel_provider().unwrap().expect("a provider");
        assert_eq!(provider.name(), "tunnel-command");
    }

    #[test]
    fn test_transport_command_mode_requires_a_command() {
        let json = r#"{"transport":{"mode":"command"}}"#;
        let config: Config = serde_json::from_str(json).unwrap();
        let err = config.tunnel_provider().unwrap_err();
        assert!(matches!(err, ConfigError::MissingTunnelCommand));
        assert!(err.to_string().contains("transport.command"));
    }

    #[test]
    fn test_cli_tunnel_overrides_config_file() {
        let mut config: Config =
            serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
        config.apply_args(&Args {
            tunnel: true,
            ..Default::default()
        });
        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
    }

    #[test]
    fn test_cli_tunnel_command_overrides_config_file() {
        let mut config: Config =
            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
        config.apply_args(&Args {
            tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
            ..Default::default()
        });
        assert_eq!(config.transport.mode, TransportMode::Command);
        assert_eq!(
            config.transport.command.as_deref(),
            Some("bore local 3000 --to bore.pub")
        );
    }

    #[test]
    fn test_config_file_transport_survives_unrelated_args() {
        let mut config: Config =
            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
        config.apply_args(&Args::default());
        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
    }

    #[test]
    fn loopback_bind_without_a_public_path_is_local() {
        let config = Config::default();
        assert_eq!(config.server.host, "127.0.0.1");
        assert_eq!(config.posture(false, false), Posture::Local);
    }

    #[test]
    fn a_tunnel_or_a_relay_makes_it_exposed() {
        let config = Config::default();
        assert_eq!(config.posture(true, false), Posture::Exposed);
        assert_eq!(config.posture(false, true), Posture::Exposed);
    }

    #[test]
    fn a_non_loopback_bind_is_exposed_on_its_own() {
        // No tunnel and no relay. Open to the LAN alone is exposure — reachable from another machine.
        let mut config = Config::default();
        config.server.host = "0.0.0.0".to_string();
        assert_eq!(config.posture(false, false), Posture::Exposed);

        config.server.host = "192.168.1.10".to_string();
        assert_eq!(config.posture(false, false), Posture::Exposed);

        config.server.host = "::".to_string();
        assert_eq!(config.posture(false, false), Posture::Exposed);
    }

    #[test]
    fn ipv6_loopback_is_local() {
        let mut config = Config::default();
        config.server.host = "::1".to_string();
        assert_eq!(config.posture(false, false), Posture::Local);
    }

    #[test]
    fn an_unparseable_host_is_exposed_rather_than_local() {
        // `to_server_config` already rejects this with `InvalidHost` at startup, so this
        // branch is not actually reachable. Even so, we fix the fail-closed direction — the
        // moment we read "unable to judge" as "safe", it becomes speculation, not proof.
        let mut config = Config::default();
        config.server.host = "not-an-ip".to_string();
        assert_eq!(config.posture(false, false), Posture::Exposed);
    }
}