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 };
200 bind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await?;
201 if let Err(e) = state.iptables.install_hairpin(&config) {
202 unbind_ports(state.ports, &config.ext_ip, &config.ports).await;
203 return Err((
204 StatusCode::INTERNAL_SERVER_ERROR,
205 Json(ErrorResponse {
206 error: e.to_string(),
207 }),
208 ));
209 }
210 state
211 .daemon_state
212 .write()
213 .await
214 .hairpins
215 .push(config.clone());
216 state.persist().await;
217 Ok(Json(config))
218}
219
220pub async fn remove_hairpin(
222 State(state): State<AppState>,
223 Json(req): Json<HairpinRequest>,
224) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
225 let mut lock = state.daemon_state.write().await;
226 let idx = lock
227 .hairpins
228 .iter()
229 .position(|h| h.ext_ip == req.ext_ip && h.int_ip == req.int_ip && h.ports == req.ports);
230 if let Some(i) = idx {
231 let config = lock.hairpins.remove(i);
232 let _ = state.iptables.remove_hairpin(&config);
233 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
234 drop(lock);
235 state.persist().await;
236 Ok(StatusCode::OK)
237 } else {
238 let config = HairpinConfig {
241 ext_ip: req.ext_ip,
242 int_ip: req.int_ip,
243 ports: req.ports,
244 proto: req.proto,
245 };
246 let _ = state.iptables.remove_hairpin(&config);
247 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
248 Ok(StatusCode::OK)
249 }
250}
251
252pub async fn add_policy_route(
255 State(state): State<AppState>,
256 Json(req): Json<PolicyRouteRequest>,
257) -> Result<Json<PolicyRouteConfig>, (StatusCode, Json<ErrorResponse>)> {
258 let config = PolicyRouteConfig {
259 src_ip: req.src_ip.clone(),
260 via: req.via.clone(),
261 table: req.table,
262 };
263
264 if let Err(e) = state.policy_route.install(&config) {
265 return Err((
266 StatusCode::INTERNAL_SERVER_ERROR,
267 Json(ErrorResponse {
268 error: e.to_string(),
269 }),
270 ));
271 }
272
273 state
274 .daemon_state
275 .write()
276 .await
277 .policy_routes
278 .push(config.clone());
279 state.persist().await;
280 Ok(Json(config))
281}
282
283pub async fn remove_policy_route(
284 State(state): State<AppState>,
285 Json(req): Json<PolicyRouteRequest>,
286) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
287 let mut lock = state.daemon_state.write().await;
288 let idx = lock
289 .policy_routes
290 .iter()
291 .position(|r| r.src_ip == req.src_ip && r.via == req.via && r.table == req.table);
292 if let Some(i) = idx {
293 let config = lock.policy_routes.remove(i);
294 let _ = state.policy_route.remove(&config);
295 drop(lock);
296 state.persist().await;
297 Ok(StatusCode::OK)
298 } else {
299 let config = PolicyRouteConfig {
301 src_ip: req.src_ip,
302 via: req.via,
303 table: req.table,
304 };
305 let _ = state.policy_route.remove(&config);
306 Ok(StatusCode::OK)
307 }
308}
309
310pub async fn remap_port(
314 State(state): State<AppState>,
315 Path(container_id): Path<String>,
316 Json(req): Json<DockerRemapRequest>,
317) -> Result<Json<Vec<DockerPortMap>>, (StatusCode, Json<ErrorResponse>)> {
318 let mut lock = state.daemon_state.write().await;
319 let container_mappings = lock.mapping.get_mut(&container_id).ok_or_else(|| {
320 (
321 StatusCode::NOT_FOUND,
322 Json(ErrorResponse {
323 error: "Container not found".into(),
324 }),
325 )
326 })?;
327
328 let mut to_replace = Vec::new();
329 for (i, m) in container_mappings.iter().enumerate() {
330 if m.request.host_addr.port() == req.host_port {
331 to_replace.push(i);
332 }
333 }
334 if to_replace.is_empty() {
335 return Err((
336 StatusCode::NOT_FOUND,
337 Json(ErrorResponse {
338 error: "Port mapping not found".into(),
339 }),
340 ));
341 }
342
343 let mut new_mappings = Vec::new();
344 for i in to_replace {
345 let old = &container_mappings[i];
346 let mut new_req = old.request.clone();
347 new_req.host_addr.set_port(req.new_host_port);
348 let id = state.allocate_id();
349 let new_mapping = DockerPortMap::new(
350 id,
351 new_req,
352 container_id.clone(),
353 old.container_name.clone(),
354 );
355 if let Err(e) = state.ports.allocate(new_mapping.request.host_addr).await {
356 return Err((
357 StatusCode::CONFLICT,
358 Json(ErrorResponse {
359 error: e.to_string(),
360 }),
361 ));
362 }
363 let _ = state.iptables.remove_mapping(old);
364 if let Err(e) = state.iptables.install_dockermap(&new_mapping) {
365 let _ = state.iptables.install_dockermap(old);
366 state.ports.deallocate(new_mapping.request.host_addr).await;
367 return Err((
368 StatusCode::INTERNAL_SERVER_ERROR,
369 Json(ErrorResponse {
370 error: e.to_string(),
371 }),
372 ));
373 }
374 state.ports.deallocate(old.request.host_addr).await;
375 container_mappings[i] = new_mapping.clone();
376 new_mappings.push(new_mapping);
377 }
378
379 drop(lock);
380 state.persist().await;
381 Ok(Json(new_mappings))
382}
383
384#[tracing::instrument(skip_all, fields(
388 host.addr = tracing::field::Empty,
389 container.addr = tracing::field::Empty,
390 proto = %req.proto,
391 container.id = %container_id
392))]
393pub async fn add_mapping(
394 State(state): State<AppState>,
395 Path(container_id): Path<String>,
396 Json(req): Json<DockerAddMapRequest>,
397) -> Result<Json<DockerPortMap>, (StatusCode, Json<ErrorResponse>)> {
398 let (container_ip, container_name) = if let Some(target_ip_str) = &req.target_ip {
399 let ip = IpAddr::from_str(target_ip_str).map_err(|e| {
400 (
401 StatusCode::BAD_REQUEST,
402 Json(ErrorResponse {
403 error: format!("Invalid target IP: {e}"),
404 }),
405 )
406 })?;
407 (ip, container_id.clone())
408 } else {
409 let docker = state.docker.as_ref().ok_or_else(|| {
410 (
411 StatusCode::SERVICE_UNAVAILABLE,
412 Json(ErrorResponse {
413 error: "Docker not available".into(),
414 }),
415 )
416 })?;
417 let inspect = docker
418 .inspect_container(&container_id, None)
419 .await
420 .map_err(|e| {
421 (
422 StatusCode::NOT_FOUND,
423 Json(ErrorResponse {
424 error: format!("Container not found: {e}"),
425 }),
426 )
427 })?;
428 let container_name = inspect
429 .name
430 .as_deref()
431 .map(lab_ops_lab_lib::docker::trim_container_name)
432 .unwrap_or("unknown")
433 .to_string();
434 let network_settings = inspect.network_settings.ok_or_else(|| {
435 (
436 StatusCode::BAD_REQUEST,
437 Json(ErrorResponse {
438 error: "Container has no network settings".into(),
439 }),
440 )
441 })?;
442 let container_ip = network_settings
443 .networks
444 .as_ref()
445 .and_then(|nets| {
446 nets.values().find_map(|net| {
447 net.ip_address
448 .as_deref()
449 .and_then(|ip| IpAddr::from_str(ip).ok())
450 })
451 })
452 .ok_or_else(|| {
453 (
454 StatusCode::BAD_REQUEST,
455 Json(ErrorResponse {
456 error: "Container has no IP address".into(),
457 }),
458 )
459 })?;
460 (container_ip, container_name)
461 };
462
463 let proto = match req.proto.to_lowercase() {
464 "tcp" => TransportProtocol::Tcp,
465 "udp" => TransportProtocol::Udp,
466 other => {
467 return Err((
468 StatusCode::BAD_REQUEST,
469 Json(ErrorResponse {
470 error: format!("Unsupported protocol: {other}"),
471 }),
472 ));
473 }
474 };
475 let host_ip = IpAddr::from_str(&req.host_ip).map_err(|e| {
476 (
477 StatusCode::BAD_REQUEST,
478 Json(ErrorResponse {
479 error: format!("Invalid host IP: {e}"),
480 }),
481 )
482 })?;
483 let host_addr = SocketAddr::new(host_ip, req.host_port);
484 let container_addr = SocketAddr::new(container_ip, req.container_port);
485
486 let span = tracing::Span::current();
487 span.record("host.addr", tracing::field::display(host_addr));
488 span.record("container.addr", tracing::field::display(container_addr));
489
490 let request = DockerPortMapRequest {
491 host_addr,
492 container_addr,
493 proto,
494 };
495 let id = state.allocate_id();
496 let mapping = DockerPortMap::new(id, request, container_id.clone(), container_name);
497
498 state.ports.allocate(host_addr).await.map_err(|e| {
499 (
500 StatusCode::CONFLICT,
501 Json(ErrorResponse {
502 error: e.to_string(),
503 }),
504 )
505 })?;
506 if let Err(e) = state.iptables.install_dockermap(&mapping) {
507 state.ports.deallocate(host_addr).await;
508 return Err((
509 StatusCode::INTERNAL_SERVER_ERROR,
510 Json(ErrorResponse {
511 error: format!("iptables error: {e}"),
512 }),
513 ));
514 }
515 state
516 .daemon_state
517 .write()
518 .await
519 .mapping
520 .entry(container_id)
521 .or_default()
522 .push(mapping.clone());
523 state.persist().await;
524 Ok(Json(mapping))
525}
526
527pub async fn remove_mapping(
529 State(state): State<AppState>,
530 Path((container_id, port_str)): Path<(String, String)>,
531) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
532 let port = port_str.parse::<u16>().unwrap_or(0);
533 let mut lock = state.daemon_state.write().await;
534 let container_mappings = lock.mapping.get_mut(&container_id).ok_or_else(|| {
535 (
536 StatusCode::NOT_FOUND,
537 Json(ErrorResponse {
538 error: "Container not found".into(),
539 }),
540 )
541 })?;
542 let pos = container_mappings
543 .iter()
544 .position(|m| m.request.host_addr.port() == port);
545 if let Some(i) = pos {
546 let m = container_mappings.remove(i);
547 let _ = state.iptables.remove_mapping(&m);
548 state.ports.deallocate(m.request.host_addr).await;
549 drop(lock);
550 state.persist().await;
551 Ok(StatusCode::OK)
552 } else {
553 Err((
554 StatusCode::NOT_FOUND,
555 Json(ErrorResponse {
556 error: "Port mapping not found".into(),
557 }),
558 ))
559 }
560}
561
562pub async fn remove_mapping_by_id(
564 State(state): State<AppState>,
565 Path(id): Path<u64>,
566) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
567 let mut lock = state.daemon_state.write().await;
568 for (_, mappings) in lock.mapping.iter_mut() {
569 if let Some(pos) = mappings.iter().position(|m| m.id == id) {
570 let m = mappings.remove(pos);
571 let _ = state.iptables.remove_mapping(&m);
572 state.ports.deallocate(m.request.host_addr).await;
573 drop(lock);
574 state.persist().await;
575 return Ok(StatusCode::OK);
576 }
577 }
578 Err((
579 StatusCode::NOT_FOUND,
580 Json(ErrorResponse {
581 error: format!("No mapping found with id {id}"),
582 }),
583 ))
584}
585
586pub async fn clear_all(
588 State(state): State<AppState>,
589) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
590 let mut lock = state.daemon_state.write().await;
591
592 for mappings in lock.mapping.values() {
593 for m in mappings {
594 let _ = state.iptables.remove_mapping(m);
595 state.ports.deallocate(m.request.host_addr).await;
596 }
597 }
598 lock.mapping.clear();
599
600 for config in &lock.dnats {
601 let _ = state.iptables.remove_dnat(config);
602 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
603 }
604 lock.dnats.clear();
605
606 for config in &lock.snats {
607 let _ = state.iptables.remove_snat(config);
608 }
609 lock.snats.clear();
610
611 for config in &lock.hairpins {
612 let _ = state.iptables.remove_hairpin(config);
613 unbind_ports(state.ports.clone(), &config.ext_ip, &config.ports).await;
614 }
615 lock.hairpins.clear();
616
617 for config in &lock.policy_routes {
618 let _ = state.policy_route.remove(config);
619 }
620 lock.policy_routes.clear();
621
622 drop(lock);
623 state.persist().await;
624 Ok(StatusCode::OK)
625}
626
627pub async fn bind_ports(
628 ports: Arc<PortAllocator>,
629 ip: &str,
630 ports_csv: &str,
631) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
632 for addr in parse_socket_addrs(ip, ports_csv)? {
633 ports.allocate(addr).await.map_err(|e| {
634 (
635 StatusCode::CONFLICT,
636 Json(ErrorResponse {
637 error: e.to_string(),
638 }),
639 )
640 })?;
641 }
642 Ok(())
643}
644
645pub async fn unbind_ports(ports: Arc<PortAllocator>, ip: &str, ports_csv: &str) {
646 if let Ok(addrs) = parse_socket_addrs(ip, ports_csv) {
647 for addr in addrs {
648 ports.deallocate(addr).await;
649 }
650 }
651}
652
653fn parse_socket_addrs(
654 ip: &str,
655 ports_csv: &str,
656) -> Result<Vec<SocketAddr>, (StatusCode, Json<ErrorResponse>)> {
657 let ip: IpAddr = ip.parse().map_err(|_| {
658 (
659 StatusCode::BAD_REQUEST,
660 Json(ErrorResponse {
661 error: format!("Invalid IP: {ip}"),
662 }),
663 )
664 })?;
665
666 ports_csv
667 .split(',')
668 .map(|p| {
669 let port = p.trim().parse::<u16>().map_err(|_| {
670 (
671 StatusCode::BAD_REQUEST,
672 Json(ErrorResponse {
673 error: format!("Invalid port: {p}"),
674 }),
675 )
676 })?;
677 Ok(SocketAddr::new(ip, port))
678 })
679 .collect()
680}