Skip to main content

just_shield/
dns_observer.rs

1//! 초소형 DNS 관찰자 (층 ⓒ의 눈, ADR-0006) — Linux 러너 전용, 의존 크레이트 0.
2//!
3//! 잡의 DNS 질의를 업스트림으로 그대로 중계하고, 질의된 이름(QNAME)만 기록 파일에
4//! 남긴다. 차단하지 않는다 — 관찰은 정책 작성을 위한 가시성 도구이지 보안 경계가 아니다.
5//!
6//! 관찰자와 판정은 이 기록 파일로 분리된다(PRD 결정): 여기서 만든 파일을
7//! `observe report`(S1)가 읽는다. 그래서 QNAME 추출이라는 테스트할 가치가 있는
8//! 부분은 순수 함수로, 소켓을 만지는 부분은 최대한 얇게 둔다.
9
10use std::collections::BTreeSet;
11use std::io;
12use std::net::UdpSocket;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17/// DNS 질의 메시지에서 첫 질문의 QNAME을 추출한다.
18///
19/// 헤더 12바이트 뒤부터 라벨 시퀀스(`<len><bytes>...0x00`)를 읽는다. 질의 패킷의
20/// 질문 섹션에는 압축 포인터가 쓰이지 않으므로(RFC 1035) 포인터는 거부한다 —
21/// 관찰 대상은 우리가 받는 아웃바운드 질의뿐이다.
22pub fn extract_qname(packet: &[u8]) -> Option<String> {
23    if packet.len() < 12 {
24        return None;
25    }
26    // QDCOUNT(질문 수)가 0이면 추출할 이름이 없다.
27    let qdcount = u16::from_be_bytes([packet[4], packet[5]]);
28    if qdcount == 0 {
29        return None;
30    }
31    let mut pos = 12;
32    let mut labels = Vec::new();
33    loop {
34        let len = *packet.get(pos)? as usize;
35        if len == 0 {
36            break; // 루트 라벨 — 이름 끝.
37        }
38        // 상위 2비트가 켜져 있으면 압축 포인터 — 질의에는 없어야 한다.
39        if len & 0xC0 != 0 {
40            return None;
41        }
42        pos += 1;
43        let end = pos.checked_add(len)?;
44        let label = packet.get(pos..end)?;
45        // 라벨은 호스트네임 문자만 — 이상하면 거부(잘린 패킷 등).
46        if !label
47            .iter()
48            .all(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
49        {
50            return None;
51        }
52        labels.push(String::from_utf8_lossy(label).to_ascii_lowercase());
53        pos = end;
54    }
55    if labels.is_empty() {
56        return None;
57    }
58    Some(labels.join("."))
59}
60
61/// resolv.conf에서 첫 `nameserver` 주소를 찾는다 — 우리의 업스트림이 된다.
62pub fn first_nameserver(resolv: &str) -> Option<String> {
63    for line in resolv.lines() {
64        if let Some(addr) = line.trim().strip_prefix("nameserver ") {
65            let addr = addr.trim();
66            if !addr.is_empty() && addr != "127.0.0.1" {
67                return Some(addr.to_string());
68            }
69        }
70    }
71    None
72}
73
74/// 관찰을 켜는 resolv.conf를 만든다 — 127.0.0.1을 첫 리졸버로 두되 **기존
75/// nameserver들을 폴백으로 남긴다**. 이것이 fail-open의 핵심: 관찰자가 죽으면
76/// 127.0.0.1 질의가 즉시 거부되고 리졸버가 다음 줄(진짜 리졸버)로 넘어가, 잡의
77/// 이름 해석이 끊기지 않는다.
78pub fn observing_resolv(original: &str) -> String {
79    let mut out = String::from("# just-shield observe — 127.0.0.1 우선, 원본은 폴백.\n");
80    out.push_str("nameserver 127.0.0.1\n");
81    for line in original.lines() {
82        let trimmed = line.trim();
83        // 원본의 nameserver는 폴백으로 보존(중복 127.0.0.1만 제외), 그 외 지시어도 보존.
84        if trimmed == "nameserver 127.0.0.1" {
85            continue;
86        }
87        if trimmed.starts_with('#') {
88            continue;
89        }
90        out.push_str(line);
91        out.push('\n');
92    }
93    out
94}
95
96/// 관찰자가 모은 도메인 집합을 S1이 읽는 기록 파일 형식으로 직렬화한다.
97pub fn render_record(job: &str, domains: &BTreeSet<String>) -> String {
98    let mut out = format!("# just-shield observe 기록 — 잡 '{job}'이 조회한 도메인.\njob {job}\n");
99    for d in domains {
100        out.push_str(d);
101        out.push('\n');
102    }
103    out
104}
105
106/// 중계기 설정.
107pub struct RelayConfig {
108    /// 우리가 들을 주소 (예: 127.0.0.1:53).
109    pub listen: String,
110    /// 질의를 전달할 진짜 리졸버 (예: 8.8.8.8:53).
111    pub upstream: String,
112    /// 기록 파일 경로 — 잡 이름과 함께.
113    pub job: String,
114    pub record_path: std::path::PathBuf,
115    /// 종료 신호.
116    pub stop: Arc<AtomicBool>,
117}
118
119/// 중계 루프. 질의를 받으면 ① QNAME을 기록하고 ② 업스트림에 그대로 전달해
120/// 응답을 돌려준다. **새 도메인이 보일 때마다 기록 파일을 즉시 갱신한다** —
121/// `kill -9`로 관찰자가 죽어도 그때까지의 기록은 디스크에 남는다.
122/// 어떤 단계가 실패해도 루프는 계속된다 — fail-open.
123pub fn serve(config: &RelayConfig) -> io::Result<()> {
124    let sock = UdpSocket::bind(&config.listen)?;
125    sock.set_read_timeout(Some(Duration::from_millis(200)))?;
126    let seen = Arc::new(Mutex::new(BTreeSet::new()));
127    // 시작 즉시 빈 기록을 한 번 쓴다 — 질의가 0건이어도 보고가 파일을 찾도록.
128    let _ = std::fs::write(
129        &config.record_path,
130        render_record(&config.job, &seen.lock().unwrap()),
131    );
132    let mut buf = [0u8; 1500];
133    while !config.stop.load(Ordering::Relaxed) {
134        let (n, from) = match sock.recv_from(&mut buf) {
135            Ok(v) => v,
136            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => continue,
137            Err(ref e) if e.kind() == io::ErrorKind::TimedOut => continue,
138            Err(_) => continue, // fail-open: 수신 오류는 무시하고 계속.
139        };
140        let query = &buf[..n];
141        if let Some(name) = extract_qname(query)
142            && let Ok(mut set) = seen.lock()
143            && set.insert(name)
144        {
145            // 새 도메인 — 기록 파일을 즉시 갱신(flush-on-update).
146            let _ = std::fs::write(&config.record_path, render_record(&config.job, &set));
147        }
148        // 업스트림으로 전달하고 응답을 질의자에게 돌려준다. 우리가 리졸버 경로에
149        // 끼어든 이상 전달은 해줘야 한다 — 실패 시 그 질의만 포기한다(fail-open).
150        let _ = forward(&sock, query, &config.upstream, from);
151    }
152    Ok(())
153}
154
155/// 한 질의를 업스트림에 전달하고 응답을 원 질의자에게 돌려준다.
156fn forward(
157    listen_sock: &UdpSocket,
158    query: &[u8],
159    upstream: &str,
160    reply_to: std::net::SocketAddr,
161) -> io::Result<()> {
162    let up = UdpSocket::bind("0.0.0.0:0")?;
163    up.set_read_timeout(Some(Duration::from_secs(3)))?;
164    up.send_to(query, upstream)?;
165    let mut resp = [0u8; 1500];
166    let n = up.recv(&mut resp)?;
167    listen_sock.send_to(&resp[..n], reply_to)?;
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// 헤더(12B) + QNAME 라벨들 + 0x00 + QTYPE/QCLASS로 질의 패킷을 만든다.
176    fn query_packet(name: &str) -> Vec<u8> {
177        let mut p = vec![
178            0x12, 0x34, // ID
179            0x01, 0x00, // flags: 표준 질의
180            0x00, 0x01, // QDCOUNT = 1
181            0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
182        ];
183        for label in name.split('.') {
184            p.push(label.len() as u8);
185            p.extend_from_slice(label.as_bytes());
186        }
187        p.push(0x00); // 루트
188        p.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]); // QTYPE=A, QCLASS=IN
189        p
190    }
191
192    #[test]
193    fn extracts_simple_and_multi_label_names() {
194        assert_eq!(
195            extract_qname(&query_packet("ghcr.io")).as_deref(),
196            Some("ghcr.io")
197        );
198        assert_eq!(
199            extract_qname(&query_packet("abc123.blob.core.windows.net")).as_deref(),
200            Some("abc123.blob.core.windows.net")
201        );
202    }
203
204    #[test]
205    fn lowercases_names() {
206        assert_eq!(
207            extract_qname(&query_packet("GHCR.IO")).as_deref(),
208            Some("ghcr.io")
209        );
210    }
211
212    #[test]
213    fn rejects_compression_pointer_in_question() {
214        let mut p = query_packet("evil.net");
215        // 첫 라벨 길이 바이트를 압축 포인터(0xC0..)로 오염.
216        p[12] = 0xC0;
217        assert_eq!(extract_qname(&p), None);
218    }
219
220    #[test]
221    fn rejects_truncated_and_empty() {
222        assert_eq!(extract_qname(&[0u8; 5]), None); // 헤더보다 짧음
223        // QDCOUNT=0
224        let mut p = query_packet("x.com");
225        p[4] = 0;
226        p[5] = 0;
227        assert_eq!(extract_qname(&p), None);
228        // 길이가 패킷을 넘어가는 라벨.
229        let mut bad = vec![0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
230        bad.push(0x40); // 64바이트 라벨이라 주장하지만 데이터 없음
231        assert_eq!(extract_qname(&bad), None);
232    }
233
234    #[test]
235    fn first_nameserver_skips_localhost() {
236        let resolv = "# comment\nnameserver 127.0.0.1\nnameserver 8.8.8.8\noptions edns0\n";
237        assert_eq!(first_nameserver(resolv).as_deref(), Some("8.8.8.8"));
238        assert_eq!(first_nameserver("options edns0\n"), None);
239    }
240
241    #[test]
242    fn observing_resolv_keeps_original_as_fallback() {
243        let original = "nameserver 8.8.8.8\nnameserver 1.1.1.1\noptions edns0\n";
244        let out = observing_resolv(original);
245        let lines: Vec<&str> = out
246            .lines()
247            .filter(|l| l.starts_with("nameserver"))
248            .collect();
249        // 127.0.0.1이 첫 줄, 원본 리졸버들이 폴백으로 뒤따른다.
250        assert_eq!(lines[0], "nameserver 127.0.0.1");
251        assert!(lines.contains(&"nameserver 8.8.8.8"));
252        assert!(lines.contains(&"nameserver 1.1.1.1"));
253        // options 같은 비-nameserver 지시어도 보존.
254        assert!(out.contains("options edns0"));
255    }
256
257    #[test]
258    fn record_format_matches_observe_reader() {
259        let mut set = BTreeSet::new();
260        set.insert("ghcr.io".to_string());
261        set.insert("crates.io".to_string());
262        let text = render_record("release", &set);
263        // S1의 parse_record가 읽을 수 있어야 한다 — 같은 형식.
264        let parsed = crate::observe::parse_record(&text).unwrap();
265        assert_eq!(parsed.job, "release");
266        assert!(parsed.domains.contains("ghcr.io"));
267        assert!(parsed.domains.contains("crates.io"));
268    }
269}