fbi-proxy 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/dist/cli.js +772 -29
- package/package.json +16 -10
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +111 -87
- package/rs/lib.rs +12 -0
- package/rs/routes.rs +976 -0
- package/ts/auth/authConfig.ts +141 -0
- package/ts/auth/caddyfileGen.test.ts +156 -0
- package/ts/auth/caddyfileGen.ts +142 -0
- package/ts/auth/downloadCaddy.test.ts +131 -0
- package/ts/auth/downloadCaddy.ts +213 -0
- package/ts/auth/setupWizard.ts +183 -0
- package/ts/auth/spawnCaddy.ts +125 -0
- package/ts/auth/spawnFbiAuth.ts +43 -0
- package/ts/buildFbiProxy.ts +3 -11
- package/ts/cli.ts +190 -7
- package/ts/dSpawn.ts +4 -9
- package/ts/getProxyFilename.ts +11 -9
- package/ts/routes.test.ts +182 -0
- package/ts/routes.ts +238 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fbi-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "FBI-Proxy provides easy HTTPS access to your local services with intelligent domain routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"development-tools",
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/snomiao/fbi-proxy/issues"
|
|
15
15
|
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "snomiao",
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
18
20
|
"url": "git+https://github.com/snomiao/fbi-proxy.git"
|
|
19
21
|
},
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"author": "snomiao",
|
|
22
|
-
"type": "module",
|
|
23
22
|
"bin": {
|
|
24
23
|
"fbi-proxy": "dist/cli.js"
|
|
25
24
|
},
|
|
@@ -29,6 +28,7 @@
|
|
|
29
28
|
"rs",
|
|
30
29
|
"ts"
|
|
31
30
|
],
|
|
31
|
+
"type": "module",
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "bun run build:rs && bun run build:js",
|
|
34
34
|
"build:js": "bun build ./ts/cli.ts --outdir dist --target node",
|
|
@@ -52,7 +52,10 @@
|
|
|
52
52
|
"execa": "^9.6.1",
|
|
53
53
|
"from-node-stream": "^0.1.2",
|
|
54
54
|
"get-port": "^7.1.0",
|
|
55
|
+
"hono": "^4.12.18",
|
|
55
56
|
"hot-memo": "^1.1.1",
|
|
57
|
+
"jose": "^6.2.3",
|
|
58
|
+
"oauth4webapi": "^3.8.6",
|
|
56
59
|
"sflow": "^1.27.0",
|
|
57
60
|
"tsa-composer": "^1.0.0",
|
|
58
61
|
"yargs": "^17.7.2"
|
|
@@ -70,6 +73,7 @@
|
|
|
70
73
|
"husky": "^9.1.7",
|
|
71
74
|
"lint-staged": "^16.2.7",
|
|
72
75
|
"node-fetch": "^3.3.2",
|
|
76
|
+
"oxmgr": "^0.4.0",
|
|
73
77
|
"playwright": "^1.58.2",
|
|
74
78
|
"prettier": "^3.8.1",
|
|
75
79
|
"semantic-release": "^24.0.0",
|
|
@@ -78,8 +82,8 @@
|
|
|
78
82
|
"vitest": "^3.2.4",
|
|
79
83
|
"ws": "^8.19.0"
|
|
80
84
|
},
|
|
81
|
-
"
|
|
82
|
-
"
|
|
85
|
+
"overrides": {
|
|
86
|
+
"@semantic-release/npm": "^13.1.3"
|
|
83
87
|
},
|
|
84
88
|
"lint-staged": {
|
|
85
89
|
"*.{ts,js}": [
|
|
@@ -89,8 +93,10 @@
|
|
|
89
93
|
"bun --bun prettier --write"
|
|
90
94
|
]
|
|
91
95
|
},
|
|
92
|
-
"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
"engines": {
|
|
97
|
+
"node": ">=22.14.0"
|
|
98
|
+
},
|
|
99
|
+
"trustedDependencies": [
|
|
100
|
+
"oxmgr"
|
|
101
|
+
]
|
|
96
102
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/rs/fbi-proxy.rs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use clap::{Arg, Command};
|
|
2
|
+
use fbi_proxy::routes::{self, CompiledRoute, RouteHit};
|
|
2
3
|
use futures_util::{SinkExt, StreamExt};
|
|
3
4
|
use http_body_util::{BodyExt, Full};
|
|
4
5
|
use hyper::body::{Bytes, Incoming};
|
|
@@ -23,18 +24,22 @@ use tokio_tungstenite::connect_async;
|
|
|
23
24
|
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
|
24
25
|
type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
|
|
25
26
|
|
|
27
|
+
/// Bundled default routes.yaml — reproduces the original `parse_host`
|
|
28
|
+
/// behavior. Loaded at compile-time so the binary works out-of-the-box.
|
|
29
|
+
const BUNDLED_ROUTES_YAML: &str = include_str!("../routes.yaml");
|
|
30
|
+
|
|
26
31
|
pub struct FBIProxy {
|
|
27
32
|
client: Client<HttpConnector, BoxBody>,
|
|
28
33
|
number_regex: Regex,
|
|
29
34
|
domain_filter: Option<String>,
|
|
35
|
+
compiled_routes: Vec<CompiledRoute>,
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/*
|
|
33
|
-
FBIProxy is a simple HTTP and WebSocket proxy server
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for localhost, it uses "localhost"
|
|
39
|
+
FBIProxy is a simple HTTP and WebSocket proxy server with rule-based
|
|
40
|
+
host header routing. The rules are loaded from `routes.yaml` (bundled
|
|
41
|
+
by default, overridable via --routes). See the bundled `routes.yaml`
|
|
42
|
+
for the default 8 rules; in short:
|
|
38
43
|
|
|
39
44
|
rule1: number host goes to local port
|
|
40
45
|
- Host="3000" => localhost:3000
|
|
@@ -52,15 +57,13 @@ rule3: subdomains are hoisted
|
|
|
52
57
|
- 3000.amd => proxies to http://amd:80, with host: 3000
|
|
53
58
|
- sur.amd => proxies to http://amd:80, with host: sur
|
|
54
59
|
- amd.sur.amd => proxies to http://amd:80, with host: amd.sur
|
|
55
|
-
- if sur also runs fbi-proxy, it will proxies to http://amd:80, with host: amd
|
|
56
|
-
- 3000.sur.amd => proxies to http://amd:80, with host: 3000.sur
|
|
57
|
-
|
|
58
|
-
for subdomains
|
|
59
|
-
*.amd => localhost:amd
|
|
60
60
|
|
|
61
|
+
When `--domain` (or `FBI_PROXY_DOMAIN`) is set, only hosts ending with
|
|
62
|
+
that suffix are accepted. The exact-domain host (e.g. `fbi.com`) serves
|
|
63
|
+
the landing page.
|
|
61
64
|
*/
|
|
62
65
|
impl FBIProxy {
|
|
63
|
-
pub fn new(domain_filter: Option<String>) -> Self {
|
|
66
|
+
pub fn new(domain_filter: Option<String>, compiled_routes: Vec<CompiledRoute>) -> Self {
|
|
64
67
|
let mut connector = HttpConnector::new();
|
|
65
68
|
// Set connection timeout to 5 seconds to avoid hanging on invalid hosts
|
|
66
69
|
connector.set_connect_timeout(Some(Duration::from_secs(3)));
|
|
@@ -72,6 +75,7 @@ impl FBIProxy {
|
|
|
72
75
|
client,
|
|
73
76
|
number_regex: Regex::new(r"^\d+$").unwrap(),
|
|
74
77
|
domain_filter,
|
|
78
|
+
compiled_routes,
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -165,77 +169,52 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
165
169
|
</html>"#.to_string()
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
let host = if let Some(domain) = domain_filter {
|
|
178
|
-
if !domain.is_empty() {
|
|
179
|
-
// Check if host ends with the domain filter
|
|
180
|
-
if host_without_port.ends_with(domain) {
|
|
181
|
-
// Strip the domain suffix (including the dot)
|
|
182
|
-
let prefix_len = host_without_port.len() - domain.len();
|
|
183
|
-
if prefix_len > 0 && host_without_port.chars().nth(prefix_len - 1) == Some('.') {
|
|
184
|
-
// Remove the domain and the dot before it
|
|
185
|
-
&host_without_port[..prefix_len - 1]
|
|
186
|
-
} else if prefix_len == 0 {
|
|
187
|
-
// The host is exactly the domain, treat as root
|
|
188
|
-
"@"
|
|
189
|
-
} else {
|
|
190
|
-
// No dot separator, invalid format
|
|
191
|
-
return None;
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
// Host doesn't match domain filter
|
|
195
|
-
return None;
|
|
196
|
-
}
|
|
197
|
-
} else {
|
|
198
|
-
// Empty domain filter, accept all
|
|
199
|
-
host_without_port
|
|
200
|
-
}
|
|
201
|
-
} else {
|
|
202
|
-
// No domain filter, accept all
|
|
203
|
-
host_without_port
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// Handle special case: @ means root domain was accessed - serve landing page
|
|
207
|
-
if host == "@" {
|
|
208
|
-
return Some(("@LANDING".to_string(), "@LANDING".to_string()));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Rule 1: number host goes to local port (e.g., "3000" => "localhost:3000")
|
|
212
|
-
if self.number_regex.is_match(host) {
|
|
213
|
-
return Some((format!("localhost:{}", host), "localhost".to_string()));
|
|
172
|
+
/// Extract the hostname portion (before the first `:`) from a target
|
|
173
|
+
/// string like `"127.0.0.1:3000"`. This is the default `Host` header
|
|
174
|
+
/// value used when a matched rule doesn't specify an explicit
|
|
175
|
+
/// `headers.Host` rewrite — which preserves the original
|
|
176
|
+
/// `parse_host` semantics (numeric subdomain → Host: localhost).
|
|
177
|
+
fn host_from_target(target: &str) -> String {
|
|
178
|
+
match target.find(':') {
|
|
179
|
+
Some(i) => target[..i].to_string(),
|
|
180
|
+
None => target.to_string(),
|
|
214
181
|
}
|
|
182
|
+
}
|
|
215
183
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
184
|
+
/// Returns Some((target, new_host_header)) if the routing engine
|
|
185
|
+
/// matches `host_header`, accounting for:
|
|
186
|
+
/// * domain filter pre-check (host must end with the configured
|
|
187
|
+
/// domain suffix, if any),
|
|
188
|
+
/// * exact-domain match → ("@LANDING", "@LANDING") so the request
|
|
189
|
+
/// handler serves the landing page,
|
|
190
|
+
/// * normal rule match → (target, host_header).
|
|
191
|
+
///
|
|
192
|
+
/// Returns None if the host is rejected (filter mismatch or no
|
|
193
|
+
/// matching rule).
|
|
194
|
+
fn route(&self, host_header: &str) -> Option<(String, String)> {
|
|
195
|
+
// Drop port if present.
|
|
196
|
+
let host_without_port = match host_header.find(':') {
|
|
197
|
+
Some(i) => &host_header[..i],
|
|
198
|
+
None => host_header,
|
|
199
|
+
};
|
|
222
200
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
if
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let subdomain = subdomain_parts.join(".");
|
|
230
|
-
|
|
231
|
-
// Target is the main domain on port 80
|
|
232
|
-
let target_host = format!("{}:80", main_domain);
|
|
233
|
-
// New host header is the subdomain
|
|
234
|
-
return Some((target_host, subdomain.to_string()));
|
|
201
|
+
// Exact-domain match → serve landing page. Only relevant when a
|
|
202
|
+
// domain filter is configured.
|
|
203
|
+
if let Some(ref domain) = self.domain_filter {
|
|
204
|
+
if !domain.is_empty() && host_without_port.eq_ignore_ascii_case(domain) {
|
|
205
|
+
return Some(("@LANDING".to_string(), "@LANDING".to_string()));
|
|
206
|
+
}
|
|
235
207
|
}
|
|
236
208
|
|
|
237
|
-
|
|
238
|
-
|
|
209
|
+
let hit = routes::match_host_with_domain(
|
|
210
|
+
&self.compiled_routes,
|
|
211
|
+
host_header,
|
|
212
|
+
self.domain_filter.as_deref(),
|
|
213
|
+
)?;
|
|
214
|
+
|
|
215
|
+
let RouteHit { target, host_header: rewrite, .. } = hit;
|
|
216
|
+
let new_host = rewrite.unwrap_or_else(|| Self::host_from_target(&target));
|
|
217
|
+
Some((target, new_host))
|
|
239
218
|
}
|
|
240
219
|
|
|
241
220
|
pub async fn handle_request(&self, req: Request<Incoming>) -> Result<Response<BoxBody>, BoxError> {
|
|
@@ -247,9 +226,9 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
247
226
|
.unwrap_or("localhost")
|
|
248
227
|
.to_string();
|
|
249
228
|
|
|
250
|
-
//
|
|
251
|
-
let parsed_host = self.
|
|
252
|
-
|
|
229
|
+
// Route the host via the rule engine.
|
|
230
|
+
let parsed_host = self.route(&host_header);
|
|
231
|
+
|
|
253
232
|
// If domain filter rejects the host, return 502 Bad Gateway
|
|
254
233
|
let (target_host, new_host) = match parsed_host {
|
|
255
234
|
Some(hosts) => hosts,
|
|
@@ -590,10 +569,29 @@ async fn handle_connection(
|
|
|
590
569
|
}
|
|
591
570
|
}
|
|
592
571
|
|
|
593
|
-
|
|
572
|
+
/// Load routes from `routes.yaml` source text and compile them. Panics
|
|
573
|
+
/// with a descriptive message on parse or compile failure — this is
|
|
574
|
+
/// only invoked at startup, so failing fast is the right policy.
|
|
575
|
+
fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
|
|
576
|
+
let parsed = match routes::parse_yaml(yaml_src) {
|
|
577
|
+
Ok(p) => p,
|
|
578
|
+
Err(e) => panic!("failed to parse {}: {}", source_label, e),
|
|
579
|
+
};
|
|
580
|
+
match routes::compile(parsed.routes) {
|
|
581
|
+
Ok(c) => c,
|
|
582
|
+
Err(e) => panic!("failed to compile {}: {}", source_label, e),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
pub async fn start_proxy_server(
|
|
587
|
+
host: Option<&str>,
|
|
588
|
+
port: u16,
|
|
589
|
+
domain_filter: Option<String>,
|
|
590
|
+
compiled_routes: Vec<CompiledRoute>,
|
|
591
|
+
) -> Result<(), BoxError> {
|
|
594
592
|
let host = host.unwrap_or("127.0.0.1");
|
|
595
593
|
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
|
596
|
-
let proxy = Arc::new(FBIProxy::new(domain_filter.clone()));
|
|
594
|
+
let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
|
|
597
595
|
|
|
598
596
|
let listener = TcpListener::bind(addr).await?;
|
|
599
597
|
|
|
@@ -606,7 +604,7 @@ pub async fn start_proxy_server(host: Option<&str>, port: u16, domain_filter: Op
|
|
|
606
604
|
}
|
|
607
605
|
println!();
|
|
608
606
|
println!("== HOW IT WORKS ==");
|
|
609
|
-
println!("Routes requests based on Host header:");
|
|
607
|
+
println!("Routes requests based on Host header (configurable via routes.yaml):");
|
|
610
608
|
println!(" 3000 -> localhost:3000 (port as host)");
|
|
611
609
|
println!(" api--8080 -> api:8080 (host--port syntax)");
|
|
612
610
|
println!(" 3000.fbi.com -> localhost:3000 (subdomain as port)");
|
|
@@ -660,11 +658,11 @@ fn main() {
|
|
|
660
658
|
|
|
661
659
|
FEATURES:
|
|
662
660
|
• HTTP and WebSocket proxying with bidirectional forwarding
|
|
663
|
-
• Smart host header parsing with multiple routing rules
|
|
661
|
+
• Smart host header parsing with multiple routing rules (configurable via routes.yaml)
|
|
664
662
|
• Port encoding support for easy local development
|
|
665
663
|
• Subdomain hoisting for multi-service architectures
|
|
666
664
|
|
|
667
|
-
HOST PARSING RULES:
|
|
665
|
+
HOST PARSING RULES (default routes.yaml):
|
|
668
666
|
1. Number host → local port: '3000' → localhost:3000
|
|
669
667
|
2. Host--port syntax: 'api--3000' → api:3000
|
|
670
668
|
3. Subdomain hoisting: 'api.service' → service:80 (host: api)
|
|
@@ -674,6 +672,7 @@ ENVIRONMENT VARIABLES:
|
|
|
674
672
|
FBI_PROXY_PORT Port to listen on (default: 2432)
|
|
675
673
|
FBI_PROXY_HOST Host/IP address to bind to (default: 127.0.0.1)
|
|
676
674
|
FBI_PROXY_DOMAIN Domain filter (only accept *.domain requests)
|
|
675
|
+
FBI_PROXY_ROUTES Path to a custom routes.yaml (default: bundled)
|
|
677
676
|
RUST_LOG Log level (error, warn, info, debug, trace)
|
|
678
677
|
|
|
679
678
|
EXAMPLES:
|
|
@@ -681,6 +680,7 @@ EXAMPLES:
|
|
|
681
680
|
fbi-proxy -p 8080 # Custom port
|
|
682
681
|
fbi-proxy -h 0.0.0.0 -p 3000 # Bind to all interfaces
|
|
683
682
|
fbi-proxy -d example.com # Only accept *.example.com requests
|
|
683
|
+
fbi-proxy -r ./my-routes.yaml # Use a custom routing config
|
|
684
684
|
FBI_PROXY_PORT=8080 fbi-proxy # Use environment variable
|
|
685
685
|
|
|
686
686
|
TRY RUN:
|
|
@@ -719,6 +719,15 @@ TRY RUN:
|
|
|
719
719
|
.env("FBI_PROXY_DOMAIN")
|
|
720
720
|
.default_value("")
|
|
721
721
|
)
|
|
722
|
+
.arg(
|
|
723
|
+
Arg::new("routes")
|
|
724
|
+
.short('r')
|
|
725
|
+
.long("routes")
|
|
726
|
+
.value_name("PATH")
|
|
727
|
+
.help("Path to a custom routes.yaml (env: FBI_PROXY_ROUTES, default: bundled)")
|
|
728
|
+
.env("FBI_PROXY_ROUTES")
|
|
729
|
+
.default_value("")
|
|
730
|
+
)
|
|
722
731
|
.get_matches();
|
|
723
732
|
|
|
724
733
|
let port = matches
|
|
@@ -732,20 +741,35 @@ TRY RUN:
|
|
|
732
741
|
|
|
733
742
|
let host = matches.get_one::<String>("host").unwrap();
|
|
734
743
|
let domain = matches.get_one::<String>("domain").unwrap();
|
|
735
|
-
|
|
744
|
+
let routes_path = matches.get_one::<String>("routes").unwrap();
|
|
745
|
+
|
|
736
746
|
let domain_filter = if domain.is_empty() {
|
|
737
747
|
None
|
|
738
748
|
} else {
|
|
739
749
|
Some(domain.clone())
|
|
740
750
|
};
|
|
741
751
|
|
|
752
|
+
// Load routes: either from --routes <path> or the bundled default.
|
|
753
|
+
let compiled_routes = if routes_path.is_empty() {
|
|
754
|
+
load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml")
|
|
755
|
+
} else {
|
|
756
|
+
let src = match std::fs::read_to_string(routes_path) {
|
|
757
|
+
Ok(s) => s,
|
|
758
|
+
Err(e) => {
|
|
759
|
+
eprintln!("error: failed to read --routes file '{}': {}", routes_path, e);
|
|
760
|
+
std::process::exit(2);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
load_routes(&src, &format!("routes file '{}'", routes_path))
|
|
764
|
+
};
|
|
765
|
+
|
|
742
766
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
743
767
|
rt.block_on(async {
|
|
744
768
|
info!(
|
|
745
769
|
"Starting FBI-Proxy on {}:{} with domain filter: {:?}",
|
|
746
770
|
host, port, domain_filter
|
|
747
771
|
);
|
|
748
|
-
if let Err(e) = start_proxy_server(Some(host), port, domain_filter).await {
|
|
772
|
+
if let Err(e) = start_proxy_server(Some(host), port, domain_filter, compiled_routes).await {
|
|
749
773
|
error!("Failed to start proxy server: {}", e);
|
|
750
774
|
}
|
|
751
775
|
});
|
package/rs/lib.rs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//! Library entry point for `fbi-proxy`.
|
|
2
|
+
//!
|
|
3
|
+
//! This file exists primarily so that internal modules (like `routes`)
|
|
4
|
+
//! can be unit-tested via `cargo test --lib` without coupling them to
|
|
5
|
+
//! the binary's runtime concerns.
|
|
6
|
+
//!
|
|
7
|
+
//! The binary in `rs/fbi-proxy.rs` does not currently depend on this
|
|
8
|
+
//! library — the routing engine is intentionally not wired into the
|
|
9
|
+
//! live request path yet (see `docs/routing.md` for the migration
|
|
10
|
+
//! plan).
|
|
11
|
+
|
|
12
|
+
pub mod routes;
|