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        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
221/// `DELETE /hairpin` — Removes a static hairpin NAT rule.
222pub 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        // Not in daemon state but may still have stale iptables rules and port
240        // reservations from a previous daemon instance. Clean them up.
241        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
254// --- Policy Route handlers ---
255
256pub 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        // Not in daemon state, but still try to remove to be safe
302        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
312// --- Docker handlers ---
313
314/// `PUT /remap/:container_id` — Remaps a host port for a running container.
315pub 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/// `POST /mapping/:container_id` — Adds a new port mapping.
387///
388/// Span fields: `host.addr`, `container.addr`, `proto`, `container.id`.
389#[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
529/// `DELETE /mapping/{container_id}/{port}` — Removes a specific port mapping by container and port.
530pub 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
564/// `DELETE /mapping/by-id/:id` — Removes a port mapping by its numeric ID.
565pub 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
588/// `DELETE /clear` — Removes all managed NAT rules and resets daemon state.
589pub 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}