Skip to main content

lab_ops_natmap/
api.rs

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
28/// `GET /mappings` — Returns all managed DNAT, SNAT, hairpin, and Docker mappings.
29pub 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// --- Static NAT handlers ---
40
41/// `POST /dnat` — Adds a static DNAT rule.
42///
43/// Idempotent: if the exact same DNAT config already exists in the daemon
44/// state (e.g. after restart reconciliation), returns OK without error.
45///
46/// Span fields: `ext.ip`, `int.ip`, `ports`, `proto`.
47#[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    // Check if this DNAT already exists (idempotent add).
66    {
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/// `DELETE /dnat` — Removes a static DNAT rule.
94///
95/// Span fields: `ext.ip`, `int.ip`, `ports`, `proto`.
96#[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        // Not in daemon state but may still have stale iptables rules and port
120        // reservations from a previous daemon instance (e.g. after restart with
121        // reconciled DNATs). Clean them up so the caller can re-add cleanly.
122        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
135/// `POST /snat` — Adds a static SNAT rule.
136pub 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
158/// `DELETE /snat` — Removes a static SNAT rule.
159pub 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
184/// `POST /hairpin` — Adds a static hairpin NAT rule.
185pub 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
215/// `DELETE /hairpin` — Removes a static hairpin NAT rule.
216pub 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        // Not in daemon state but may still have stale iptables rules and port
234        // reservations from a previous daemon instance. Clean them up.
235        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
247// --- Docker handlers ---
248
249/// `PUT /remap/:container_id` — Remaps a host port for a running container.
250pub 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/// `POST /mapping/:container_id` — Adds a new port mapping.
322///
323/// Span fields: `host.addr`, `container.addr`, `proto`, `container.id`.
324#[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
464/// `DELETE /mapping/{container_id}/{port}` — Removes a specific port mapping by container and port.
465pub 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
499/// `DELETE /mapping/by-id/:id` — Removes a port mapping by its numeric ID.
500pub 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
523/// `DELETE /clear` — Removes all managed NAT rules and resets daemon state.
524pub 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}