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