1use std::net::IpAddr;
2use std::net::SocketAddr;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use axum::Json;
7use axum::extract::Path;
8use axum::extract::State;
9use axum::http::StatusCode;
10use color_eyre::Result;
11use lab_ops_lab_lib::port::PortAllocator;
12
13use crate::daemon::AppState;
14use crate::daemon::ErrorResponse;
15use crate::models::DnatConfig;
16use crate::models::DnatRequest;
17use crate::models::DockerAddMapRequest;
18use crate::models::DockerPortMap;
19use crate::models::DockerPortMapRequest;
20use crate::models::DockerRemapRequest;
21use crate::models::HairpinConfig;
22use crate::models::HairpinRequest;
23use crate::models::ListResponse;
24use crate::models::PolicyRouteConfig;
25use crate::models::PolicyRouteRequest;
26use crate::models::SnatConfig;
27use crate::models::SnatRequest;
28use crate::models::TransportProtocol;
29
30pub async fn list_mappings(State(state): State<AppState>) -> Json<ListResponse> {
32 let state = state.daemon_state.read().await;
33 Json(ListResponse {
34 docker: state.mapping.values().flatten().cloned().collect(),
35 dnats: state.dnats.clone(),
36 snats: state.snats.clone(),
37 hairpins: state.hairpins.clone(),
38 policy_routes: state.policy_routes.clone(),
39 })
40}
41
42#[tracing::instrument(skip_all, fields(
51 ext.ip = %req.ext_ip,
52 int.ip = %req.int_ip,
53 ports = %req.ports,
54 proto = %req.proto
55))]
56pub async fn add_dnat(
57 State(state): State<AppState>,
58 Json(req): Json<DnatRequest>,
59) -> Result<Json<DnatConfig>, (StatusCode, Json<ErrorResponse>)> {
60 let config = DnatConfig {
61 ext_ip: req.ext_ip.clone(),
62 int_ip: req.int_ip.clone(),
63 ports: req.ports.clone(),
64 proto: req.proto,
65 ext_if: req.ext_if.clone(),
66 no_masquerade: req.no_masquerade,
67 };
68
69 {
71 let lock = state.daemon_state.read().await;
72 if lock.dnats.iter().any(|d| {
73 d.ext_ip == config.ext_ip
74 && d.int_ip == config.int_ip
75 && d.ports == config.ports
76 && d.proto == config.proto
77 }) {
78 return Ok(Json(config));
79 }
80 }
81
82 bind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await?;
83 if let Err(e) = state.iptables.install_dnat(&config) {
84 unbind_ports(state.ports, &config.ext_ip, &config.ports).await;
85 return Err((
86 StatusCode::INTERNAL_SERVER_ERROR,
87 Json(ErrorResponse {
88 error: e.to_string(),
89 }),
90 ));
91 }
92 state.daemon_state.write().await.dnats.push(config.clone());
93 state.persist().await;
94 Ok(Json(config))
95}
96
97#[tracing::instrument(skip_all, fields(
101 ext.ip = %req.ext_ip,
102 int.ip = %req.int_ip,
103 ports = %req.ports,
104 proto = %req.proto
105))]
106pub async fn remove_dnat(
107 State(state): State<AppState>,
108 Json(req): Json<DnatRequest>,
109) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
110 let mut lock = state.daemon_state.write().await;
111 let idx = lock
112 .dnats
113 .iter()
114 .position(|d| d.ext_ip == req.ext_ip && d.int_ip == req.int_ip && d.ports == req.ports);
115 if let Some(i) = idx {
116 let config = lock.dnats.remove(i);
117 let _ = state.iptables.remove_dnat(&config);
118 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
119 drop(lock);
120 state.persist().await;
121 Ok(StatusCode::OK)
122 } else {
123 let config = DnatConfig {
127 ext_ip: req.ext_ip,
128 int_ip: req.int_ip,
129 ports: req.ports,
130 proto: req.proto,
131 ext_if: req.ext_if,
132 no_masquerade: req.no_masquerade,
133 };
134 let _ = state.iptables.remove_dnat(&config);
135 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
136 Ok(StatusCode::OK)
137 }
138}
139
140pub async fn add_snat(
142 State(state): State<AppState>,
143 Json(req): Json<SnatRequest>,
144) -> Result<Json<SnatConfig>, (StatusCode, Json<ErrorResponse>)> {
145 let config = SnatConfig {
146 int_ip: req.int_ip.clone(),
147 ext_ip: req.ext_ip.clone(),
148 ext_if: req.ext_if.clone(),
149 };
150 state.iptables.install_snat(&config).map_err(|e| {
151 (
152 StatusCode::INTERNAL_SERVER_ERROR,
153 Json(ErrorResponse {
154 error: e.to_string(),
155 }),
156 )
157 })?;
158 state.daemon_state.write().await.snats.push(config.clone());
159 state.persist().await;
160 Ok(Json(config))
161}
162
163pub async fn remove_snat(
165 State(state): State<AppState>,
166 Json(req): Json<SnatRequest>,
167) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
168 let mut lock = state.daemon_state.write().await;
169 let idx = lock
170 .snats
171 .iter()
172 .position(|s| s.int_ip == req.int_ip && s.ext_ip == req.ext_ip && s.ext_if == req.ext_if);
173 if let Some(i) = idx {
174 let config = lock.snats.remove(i);
175 let _ = state.iptables.remove_snat(&config);
176 drop(lock);
177 state.persist().await;
178 Ok(StatusCode::OK)
179 } else {
180 Err((
181 StatusCode::NOT_FOUND,
182 Json(ErrorResponse {
183 error: "SNAT rule not found".into(),
184 }),
185 ))
186 }
187}
188
189pub async fn add_hairpin(
191 State(state): State<AppState>,
192 Json(req): Json<HairpinRequest>,
193) -> Result<Json<HairpinConfig>, (StatusCode, Json<ErrorResponse>)> {
194 let config = HairpinConfig {
195 ext_ip: req.ext_ip.clone(),
196 int_ip: req.int_ip.clone(),
197 ports: req.ports.clone(),
198 proto: req.proto,
199 lan_cidr: req.lan_cidr.clone(),
200 };
201 bind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await?;
202 if let Err(e) = state.iptables.install_hairpin(&config) {
203 unbind_ports(state.ports, &config.ext_ip, &config.ports).await;
204 return Err((
205 StatusCode::INTERNAL_SERVER_ERROR,
206 Json(ErrorResponse {
207 error: e.to_string(),
208 }),
209 ));
210 }
211 state
212 .daemon_state
213 .write()
214 .await
215 .hairpins
216 .push(config.clone());
217 state.persist().await;
218 Ok(Json(config))
219}
220
221pub async fn remove_hairpin(
223 State(state): State<AppState>,
224 Json(req): Json<HairpinRequest>,
225) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
226 let mut lock = state.daemon_state.write().await;
227 let idx = lock
228 .hairpins
229 .iter()
230 .position(|h| h.ext_ip == req.ext_ip && h.int_ip == req.int_ip && h.ports == req.ports);
231 if let Some(i) = idx {
232 let config = lock.hairpins.remove(i);
233 let _ = state.iptables.remove_hairpin(&config);
234 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
235 drop(lock);
236 state.persist().await;
237 Ok(StatusCode::OK)
238 } else {
239 let config = HairpinConfig {
242 ext_ip: req.ext_ip,
243 int_ip: req.int_ip,
244 ports: req.ports,
245 proto: req.proto,
246 lan_cidr: None,
247 };
248 let _ = state.iptables.remove_hairpin(&config);
249 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
250 Ok(StatusCode::OK)
251 }
252}
253
254pub async fn add_policy_route(
257 State(state): State<AppState>,
258 Json(req): Json<PolicyRouteRequest>,
259) -> Result<Json<PolicyRouteConfig>, (StatusCode, Json<ErrorResponse>)> {
260 let config = PolicyRouteConfig {
261 src_ip: req.src_ip.clone(),
262 via: req.via.clone(),
263 table: req.table,
264 };
265
266 if let Err(e) = state.policy_route.install(&config) {
267 return Err((
268 StatusCode::INTERNAL_SERVER_ERROR,
269 Json(ErrorResponse {
270 error: e.to_string(),
271 }),
272 ));
273 }
274
275 state
276 .daemon_state
277 .write()
278 .await
279 .policy_routes
280 .push(config.clone());
281 state.persist().await;
282 Ok(Json(config))
283}
284
285pub async fn remove_policy_route(
286 State(state): State<AppState>,
287 Json(req): Json<PolicyRouteRequest>,
288) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
289 let mut lock = state.daemon_state.write().await;
290 let idx = lock
291 .policy_routes
292 .iter()
293 .position(|r| r.src_ip == req.src_ip && r.via == req.via && r.table == req.table);
294 if let Some(i) = idx {
295 let config = lock.policy_routes.remove(i);
296 let _ = state.policy_route.remove(&config);
297 drop(lock);
298 state.persist().await;
299 Ok(StatusCode::OK)
300 } else {
301 let config = PolicyRouteConfig {
303 src_ip: req.src_ip,
304 via: req.via,
305 table: req.table,
306 };
307 let _ = state.policy_route.remove(&config);
308 Ok(StatusCode::OK)
309 }
310}
311
312pub async fn remap_port(
316 State(state): State<AppState>,
317 Path(container_id): Path<String>,
318 Json(req): Json<DockerRemapRequest>,
319) -> Result<Json<Vec<DockerPortMap>>, (StatusCode, Json<ErrorResponse>)> {
320 let mut lock = state.daemon_state.write().await;
321 let container_mappings = lock.mapping.get_mut(&container_id).ok_or_else(|| {
322 (
323 StatusCode::NOT_FOUND,
324 Json(ErrorResponse {
325 error: "Container not found".into(),
326 }),
327 )
328 })?;
329
330 let mut to_replace = Vec::new();
331 for (i, m) in container_mappings.iter().enumerate() {
332 if m.request.host_addr.port() == req.host_port {
333 to_replace.push(i);
334 }
335 }
336 if to_replace.is_empty() {
337 return Err((
338 StatusCode::NOT_FOUND,
339 Json(ErrorResponse {
340 error: "Port mapping not found".into(),
341 }),
342 ));
343 }
344
345 let mut new_mappings = Vec::new();
346 for i in to_replace {
347 let old = &container_mappings[i];
348 let mut new_req = old.request.clone();
349 new_req.host_addr.set_port(req.new_host_port);
350 let id = state.allocate_id();
351 let new_mapping = DockerPortMap::new(
352 id,
353 new_req,
354 container_id.clone(),
355 old.container_name.clone(),
356 );
357 if let Err(e) = state.ports.allocate(new_mapping.request.host_addr).await {
358 return Err((
359 StatusCode::CONFLICT,
360 Json(ErrorResponse {
361 error: e.to_string(),
362 }),
363 ));
364 }
365 let _ = state.iptables.remove_mapping(old);
366 if let Err(e) = state.iptables.install_dockermap(&new_mapping) {
367 let _ = state.iptables.install_dockermap(old);
368 state.ports.deallocate(new_mapping.request.host_addr).await;
369 return Err((
370 StatusCode::INTERNAL_SERVER_ERROR,
371 Json(ErrorResponse {
372 error: e.to_string(),
373 }),
374 ));
375 }
376 state.ports.deallocate(old.request.host_addr).await;
377 container_mappings[i] = new_mapping.clone();
378 new_mappings.push(new_mapping);
379 }
380
381 drop(lock);
382 state.persist().await;
383 Ok(Json(new_mappings))
384}
385
386#[tracing::instrument(skip_all, fields(
390 host.addr = tracing::field::Empty,
391 container.addr = tracing::field::Empty,
392 proto = %req.proto,
393 container.id = %container_id
394))]
395pub async fn add_mapping(
396 State(state): State<AppState>,
397 Path(container_id): Path<String>,
398 Json(req): Json<DockerAddMapRequest>,
399) -> Result<Json<DockerPortMap>, (StatusCode, Json<ErrorResponse>)> {
400 let (container_ip, container_name) = if let Some(target_ip_str) = &req.target_ip {
401 let ip = IpAddr::from_str(target_ip_str).map_err(|e| {
402 (
403 StatusCode::BAD_REQUEST,
404 Json(ErrorResponse {
405 error: format!("Invalid target IP: {e}"),
406 }),
407 )
408 })?;
409 (ip, container_id.clone())
410 } else {
411 let docker = state.docker.as_ref().ok_or_else(|| {
412 (
413 StatusCode::SERVICE_UNAVAILABLE,
414 Json(ErrorResponse {
415 error: "Docker not available".into(),
416 }),
417 )
418 })?;
419 let inspect = docker
420 .inspect_container(&container_id, None)
421 .await
422 .map_err(|e| {
423 (
424 StatusCode::NOT_FOUND,
425 Json(ErrorResponse {
426 error: format!("Container not found: {e}"),
427 }),
428 )
429 })?;
430 let container_name = inspect
431 .name
432 .as_deref()
433 .map(lab_ops_lab_lib::docker::trim_container_name)
434 .unwrap_or("unknown")
435 .to_string();
436 let network_settings = inspect.network_settings.ok_or_else(|| {
437 (
438 StatusCode::BAD_REQUEST,
439 Json(ErrorResponse {
440 error: "Container has no network settings".into(),
441 }),
442 )
443 })?;
444 let container_ip = network_settings
445 .networks
446 .as_ref()
447 .and_then(|nets| {
448 nets.values().find_map(|net| {
449 net.ip_address
450 .as_deref()
451 .and_then(|ip| IpAddr::from_str(ip).ok())
452 })
453 })
454 .ok_or_else(|| {
455 (
456 StatusCode::BAD_REQUEST,
457 Json(ErrorResponse {
458 error: "Container has no IP address".into(),
459 }),
460 )
461 })?;
462 (container_ip, container_name)
463 };
464
465 let proto = match req.proto.to_lowercase() {
466 "tcp" => TransportProtocol::Tcp,
467 "udp" => TransportProtocol::Udp,
468 other => {
469 return Err((
470 StatusCode::BAD_REQUEST,
471 Json(ErrorResponse {
472 error: format!("Unsupported protocol: {other}"),
473 }),
474 ));
475 }
476 };
477 let host_ip = IpAddr::from_str(&req.host_ip).map_err(|e| {
478 (
479 StatusCode::BAD_REQUEST,
480 Json(ErrorResponse {
481 error: format!("Invalid host IP: {e}"),
482 }),
483 )
484 })?;
485 let host_addr = SocketAddr::new(host_ip, req.host_port);
486 let container_addr = SocketAddr::new(container_ip, req.container_port);
487
488 let span = tracing::Span::current();
489 span.record("host.addr", tracing::field::display(host_addr));
490 span.record("container.addr", tracing::field::display(container_addr));
491
492 let request = DockerPortMapRequest {
493 host_addr,
494 container_addr,
495 proto,
496 };
497 let id = state.allocate_id();
498 let mapping = DockerPortMap::new(id, request, container_id.clone(), container_name);
499
500 state.ports.allocate(host_addr).await.map_err(|e| {
501 (
502 StatusCode::CONFLICT,
503 Json(ErrorResponse {
504 error: e.to_string(),
505 }),
506 )
507 })?;
508 if let Err(e) = state.iptables.install_dockermap(&mapping) {
509 state.ports.deallocate(host_addr).await;
510 return Err((
511 StatusCode::INTERNAL_SERVER_ERROR,
512 Json(ErrorResponse {
513 error: format!("iptables error: {e}"),
514 }),
515 ));
516 }
517 state
518 .daemon_state
519 .write()
520 .await
521 .mapping
522 .entry(container_id)
523 .or_default()
524 .push(mapping.clone());
525 state.persist().await;
526 Ok(Json(mapping))
527}
528
529pub async fn remove_mapping(
531 State(state): State<AppState>,
532 Path((container_id, port_str)): Path<(String, String)>,
533) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
534 let port = port_str.parse::<u16>().unwrap_or(0);
535 let mut lock = state.daemon_state.write().await;
536 let container_mappings = lock.mapping.get_mut(&container_id).ok_or_else(|| {
537 (
538 StatusCode::NOT_FOUND,
539 Json(ErrorResponse {
540 error: "Container not found".into(),
541 }),
542 )
543 })?;
544 let pos = container_mappings
545 .iter()
546 .position(|m| m.request.host_addr.port() == port);
547 if let Some(i) = pos {
548 let m = container_mappings.remove(i);
549 let _ = state.iptables.remove_mapping(&m);
550 state.ports.deallocate(m.request.host_addr).await;
551 drop(lock);
552 state.persist().await;
553 Ok(StatusCode::OK)
554 } else {
555 Err((
556 StatusCode::NOT_FOUND,
557 Json(ErrorResponse {
558 error: "Port mapping not found".into(),
559 }),
560 ))
561 }
562}
563
564pub async fn remove_mapping_by_id(
566 State(state): State<AppState>,
567 Path(id): Path<u64>,
568) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
569 let mut lock = state.daemon_state.write().await;
570 for (_, mappings) in lock.mapping.iter_mut() {
571 if let Some(pos) = mappings.iter().position(|m| m.id == id) {
572 let m = mappings.remove(pos);
573 let _ = state.iptables.remove_mapping(&m);
574 state.ports.deallocate(m.request.host_addr).await;
575 drop(lock);
576 state.persist().await;
577 return Ok(StatusCode::OK);
578 }
579 }
580 Err((
581 StatusCode::NOT_FOUND,
582 Json(ErrorResponse {
583 error: format!("No mapping found with id {id}"),
584 }),
585 ))
586}
587
588pub async fn clear_all(
590 State(state): State<AppState>,
591) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
592 let mut lock = state.daemon_state.write().await;
593
594 for mappings in lock.mapping.values() {
595 for m in mappings {
596 let _ = state.iptables.remove_mapping(m);
597 state.ports.deallocate(m.request.host_addr).await;
598 }
599 }
600 lock.mapping.clear();
601
602 for config in &lock.dnats {
603 let _ = state.iptables.remove_dnat(config);
604 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
605 }
606 lock.dnats.clear();
607
608 for config in &lock.snats {
609 let _ = state.iptables.remove_snat(config);
610 }
611 lock.snats.clear();
612
613 for config in &lock.hairpins {
614 let _ = state.iptables.remove_hairpin(config);
615 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
616 }
617 lock.hairpins.clear();
618
619 for config in &lock.policy_routes {
620 let _ = state.policy_route.remove(config);
621 }
622 lock.policy_routes.clear();
623
624 drop(lock);
625 state.persist().await;
626 Ok(StatusCode::OK)
627}
628
629pub async fn bind_ports(
630 ports: Arc<PortAllocator>,
631 ip: &str,
632 ports_csv: &str,
633) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
634 for addr in parse_socket_addrs(ip, ports_csv)? {
635 ports.allocate(addr).await.map_err(|e| {
636 (
637 StatusCode::CONFLICT,
638 Json(ErrorResponse {
639 error: e.to_string(),
640 }),
641 )
642 })?;
643 }
644 Ok(())
645}
646
647pub async fn unbind_ports(ports: Arc<PortAllocator>, ip: &str, ports_csv: &str) {
648 if let Ok(addrs) = parse_socket_addrs(ip, ports_csv) {
649 for addr in addrs {
650 ports.deallocate(addr).await;
651 }
652 }
653}
654
655fn parse_socket_addrs(
656 ip: &str,
657 ports_csv: &str,
658) -> Result<Vec<SocketAddr>, (StatusCode, Json<ErrorResponse>)> {
659 let ip: IpAddr = ip.parse().map_err(|_| {
660 (
661 StatusCode::BAD_REQUEST,
662 Json(ErrorResponse {
663 error: format!("Invalid IP: {ip}"),
664 }),
665 )
666 })?;
667
668 ports_csv
669 .split(',')
670 .map(|p| {
671 let port = p.trim().parse::<u16>().map_err(|_| {
672 (
673 StatusCode::BAD_REQUEST,
674 Json(ErrorResponse {
675 error: format!("Invalid port: {p}"),
676 }),
677 )
678 })?;
679 Ok(SocketAddr::new(ip, port))
680 })
681 .collect()
682}