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

비밀번호

댓글 작성자
 




... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
1847정성태2/3/201526407기타: 50. C# - 윈도우에서 dropbox 동기화 폴더 경로 및 종료하는 방법
1846정성태2/2/201535511Windows: 102. 제어판의 프로그램 추가/삭제 항목을 수동으로 실행하고 싶다면? [1]
1845정성태1/26/201537187Windows: 101. 제어판의 "Windows 자격 증명 관리(Manage your credentials)"를 금지시키는 방법
1844정성태1/26/201534205오류 유형: 269. USB 메모리의 용량이 비정상적으로 보여진다면? [7]
1843정성태1/24/201525528VC++: 87. 무시할 수 없는 Visual C++ 런타임 함수 성능
1842정성태1/23/201549345개발 환경 구성: 255. 노트북 키보드에 없는 BREAK 키를 다른 키로 대체하는 방법
1841정성태1/21/201522892오류 유형: 268. Win32 핸들 관련 CLR4 보안 오류 사례
1840정성태1/8/201531167오류 유형: 267. Visual Studio - CodeLens 사용 시 CPU 100% 현상
1839정성태1/5/201523561디버깅 기술: 69. windbg 분석 사례 - cpu 100% 현상 (2)
1838정성태1/4/201544354기타: 49. 윈도우 내레이터(Narrator) 기능 끄는 방법(윈도우에 파란색의 굵은 테두리 선이 나타난다면?) [4]
1837정성태1/4/201530442디버깅 기술: 68. windbg 분석 사례 - 메모리 부족 [1]
1836정성태1/4/201530370디버깅 기술: 67. windbg - 덤프 파일과 handle 정보
1835정성태1/3/201531190개발 환경 구성: 254. SQL 서버 역시 SSL 3.0/TLS 1.0만을 지원하는 듯!
1834정성태1/3/201555752개발 환경 구성: 253. TLS 1.2를 적용한 IIS 웹 사이트 구성
1833정성태1/3/201532116.NET Framework: 490. System.Data.SqlClient는 SSL 3.0/TLS 1.0만 지원하는 듯! [3]
1832정성태1/2/201523669오류 유형: 266. Azure에 응용 프로그램 게시 중 로그인 오류
1831정성태1/1/201532490디버깅 기술: 66. windbg 분석 사례 - cpu 100% 현상 (1) [1]
1830정성태1/1/201531883오류 유형: 265. svchost.exe 프로세스(IP Helper: IPHLPSVC)의 CPU 100% 현상
1829정성태12/16/201435480VC++: 86. Windows Vista부터 바뀐 Credential Provider 예제 분석 (2) [2]파일 다운로드1
1828정성태12/15/201432101VC++: 85. Windows Vista부터 바뀐 Credential Provider 예제 분석 (1) [4]파일 다운로드1
1827정성태12/12/201428329VC++: 84. CredUIPromptForWindowsCredentials Win32 API 사용법 정리
1826정성태12/11/201432656.NET Framework: 489. Socket.Listen에 전달된 backlog 인자의 의미 [6]
1825정성태12/11/201480545.NET Framework: 488. TCP 소켓 연결의 해제를 알 수 있는 방법 [10]파일 다운로드1
1824정성태12/10/201429660.NET Framework: 487. Socket.Receive 메서드의 SocketFlags.Peek 동작을 이용해 소켓 연결 유무를 확인? [8]파일 다운로드1
1823정성태12/10/201426863.NET Framework: 486. Java의 ScheduledExecutorService에 대응하는 C#의 System.Threading.Timer [2]
1822정성태12/3/201428178개발 환경 구성: 252. Xamarin 라이선스 관리 [8]
... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...