본문 바로가기

勉強/Network

Rust 기반 NAC 플랫폼 개발기: 세션 인증 + 캡티브 포털 리다이렉트 구현

집에서 Raspberry Pi로 운영 중인 NAC(Network Access Control) 플랫폼을 개발하면서 겪은 두 가지 핵심 문제와 해결 과정을 정리합니다.

  • 문제 1: policy-manager 서비스를 재시작해도 이전에 로그인한 단말이 그대로 허용 상태를 유지함
  • 문제 2: 캡티브 포털 로그인 성공 후, 원래 접속하려던 페이지 대신 /success 고정 페이지만 표시됨

아키텍처 개요

[단말] → ARP/DHCP 탐지 → [sensor]
                              ↓ NATS: nac.events.endpoint.detected
                        [policy-manager]
                              ↓ NATS: nac.commands.enforcement
                        [enforcement] → nftables + ARP 스푸핑
                        
[격리된 단말] HTTP 80 → nftables REDIRECT → [aaa:8080 캡티브 포털]
                                                     ↓ NATS: nac.events.auth.response
                                               [policy-manager] → allow
 

서비스는 모두 Rust로 작성, NATS를 통해 비동기 통신, PostgreSQL에 상태 저장.


문제 1: 서비스 재시작 후 재인증 미적용

원인 분석

기존 consumer.rs의 인증 상태 확인 로직:

// 기존 코드 — 문제 있음
let already_authenticated = row.status == "allowed" && row.username.is_some();
let status_changed = !already_authenticated && row.status != new_status;

이 코드는 DB에 username이 저장되어 있으면 "이미 인증됨"으로 간주했습니다. 서비스를 재시작해도 DB의 username 필드는 그대로 남아 있으니 ARP 이벤트가 들어올 때마다 "인증 유지"로 처리됐습니다.

해결책: 세션 기반 인증 확인

// 수정 코드 — sessions 테이블의 활성 세션 존재 여부로 판단
let already_authenticated = if row.status == "allowed" && row.username.is_some() {
    session_repo
        .find_active_by_endpoint(row.id)
        .await
        .ok()
        .flatten()
        .map(|s| s.state == "active")
        .unwrap_or(false)
} else {
    false
};
let status_changed = !already_authenticated && row.status != new_status;
 

세션 테이블(sessions)에 state = 'active'인 레코드가 없으면, username이 있어도 재인증을 요구하도록 변경했습니다.

문제 2: 재시작 시 정책 재평가가 효과 없음

단순히 세션을 삭제하고 NATS 이벤트로 정책 재평가를 트리거하는 것만으로는 부족했습니다. 이유:

  • quarantine-unknown 정책(priority 10): MAC OUI 미확인, hostname 없는 장치에만 매칭
  • 한 번 핑거프린팅된 장치(os_family, hostname 채워진 상태)는 allow-corp-devices나 default-allow(priority 9999)에 매칭

즉, 핑거프린팅이 완료된 장치는 정책 재평가를 해도 격리되지 않습니다.

해결책: 재시작 시 직접 격리

background.rs의 reconciliation 로직을 수정:

async fn reconcile(nats: &Client, pool: &PgPool) -> anyhow::Result<()> {
    // 0. 모든 열린 세션 종료
    let terminated: i64 = sqlx::query_scalar(
        "WITH t AS (
            UPDATE sessions SET state = 'terminated', ended_at = NOW(), updated_at = NOW()
            WHERE ended_at IS NULL
            RETURNING 1
        ) SELECT COUNT(*) FROM t",
    )
    .fetch_one(pool)
    .await
    .unwrap_or(0);

    // 캡티브 포털 인증 단말(username 있는 allowed)을 정책 우회하여 직접 격리
    // 이유: 핑거프린팅 완료 후엔 quarantine-unknown이 매칭 안 됨
    let reauth: Vec<(uuid::Uuid, String, Option<String>)> = sqlx::query_as(
        "UPDATE endpoints SET status = 'quarantined', username = NULL \
         WHERE status = 'allowed' AND username IS NOT NULL \
         RETURNING id, mac_address::TEXT, ip_address::TEXT",
    )
    .fetch_all(pool)
    .await?;

    for (id, mac, ip_opt) in &reauth {
        let ip = ip_opt.as_deref()
            .map(|s| s.split('/').next().unwrap_or(s))
            .unwrap_or("");
        
        // 새 세션 생성 (캡티브 포털로 안내)
        sqlx::query(
            "INSERT INTO sessions (endpoint_id, auth_method) VALUES ($1, 'captive_portal')",
        )
        .bind(id)
        .execute(pool)
        .await?;
        
        // enforcement에 격리 명령 발행
        publish_enforcement(nats, mac, ip, "quarantine").await;
    }
    
    // 1. 기존 denied/quarantined 단말도 재차단 (재시작 후 nftables set 초기화됨)
    // ...
}

 

핵심: 정책 엔진을 거치지 않고 DB를 직접 UPDATE + NATS enforcement 명령 발행.


문제 2: 로그인 성공 후 원래 페이지 미복원

원인 분석

captive_portal.rs의 기존 흐름:

  1. catch_all 핸들러: 격리된 단말의 모든 HTTP 요청 → state.portal_url(루트 /)로 리다이렉트. 원래 URL 버림.
  2. 로그인 폼: redirect_url 필드 없음
  3. handle_login 성공 시: Redirect::to("/success") 고정

해결책: URL 전달 체인 구현

흐름 수정:

단말 → http://naver.com 접속
  → nftables가 :8080으로 리다이렉트
  → catch_all: Host 헤더 + URI로 원래 URL 재구성
  → /?url=http%3A%2F%2Fnaver.com%2F 로 리다이렉트
  → landing_page: ?url= 파라미터 수신 → 로그인 폼의 hidden 필드에 삽입
  → 로그인 성공 → redirect_url로 리다이렉트 (http://naver.com/)
 

catch_all 수정:

async fn catch_all(
    State(state): State<SharedState>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    uri: Uri,
    headers: HeaderMap,
) -> Response {
    let ip = addr.ip().to_string();
    match endpoint_status(&state.pool, &ip).await.as_deref() {
        Some("quarantined") => {
            let redirect_to = headers
                .get("host")
                .and_then(|h| h.to_str().ok())
                .map(|host| {
                    let path = uri.path_and_query()
                        .map(|pq| pq.as_str())
                        .unwrap_or("/");
                    let original = format!("http://{}{}", host, path);
                    format!("{}/?url={}", state.portal_url, percent_encode_url(&original))
                })
                .unwrap_or_else(|| state.portal_url.clone());
            Redirect::to(&redirect_to).into_response()
        }
        // ...
    }
}
 

percent-encoding 헬퍼 (외부 의존성 없이):

fn percent_encode_url(input: &str) -> String {
    let mut s = String::with_capacity(input.len() * 3);
    for b in input.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                s.push(b as char)
            }
            b => s.push_str(&format!("%{b:02X}")),
        }
    }
    s
}
 

landing_page 수정 — ?url= 파라미터 수신:

#[derive(Deserialize)]
struct LandingQuery { url: Option<String> }

async fn landing_page(
    State(state): State<SharedState>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Query(query): Query<LandingQuery>,
) -> Response {
    let redirect_url = query.url.as_deref().unwrap_or("");
    // ...
    Html(login_html(&mac, redirect_url)).into_response()
}
 
 

로그인 폼 HTML에 hidden 필드 추가:

<form method="POST" action="/login">
  <input type="hidden" name="mac" value="{{MAC}}">
  <input type="hidden" name="redirect_url" value="{{REDIRECT_URL}}">
  <!-- ... -->
</form>
 

handle_login — 성공 시 원래 URL로 리다이렉트:

// LoginForm에 redirect_url 필드 추가
struct LoginForm {
    username: String,
    password: String,
    mac: Option<String>,
    session_id: Option<String>,
    redirect_url: Option<String>,  // 추가
}

// 성공 시
let dest = form
    .redirect_url
    .as_deref()
    .filter(|u| !u.is_empty() 
        && (u.starts_with("http://") || u.starts_with("https://")))
    .unwrap_or("/success");
Redirect::to(dest).into_response()
 

http:// 또는 https://로 시작하는 URL만 허용해 오픈 리다이렉트 취약점을 방지했습니다.


배포 환경

  • 빌드: Windows에서 cross 크로스 컴파일 (aarch64-unknown-linux-gnu)
  • 배포: SCP로 Raspberry Pi에 바이너리 전송 → docker restart
  • 배포 스크립트: .\scripts\deploy.ps1 aaa
cross build --release --target aarch64-unknown-linux-gnu -p aaa
scp target/aarch64-unknown-linux-gnu/release/aaa pi@raspberrypi:...
docker restart lentropy-aaa-1

주의사항

HTTPS 사이트는 nftables가 포트 80만 리다이렉트하므로, 브라우저가 직접 HTTPS 연결 시도 후 실패합니다. 캡티브 포털 테스트는 http://로 시작하는 URL로 해야 합니다. (iOS/Android의 연결 확인 프로브는 HTTP를 사용하므로 정상 작동)


마무리

핵심 교훈:

  1. 정책 엔진 우회가 필요한 경우가 있다 — 핑거프린팅 완료 후엔 "unknown" 정책이 매칭되지 않으므로, 재시작 시 직접 DB를 업데이트해야 함
  2. 세션 테이블을 인증의 진실의 원천으로 — DB 필드(username)보다 세션 레코드의 상태가 더 신뢰할 수 있는 인증 증거
  3. URL 전달 체인의 각 단계를 명확히 — catch_all → query param → hidden field → form submit → redirect

undefined