집에서 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의 기존 흐름:
- catch_all 핸들러: 격리된 단말의 모든 HTTP 요청 → state.portal_url(루트 /)로 리다이렉트. 원래 URL 버림.
- 로그인 폼: redirect_url 필드 없음
- 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를 사용하므로 정상 작동)
마무리
핵심 교훈:
- 정책 엔진 우회가 필요한 경우가 있다 — 핑거프린팅 완료 후엔 "unknown" 정책이 매칭되지 않으므로, 재시작 시 직접 DB를 업데이트해야 함
- 세션 테이블을 인증의 진실의 원천으로 — DB 필드(username)보다 세션 레코드의 상태가 더 신뢰할 수 있는 인증 증거
- URL 전달 체인의 각 단계를 명확히 — catch_all → query param → hidden field → form submit → redirect
'勉強 > Network' 카테고리의 다른 글
| 암호프로토콜 - TCP/IP 응용계층 보안 프로토콜 분석 (TLS, AES, RSA 이론 + KMS 프로젝트 구현 코드 분석) (1) | 2026.06.05 |
|---|---|
| 문서 중앙화 DLP/ECM 시스템 전체 아키텔처 - 통신 흐름, 보안 설계, AWS 배포 정리 (0) | 2026.06.02 |