DNS Lookup간 통신 이슈 회고 (feat. org.xbill.DNS)

겔로그·2024년 4월 21일
0
post-thumbnail

이 글은 회사에서 있었던 네트워크 통신 이슈에 대해 정리 및 회고해보는 글입니다.
회사 네트워크 정책 및 서비스와 관련없는 오류와 관련된 내용만을 작성해 조금 짧은 회고가 될 것 같네요.

사건 발생

4월 점검 이후 고객의 서비스 문의 사항을 통해 오류 내용을 최초로 인지하였습니다.
오류가 발생한 주요 변경사항은 SPF 레코드 인증 강화 태스크였으며 아래와 같은 내용을 서비스 인증 내용에 반영하였습니다.

m3aawg_managing-spf_records-2017-08.pdf - 3.2.1 Syntax Issues 참고

오류로 인해 발생한 이슈는 SPF 레코드를 인증한 모든 고객사에 SPF 미인증 메일 발송이었고, 해당 메일이 발송된 이후 몇몇 고객사에서 해당 내용 관련 문의가 유입되었습니다.

원인 파악

1. 반영건 점검

사실 앞서 공유드린 SPF 레코드 인증 강화 로직은 1월 반영건이었습니다. 당시에는 발견되지 않았던 오류가 왜 4월에 되어서야 확인이 되었는지 의문이 들었습니다.

해당 내용을 자세히 파악해보니 두 가지 변경사항이 존재했습니다.

주요 변경사항

  • SPF 미인증 모니터링 로직 개선
  • DNS LookUp Resolver 개선

2. 변경사항 파악

  1. SPF 미인증 모니터링 로직 개선 로직에는 1월에 반영된 로직을 동일하게 반영했습니다.
    반영된 내용으로 인해 오류가 발생했다는 것을 인지하였습니다.

다만, 변경된 로직은 동일하게 동작하였고 단 한 줄의 차이가 존재했습니다. 그것이 2. DNS LookUp Resolver 개선 건이었습니다.

3. 코드 분석

서비스에서는 GoogleDNSResolver를 통해 도메인에 정의된 레코드를 Lookup하도록 설계되었습니다.

  • 도메인 Lookup은 DNS(도메인 네임 서버)를 통해 문자열 도메인을 조회하는 기능입니다.
  • 이를 통해 도메인에 정의된 여러 레코드를 조회할 수 있습니다.

GoogleDNSResolver에 정상적으로 레코드가 정상적으로 반영되어 있을경우, 서비스에서는 해당 내용을 인증 처리하고 있습니다. 그럼 내부 로직을 간단하게 볼까요? (해당 내용은 서비스 내부 로직 중 오류 상황에 영향을 미친 로직만을 재구성한 코드입니다.)

기존 내부 로직 변경(LookUp 클래스 초기화)

//Before 
records = Optional.ofNullable(new Lookup(domain, Type.TXT).run()).orElse(new Record[0]);

//After
Lookup lookup = new Lookup(domain, Type.TXT);
lookup.setResolver(googleDnsResolver);
records = Optional.ofNullable(lookup.run()).orElse(new Record[0]);

확인 결과 기존에는 자체 정의된 googleDnsResolver를 사용하고 있지 않았습니다. 그렇다면 googleDnsResolver를 설정하지 않은 LookUp 클래스는 어떻게 정의되는지 확인해 보겠습니다.

googleDNSResolver

    public DNSResolverHelper() throws UnknownHostException {
        try {
            this.googleDnsResolver = new SimpleResolver(GOOGLE_DNS_IP_ADDRESS);
            this.googleDnsResolver.setTimeout(DNS_LOOKUP_TIMEOUT_SECOND);
        } catch (UnknownHostException e) {
            log.error("Make DNS Resolver is failed", e);
            throw e;
        }
    }
  • 사용하고자 한 googleDNSResolver 의 세팅은 다음과 같습니다.
  • SimpleResolver를 사용합니다.

Lookup.java의 defaultResolver

    public Lookup(Name name, int type, int dclass) {
        Type.check(type);
        DClass.check(dclass);
        if (!Type.isRR(type) && type != 255) {
            throw new IllegalArgumentException("Cannot query for meta-types other than ANY");
        } else {
            this.name = name;
            this.type = type;
            this.dclass = dclass;
            synchronized(Lookup.class) {
                this.resolver = getDefaultResolver();
                this.searchPath = getDefaultSearchPath();
                this.cache = getDefaultCache(dclass);
            }

            this.credibility = 3;
            this.verbose = Options.check("verbose");
            this.result = -1;
        }
    }
     public static synchronized void refreshDefault() {
        try {
            defaultResolver = new ExtendedResolver();
        } catch (UnknownHostException var1) {
            throw new RuntimeException("Failed to initialize resolver");
        }

        defaultSearchPath = ResolverConfig.getCurrentConfig().searchPath();
        defaultCaches = new HashMap();
        defaultNdots = ResolverConfig.getCurrentConfig().ndots();
    }
  • LookUp 클래스를 기본값으로 초기화시 default로 ExtendedResolver를 생성해 사용하고 있습니다.

ExtendedResolver.java

    public ExtendedResolver() throws UnknownHostException {
        this.init();
        String[] servers = ResolverConfig.getCurrentConfig().servers();
        if (servers != null) {
            for(int i = 0; i < servers.length; ++i) {
                Resolver r = new SimpleResolver(servers[i]);
                r.setTimeout(5);
                this.resolvers.add(r);
            }
        } else {
            this.resolvers.add(new SimpleResolver());
        }

    }
   
  • ExtendedResolver는 SimpleResolver를 사용하지만 ResolverConfig.getCurrentConfig().servers() 에서 나온 servers 배열을 통해 초기화를 진행합니다.
  • 해당 부분에서 정의된 servers가 어떤 내용인지 확인이 필요해 보입니다.

ResolverConfig.java

    public ResolverConfig() {
        if (!this.findProperty()) {
            if (!this.findSunJVM()) {
                if (this.servers == null || this.searchlist == null) {
                    String OS = System.getProperty("os.name");
                    String vendor = System.getProperty("java.vendor");
                    if (OS.indexOf("Windows") != -1) {
                        if (OS.indexOf("95") == -1 && OS.indexOf("98") == -1 && OS.indexOf("ME") == -1) {
                            this.findNT();
                        } else {
                            this.find95();
                        }
                    } else if (OS.indexOf("NetWare") != -1) {
                        this.findNetware();
                    } else if (vendor.indexOf("Android") != -1) {
                        this.findAndroid();
                    } else {
                        this.findUnix();
                    }
                }

            }
        }
    }


    private void findUnix() {
        this.findResolvConf("/etc/resolv.conf");
    }
  • Unix 서버에서는 /etc/resolv.conf 파일을 보고 nameserver를 확인하고 있는 것을 확인할 수 있습니다.
  • 오류가 발생한 서버의 /etc/resolv.conf 파일에는 googleDNS 서버 IP가 아닌 사내 nameserver IP가 정의되어 있었습니다.

4. 정리

LookUp 클래스 초기화간 Resolver 세팅 유무에 따른 차이점은 다음과 같았습니다.

  • googleDNSResolver로 세팅시 : 8.8.8.8 53번 포트로 googleDNS 서버와 통신 진행
  • LookUp으로 초기화시: /etc/resolv.conf 파일에 정의된 nameserver로 DNS Lookup진행

어차피 둘 다 nameserver인 것은 똑같은데 왜 한 쪽은 오류가 발생했고 한쪽은 오류가 발생하지 않았을까요?

이는 회사 내부 정책과 RFC 표준을 통해 알 수 있었습니다.

5. RFC-6891 - UDP Message Size

현재 서비스에서는 DNS Lookup간 org.xbill.DNS 패키지를 사용하고 있습니다.
org.xbill.DNS 패키지에서 LookUp간 네트워크 통신을 하는 로직을 세부적으로 확인할 경우 다음과 같은 로직을 확인하실 수 있습니다.

public Message send(Message query) throws IOException {
        if (Options.check("verbose")) {
            System.err.println("Sending to " + this.address.getAddress().getHostAddress() + ":" + this.address.getPort());
        }

        if (query.getHeader().getOpcode() == 0) {
            Record question = query.getQuestion();
            if (question != null && question.getType() == 252) {
                return this.sendAXFR(query);
            }
        }

        query = (Message)query.clone();
        this.applyEDNS(query);
        if (this.tsig != null) {
            this.tsig.apply(query, (TSIGRecord)null);
        }

        byte[] out = query.toWire(65535);
        int udpSize = this.maxUDPSize(query);
        boolean tcp = false;
        long endTime = System.currentTimeMillis() + this.timeoutValue;

        while(true) {
            while(true) {
                if (this.useTCP || out.length > udpSize) {
                    tcp = true;
                }

                byte[] in;
                if (tcp) {
                    in = TCPClient.sendrecv(this.localAddress, this.address, out, endTime);
                } else {
                    in = UDPClient.sendrecv(this.localAddress, this.address, out, udpSize, endTime);
                }

                if (in.length < 12) {
                    throw new WireParseException("invalid DNS header - too short");
                }

                int id = ((in[0] & 255) << 8) + (in[1] & 255);
                int qid = query.getHeader().getID();
                if (id == qid) {
                    Message response = this.parseMessage(in);
                    this.verifyTSIG(query, response, in, this.tsig);
                    if (tcp || this.ignoreTruncation || !response.getHeader().getFlag(6)) {
                        return response;
                    }

                    tcp = true;
                } else {
                    String error = "invalid message id: expected " + qid + "; got id " + id;
                    if (tcp) {
                        throw new WireParseException(error);
                    }

                    if (Options.check("verbose")) {
                        System.err.println(error);
                    }
                }
            }
        }
    }
    
    private int maxUDPSize(Message query) {
        OPTRecord opt = query.getOPT();
        return opt == null ? 512 : opt.getPayloadSize();
    }

여기서 주요 로직은 다음과 같음

int udpSize = this.maxUDPSize(query); // udp size를 넘을 경우 TCP로 자동 변환

if (this.useTCP || out.length > udpSize) {
    tcp = true;
}

byte[] in;
if (tcp) {
    in = TCPClient.sendrecv(this.localAddress, this.address, out, endTime);
} else {
    in = UDPClient.sendrecv(this.localAddress, this.address, out, udpSize, endTime);
}

private int maxUDPSize(Message query) {
    OPTRecord opt = query.getOPT();
    return opt == null ? 512 : opt.getPayloadSize();
 }

RFC 6891에 의거하여 udp size를 초과할 경우 tcp 통신으로 변경하는 로직이 내부적으로 동작하고 있습니다.

현재 기본 옵션을 사용하고 있어 기본적인 LookUp간 네트워크 통신은 UDP를 사용하나 512 bytes를 초과할 경우 tcp 통신으로 변환됩니다.

6. 사내 정책

사내 정책은 기본적으로 UDP를 허용하고 있으나 외부 IP로의 TCP 통신은 차단하고 있었습니다. 차단 해제를 위해서는 별도의 허용 정책 요청이 필요한 상황입니다.

결론

  • 일부 도메인 Lookup간 쿼리 사이즈가 512 bytes 보다 커 자동으로 통신이 udp -> tcp로 변경
  • 사내 정책에 따라 외부 IP에 대한 TCP 통신이 차단되어 LookUp간 timed out 이 발생

영향도

  • LookUp간 조회되는 레코드 내용이 512bytes를 초과하는 도메인

해결 방법

    1. LookUp을 확장하여 정의해 512 bytes를 넘어서도 udp 통신을 가능하게 정의
    1. 사내 tcp 정책을 차단 -> 허용으로 변경
    1. 도메인 레코드 LookUp Size를 512 bytes 미만으로 변경

이 중 2번이 가장 쉽게 적용할 수 있는 방안이어서 정책을 허용으로 변경하는 것으로 대응 마무리 하였습니다.

회고

  • 네트워크 통신간 어떤 프로토콜을 사용하는지, 사내 정책으로는 어떤 것이 있는지 한 번 더 꼼꼼하게 파악하고 개선을 진행해야겠다는 생각을 하게 되었습니다.
  • RFC 문서를 완벽하게 이해하진 않더라도 어느 정도는 읽어보면서 서비스에 적용되었거나 필요한 내용들을 문서화하여 관리해야겠다는 생각을 하게 되었습니다.
profile
Gelog 나쁜 것만 드려요~

0개의 댓글