Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

C# - stun.l.google.com을 사용해 공용 IP 주소와 포트를 알아내는 방법

예전에 Hole Punching에 대해서 설명한 적이 있는데요,

홀 펀칭(Hole Punching)을 이용한 Private IP 간 통신 - C#
; https://www.sysnet.pe.kr/2/0/1226

당시에는 NAT 환경으로부터 인터넷으로 나가는 구간의 공용 IP/포트를 알아내기 위해 제가 별도로 서버를 만들어 사용했었습니다. 그런데, 이와 관련해 STUN(Session Traversal Utilities for NAT) 표준 프로토콜(RFC 8489)이 이미 존재하고, 심지어 구글은 이에 대한 물리 서버를 무료로 공개해 두고 있습니다.

// https://stackoverflow.com/questions/20068944/how-to-self-host-to-not-rely-on-webrtc-stun-server-stun-l-google-com19302/20134888#20134888

stun:stun.l.google.com:19302
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302
stun:stun3.l.google.com:19302
stun:stun4.l.google.com:19302

어떻게 사용하는지 좀 볼까요? ^^




가장 기본적인 STUN 서버의 기능은, 그 응답으로 공용 IP와 Port를 알려주는 것입니다. 이에 대한 주소를 일컬어 "Server Reflexive Address"라고 부르는데요, 프로토콜에서는 이에 대한 요청 패킷을 20바이트 크기로 정의하고 있습니다.

// 요청에 사용하는 STUN 헤더 (20 bytes)

0                   1                   2                   3                   4
+-------------------+-------------------+-------------------+-------------------+
|      Message Type (0x0001)            |     Message Length (0x0000)           |
+-------------------+-------------------+-------------------+-------------------+
|                               Magic Cookie (0x2112A442)                       |
+-------------------+-------------------+-------------------+-------------------+
|                                                                               |
|                     Transaction ID (12 bytes, random)                         |
|                                                                               |
+-------------------+-------------------+-------------------+-------------------+

간단하죠? ^^ 저 정도는 일단 C#으로 대충 이렇게 표현할 수 있습니다.

using System.Net.Sockets;

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        StunHeader request = new StunHeader(StunMessageType.BindingRequest);

        byte[] message = request.ToByteArray();
    }
}

public enum StunMessageType : ushort
{
    BindingRequest = 0x0001,

    BindingResponse = 0x0101,
    BindingErrorResponse = 0x0111,
}

public enum StunAttrType : ushort
{
    ATTR_MAPPED_ADDRESS = 0x0001,       // RFC 3489 (legacy)
    ATTR_XOR_MAPPED_ADDRESS = 0x0020,   // RFC 5389
}

public struct StunHeader
{
    public StunMessageType Type { get; init; }
    ushort messageLength; // body(attributes) length (if no attrs == 0)
    public const uint MagicCookie = 0x2112A442; // 고정값
    byte[] transactionId = new byte[12];

    public StunHeader(StunMessageType type, ushort messageLength = 0)
    {
        this.Type = type;
        this.messageLength = messageLength;
        Random.Shared.NextBytes(transactionId);
    }

    public byte[] ToByteArray()
    {
        byte[] result = new byte[20]; // 20-byte header, no attributes

        ByteOrder.WriteUInt16BE(result, 0, (ushort)this.Type);
        ByteOrder.WriteUInt16BE(result, 2, this.messageLength);
        ByteOrder.WriteUInt32BE(result, 4, MagicCookie);

        transactionId.CopyTo(result, 8);

        return result;
    }
}

자, 그럼 저 내용 그대로 UDP에 실어 보낸 후 응답을 받으면 됩니다.

using (var udpClient = new UdpClient())
{
    udpClient.Connect("stun.l.google.com", 19302);

    await udpClient.SendAsync(message, message.Length);

    var result = await udpClient.ReceiveAsync(); // 서버가 보내준 응답
}

이제 UdpClient.ReceiveAsync가 반환한 UdpReceiveResult.Buffer 속성의 바이트 배열을 분석하면 되는데요, 응답 역시 요청에 사용한 StunHeader 구조와 동일한 20바이트로 시작하고 요청했던 메시지를 위한 추가 Attribute 값들이 붙게 됩니다.

// 반환 배열의 앞부분 20 bytes == STUN 헤더와 동일
// 20바이트 후부터는 요청에 대한 응답과 관련된 속성(Attribute) 데이터

0                   1                   2                   3                   4
+-------------------+-------------------+-------------------+-------------------+        ---
|      Message Type (0x0001)            |  Message Length == sizeof(attrs)      |         ^
+-------------------+-------------------+-------------------+-------------------+         |
|                               Magic Cookie (0x2112A442)                       |         |
+-------------------+-------------------+-------------------+-------------------+   (Stun Header)
|                                                                               |         |
|           Transaction ID (== same as request)                                 |         v
|                                                                               |        ---
+-------------------+-------------------+-------------------+-------------------+------(variable attribute length)---+
|        Attribute Type (1)             |  Attribute Length                     |  Attribute Value (4byte padding)   |
+-------------------+-------------------+-------------------+-------------------+------------------------------------+----+
|        Attribute Type (2)             |  Attribute Length                     |  Attribute Value (4byte padding)        |
+-------------------+-------------------+-------------------+-------------------+---------------------------------------+-+
|        Attribute Type ...(N)          |  Attribute Length                     |  Attribute Value (4byte padding)      |
+-------------------+-------------------+-------------------+-------------------+---------------------------------------+

따라서 Header 부분을 이전에 요청했던 내용과 비교하며 정합성을 체크하는 것부터 시작한 후,

var result = await udpClient.ReceiveAsync();

var response = request.GetResponse(result.Buffer);

public struct StunHeader
{
    // ...[생략]...

    public StunResponse GetResponse(byte[] buffer)
    {
        Trace.Assert(buffer.Length > 20, "Buffer too small for STUN header");

        StunMessageType type = (StunMessageType)ByteOrder.ReadUInt16BE(buffer, 0);
        Trace.Assert(type == StunMessageType.BindingResponse, $"Unexpected STUN message type: {type:x}");

        ushort messageLength = ByteOrder.ReadUInt16BE(buffer, 2);

        uint magicCookie = ByteOrder.ReadUInt32BE(buffer, 4);
        Trace.Assert(magicCookie == MagicCookie, "Invalid magic cookie");

        Trace.Assert(Enumerable.SequenceEqual(buffer.Skip(8).Take(12), this.transactionId), "transaction ID mismatch");

        // ...[생략]...
    }
}

남은 영역을 순회하며 Attribute에 대해 Type, Length, Value를 추출하면 됩니다.

public struct StunResponse
{
    public StunHeader Header { get; init; }
    public IPEndPoint MappedAddress { get; init; }

    public override string ToString()
    {
        return $"Type={Header.Type}, MappedAddress={MappedAddress}";
    }

    public static IPEndPoint ParseAttributes(byte[] buffer, int offset, int length, byte[] transactionId)
    {
        int idx = offset;
        int end = offset + length;
        IPEndPoint? mappedAddress = null;

        while (idx + 4 <= end) // attr type과 length 읽기: 최소 4바이트 필요
        {
            StunAttrType attrType = (StunAttrType)ByteOrder.ReadUInt16BE(buffer, idx);
            ushort attrLength = ByteOrder.ReadUInt16BE(buffer, idx + 2);

            idx += 4;

            if (idx + attrLength > end)
            {
                throw new Exception("Attribute length exceeds message length");
            }

            switch (attrType)
            {
                case StunAttrType.ATTR_MAPPED_ADDRESS:
                    mappedAddress = ...[생략: ATTR_MAPPED_ADDRESS 처리 코드]...
                    break;

                case StunAttrType.ATTR_XOR_MAPPED_ADDRESS:
                    mappedAddress = ...[생략: ATTR_XOR_MAPPED_ADDRESS 처리 코드]...
                    break;

                default:
                    // 관심 없는 속성은 무시 (FINGERPRINT, SOFTWARE, ERROR-CODE, ALTERNATE-SERVER, ...)
                    break;
            }

            // Attributes are padded to a multiple of 4 bytes
            idx += attrLength;
            if (attrLength % 4 != 0)
            {
                idx += 4 - (attrLength % 4);
            }
        }
        
        if (mappedAddress == null)
        {
            throw new Exception("No MAPPED-ADDRESS or XOR-MAPPED-ADDRESS attribute found");
        }

        return mappedAddress;
    }
}

여기서 우리가 가장 관심 있는 속성은 Server Reflexive Address를 알려주는 ATTR_MAPPED_ADDRESSATTR_XOR_MAPPED_ADDRESS입니다.

[MAPPED-ADDRESS (RFC 3489)]
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0 0 0 0 0 0 0 0|    Family     |           Port                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                 Address (32 bits or 128 bits)                 |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

[ATTR_XOR_MAPPED_ADDRESS (RFC 5389)]
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|x x x x x x x x|    Family     |         XOR-Port              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                XOR-Address (32 bits or 128 bits)              |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

이 둘은 사실 동일한 정보를 담고 있는데요, 단지 후자는 Magic Cookie와 Transaction ID를 이용해 IP와 Port를 XOR 연산한 값을 담고 있습니다.

또한, 차이점이 하나 더 있는데요, 현재 (구글의 stun.l.google.com을 포함해) 대부분의 STUN 서버는 RFC 5389를 지원하므로 ATTR_MAPPED_ADDRESS가 아닌 ATTR_XOR_MAPPED_ADDRESS를 반환한다는 점입니다. 따라서, 위의 코드를 stun.l.google.com에 대해서만 사용한다면 ATTR_MAPPED_ADDRESS 처리는 지워도 무방합니다.

설명한 바에 따라, ATTR_XOR_MAPPED_ADDRESS 속성의 Value 부분을 처리하는 코드는 다음과 같습니다.

IPEndPoint? mappedAddress = null;

if (attrType == StunAttrType.ATTR_XOR_MAPPED_ADDRESS)
{
    mappedAddress = ParseXorMappedAddress(buffer, idx, attrLength, transactionId);
} else ... {
    // ...
}

static IPEndPoint? ParseXorMappedAddress(byte[] buf, int pos, int len, byte[] transactionId)
{
    // Format (RFC 5389):
    //   0x00, family, xport(2), xaddr(4 or 16)
    if (len < 4)
    {
        return null;
    }

    byte family = buf[pos + 1];
    ushort xport = ByteOrder.ReadUInt16BE(buf, pos + 2);
    ushort port = (ushort)(xport ^ (StunHeader.MagicCookie >> 16)); // XOR with 0x2112

    if (family == 0x01) // IPv4
    {
        if (len < 8)
        {
            return null;
        }

        uint xaddr = ByteOrder.ReadUInt32BE(buf, pos + 4);
        uint addr = xaddr ^ StunHeader.MagicCookie;

        var addrBytes = new byte[]
        {
            (byte)((addr >> 24) & 0xFF),
            (byte)((addr >> 16) & 0xFF),
            (byte)((addr >> 8) & 0xFF),
            (byte)(addr & 0xFF)
        };

        return new IPEndPoint(new IPAddress(addrBytes), port);
    }
    else if (family == 0x02) // IPv6
    {
        if (len < 20)
        {
            return null;
        }

        var addrBytes = new byte[16];
        Buffer.BlockCopy(buf, pos + 4, addrBytes, 0, 16);

        // For IPv6, XOR with (magic cookie || transaction ID) 16 bytes
        var mask = new byte[16];
        ByteOrder.WriteUInt32BE(mask, 0, StunHeader.MagicCookie);
        Buffer.BlockCopy(transactionId, 0, mask, 4, 12);
        for (int i = 0; i < 16; i++)
        {
            addrBytes[i] ^= mask[i];
        }

        return new IPEndPoint(new IPAddress(addrBytes), port);
    }

    return null;
}

복잡한 듯 보이지만, IPv4, IPv6를 모두 지원한다는 점과 XOR 연산을 하는 정도가 추가된 것을 감안하면 거의 직관적으로 이해할 수 있을 것입니다.

끝입니다. ^^ 물론, STUN 프로토콜에 준비된 몇 가지 Attribute가 더 있지만, 우리가 관심을 두는 부분은 저 정도로 충분합니다.

(이 글의 소스 코드를 포함하는 첨부 파일의) 예제를 실행하면 이제 다음과 같은 결과를 보게 될 것입니다.

Received STUN response: Type=BindingResponse, MappedAddress=20.249.98.255:57101




그나저나, 현실적인 기준으로 보면 저렇게 공용 STUN 서버가 있다고 해도 여전히 (자신이 아닌) "상대방"의 공용 IP/Port를 알기 위해서는 별도 서버를 거쳐야만 합니다. 왜냐하면, 서로 간의 공용 IP/Port를 어딘가에는 기록해 두었다가 알려줘야 하기 때문인데 아쉽게도 공용 STUN 서버에는 이런 보관 기능이 없습니다.

물론, 수작업으로 서로 직접 입력하도록 해도 되지만... 글쎄요, 불편함만 감수할 수 있다면 그렇게 해도 되겠죠? ^^

하지만, 그걸 해결했다고 해도 (다시 한번 현실적인 기준으로) 결국엔 Hole Punching이 100% 성공하는 것은 아니므로 그에 대한 대비책으로 메시지 중계 서버를 준비해야 하기 때문에 결국엔 별도 서버가 필요하긴 마찬가지입니다. ^^;

그나마 부하 분산용으로 google의 STUN 서버를 활용할 수는 있겠지만 다른 의미로는 의존성을 늘리는 결과를 낳게 되므로 장단점을 따져봐야 합니다.




참고로, STUN 서버를 이용해 NAT 유형을 판별하는 방법도 있습니다. 이것은 단 한 번의 요청으로 해결되는 것은 아니고, 일정한 절차의 요청과 응답을 통해 판별해야 합니다.

이에 대한 이력을 잠시 살펴보면, 초기 RFC 3489에서는 NAT 유형을 "full-cone / restricted-cone / port-restricted / symmetric"이라는 용어로 구분했다고 합니다. 하지만, RFC 5780으로 오면서 NAT 유형이 단순하지 않다는 점을 감안해 2가지 Behavior(Mapping, Filtering)로 나누어 구분하고 있습니다.

Mapping Behavior:
    - Endpoint Independent Mapping (EIM)
    - Address Dependent Mapping (ADM)
    - Address and Port Dependent Mapping (APDM)

Filtering Behavior:
    - Endpoint Independent Filtering (EIF)
    - Address Dependent Filtering (ADF)
    - Address and Port Dependent Filtering (APDF)

각각을 나누는 기준에 대해서는 아래의 글에서 잘 설명하고 있으므로 생략합니다. ^^

NAT 장비는 이렇게 만들어야 하는데... (RFC 4787) - 1편: Mapping Behavior
; https://www.netmanias.com/ko/?m=view&id=blog&no=5833

NAT 장비는 이렇게 만들어야 하는데... (RFC 4787) - 2편: Filtering Behavior
; https://www.netmanias.com/ko/?m=view&id=blog&no=5839

RFC 5780 (STUN 확장) NAT Behavior Discovery를 통한 UDP Hole punching 가능 여부 판별
; https://swjman.tistory.com/169

새로운 Behaivor 기준을 기존 용어에 적용하면 다음과 같이 매핑됩니다.

| Mapping  | Filtering | Legacy label                  |
| -------- | --------- | ----------------------------- |
| EIM      | EIF       | Full-cone NAT                 |
| EIM      | ADF       | (Address-)Restricted-cone NAT |
| EIM      | APDF      | Port-restricted-cone NAT      |
| ADM/APDM | any       | Symmetric NAT                 |

간단하게 요약하면, Hole Punching이 성공하려면 Mapping Behavior의 판정이 EIM(Endpoint Independent Mapping)이어야 합니다. 만약 ADM 또는 APDM이라면 Filtering Behavior가 어떻든 간에 Hole Punching은 실패합니다. (ADM의 경우에는 100%는 아니고 대부분 실패한다고 합니다.)

P2P 응용 프로그램의 개발자 입장에서 현실적인 관점으로 저 내용을 다시 살펴보면, Mapping/Filtering Behavior 판별 결과를 굳이 알 필요가 없습니다. 그에 상관없이 상대방 공용 IP/Port를 알았다면 일단 Hole Punching을 시도해 보고, 실패하면 중계 서버로 연결을 시도하면 그만이기 때문입니다.

이 과정에서 하나 더 고려한다면, 우선 같은 NAT 환경에 있을 수도 있으므로 우선적으로 1) Private IP로 연결을 시도하고, 2) STUN으로 알아낸 공용 IP/Port로 연결을 시도하고, 3) 마지막으로 중계 서버로 연결을 시도하는 순서로 진행할 수 있습니다. 그리고 이런 3가지 절차를 프로토콜로 정리한 것이 ICE(Interactive Connectivity Establishment)이고 각각 "Local Address", "Server Reflexive Address", "Relayed Address" 3개의 후보군 중에서 연결을 선택하게 됩니다.




Python에서는 talkiq/pystun3라는 패키지를 이용해,

// talkiq/pystun3
// https://github.com/talkiq/pystun3

python -m pip install pystun3

STUN 서버를 쉽게 사용할 수 있습니다.

import stun

nat_type, external_ip, external_port = stun.get_ip_info(
    stun_host="stun.l.google.com",
    stun_port=19302
)
print(f"NAT Type: {nat_type}\nExternal IP: {external_ip}\nExternal Port: {external_port}")

# 출력 결과:
# NAT Type: Full Cone
# External IP: 20.249.98.255
# External Port: 54620

get_ip_info 함수 내부를 살펴보면, NAT 유형 판별을 위한 절차도 포함하고 있으니 필요하다면 참고하시기 바랍니다.




끝으로, 공용 IP만이라면 curl 등의 방법을 이용해서도 구할 수 있습니다.

c:\temp> curl ifconfig.me
20.249.98.255

혹은 이러한 동작을 구현하는 초간단 서버가 필요하다면 (C# + ASP.NET의 경우) Request.ServerVariables 속성으로 조회한 값을 반환하도록 구현하면 됩니다.

string addr = Request.ServerVariables["REMOTE_ADDR"];
string port = Request.ServerVariables["REMOTE_PORT"];
Response.Write($"{addr}:{port}"); // 바로 이것이 Server Reflexive Address 값입니다. (Reverse Proxy가 끼어들지 않는다면...)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 9/17/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




1  2  3  4  5  6  [7]  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13865정성태1/15/20258089오류 유형: 942. C# - .NET Framework 4.5.2 이하의 버전에서 HttpWebRequest로 https 호출 시 "System.Net.WebException" 예외 발생
13864정성태1/15/20258060Linux: 114. eBPF를 위해 필요한 SELinux 보안 정책
13863정성태1/14/20255976Linux: 113. Linux - 프로세스를 위한 전용 SELinux 보안 문맥 지정
13862정성태1/13/20256985Linux: 112. Linux - 데몬을 위한 SELinux 보안 정책 설정
13861정성태1/11/20257447Windows: 276. 명령행에서 원격 서비스를 동기/비동기로 시작/중지
13860정성태1/10/20256584디버깅 기술: 216. WinDbg - 2가지 유형의 식 평가 방법(MASM, C++)
13859정성태1/9/20258070디버깅 기술: 215. Windbg - syscall 이후 실행되는 KiSystemCall64 함수 및 SSDT 디버깅
13858정성태1/8/20258482개발 환경 구성: 738. PowerShell - 원격 호출 시 "powershell.exe"가 아닌 "pwsh.exe" 환경으로 명령어를 실행하는 방법
13857정성태1/7/20258428C/C++: 187. Golang - 콘솔 응용 프로그램을 Linux 데몬 서비스를 지원하도록 변경파일 다운로드1
13856정성태1/6/20256409디버깅 기술: 214. Windbg - syscall 단계까지의 Win32 API 호출 (예: Sleep)
13855정성태12/28/20248932오류 유형: 941. Golang - os.StartProcess() 사용 시 오류 정리
13854정성태12/27/20248703C/C++: 186. Golang - 콘솔 응용 프로그램을 NT 서비스를 지원하도록 변경파일 다운로드1
13853정성태12/26/20246930디버깅 기술: 213. Windbg - swapgs 명령어와 (Ring 0 커널 모드의) FS, GS Segment 레지스터
13852정성태12/25/20249462디버깅 기술: 212. Windbg - (Ring 3 사용자 모드의) FS, GS Segment 레지스터파일 다운로드1
13851정성태12/23/20247279디버깅 기술: 211. Windbg - 커널 모드 디버깅 상태에서 사용자 프로그램을 디버깅하는 방법
13850정성태12/23/20249345오류 유형: 940. "Application Information" 서비스를 중지한 경우, "This file does not have an app associated with it for performing this action."
13849정성태12/20/20249293디버깅 기술: 210. Windbg - 논리(가상) 주소를 Segmentation을 거쳐 선형 주소로 변경
13848정성태12/18/20248338디버깅 기술: 209. Windbg로 알아보는 Prototype PTE파일 다운로드2
13847정성태12/18/20247873오류 유형: 939. golang - 빌드 시 "unknown directive: toolchain" 오류 빌드 시 이런 오류가 발생한다면?
13846정성태12/17/20249658디버깅 기술: 208. Windbg로 알아보는 Trans/Soft PTE와 2가지 Page Fault 유형파일 다운로드1
13845정성태12/16/20246894디버깅 기술: 207. Windbg로 알아보는 PTE (_MMPTE)
13844정성태12/14/202410210디버깅 기술: 206. Windbg로 알아보는 PFN (_MMPFN)파일 다운로드1
13843정성태12/13/20247481오류 유형: 938. Docker container 내에서 빌드 시 error MSB3021: Unable to copy file "..." to "...". Access to the path '...' is denied.
13842정성태12/12/20247881디버깅 기술: 205. Windbg - KPCR, KPRCB
13841정성태12/11/20248562오류 유형: 937. error MSB4044: The "ValidateValidArchitecture" task was not given a value for the required parameter "RemoteTarget"
13840정성태12/11/20247670오류 유형: 936. msbuild - Your project file doesn't list 'win' as a "RuntimeIdentifier"
1  2  3  4  5  6  [7]  8  9  10  11  12  13  14  15  ...