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::PolicyRouteConfig;
25use crate::models::PolicyRouteRequest;
26use crate::models::SnatConfig;
27use crate::models::SnatRequest;
28use crate::models::TransportProtocol;
29
30/// `GET /mappings` — Returns all managed DNAT, SNAT, hairpin, and Docker mappings.
31pub 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// --- Static NAT handlers ---
43
44/// `POST /dnat` — Adds a static DNAT rule.
45///
46/// Idempotent: if the exact same DNAT config already exists in the daemon
47/// state (e.g. after restart reconciliation), returns OK without error.
48///
49/// Span fields: `ext.ip`, `int.ip`, `ports`, `proto`.
50#[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    // Check if this DNAT already exists (idempotent add).
70    {
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/// `DELETE /dnat` — Removes a static DNAT rule.
98///
99/// Span fields: `ext.ip`, `int.ip`, `ports`, `proto`.
100#[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        // Not in daemon state but may still have stale iptables rules and port
124        // reservations from a previous daemon instance (e.g. after restart with
125        // reconciled DNATs). Clean them up so the caller can re-add cleanly.
126        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
140/// `POST /snat` — Adds a static SNAT rule.
141pub 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
163/// `DELETE /snat` — Removes a static SNAT rule.
164pub 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
189/// `POST /hairpin` — Adds a static hairpin NAT rule.
190pub 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
220/// `DELETE /hairpin` — Removes a static hairpin NAT rule.
221pub 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        // Not in daemon state but may still have stale iptables rules and port
239        // reservations from a previous daemon instance. Clean them up.
240        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
252// --- Policy Route handlers ---
253
254pub 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        // Not in daemon state, but still try to remove to be safe
300        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
310// --- Docker handlers ---
311
312/// `PUT /remap/:container_id` — Remaps a host port for a running container.
313pub 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/// `POST /mapping/:container_id` — Adds a new port mapping.
385///
386/// Span fields: `host.addr`, `container.addr`, `proto`, `container.id`.
387#[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
527/// `DELETE /mapping/{container_id}/{port}` — Removes a specific port mapping by container and port.
528pub 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
562/// `DELETE /mapping/by-id/:id` — Removes a port mapping by its numeric ID.
563pub 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
586/// `DELETE /clear` — Removes all managed NAT rules and resets daemon state.
587pub 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}