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

비밀번호

댓글 작성자
 




... 46  47  48  49  50  51  [52]  53  54  55  56  57  58  59  60  ...
NoWriterDateCnt.TitleFile(s)
12732정성태7/23/202116799오류 유형: 742. SharePoint - The super user account utilized by the cache is not configured.
12731정성태7/23/202118856개발 환경 구성: 584. Add Internal URLs 화면에서 "Save" 버튼이 비활성화 된 경우
12730정성태7/23/202121368개발 환경 구성: 583. Visual Studio Code - Go 코드에서 입력을 받는 경우
12729정성태7/22/202118394.NET Framework: 1080. xUnit 단위 테스트에 메서드/클래스 수준의 문맥 제공 - Fixture
12728정성태7/22/202120168.NET Framework: 1079. MSTestv2 단위 테스트에 메서드/클래스/어셈블리 수준의 문맥 제공
12727정성태7/21/202120955.NET Framework: 1078. C# 단위 테스트 - MSTestv2/NUnit의 Assert.Inconclusive 사용법(?) [1]
12726정성태7/21/202119853VS.NET IDE: 169. 비주얼 스튜디오 - 단위 테스트 선택 시 MSTestv2 외의 xUnit, NUnit 사용법 [1]
12725정성태7/21/202119169오류 유형: 741. Failed to find the "go" binary in either GOROOT() or PATH
12724정성태7/21/202122700개발 환경 구성: 582. 윈도우 환경에서 Visual Studio Code + Go (Zip) 개발 환경 [1]
12723정성태7/21/202117006오류 유형: 740. SharePoint - Alternate access mappings have not been configured 경고
12722정성태7/20/202117339오류 유형: 739. MSVCR110.dll이 없어 exe 실행이 안 되는 경우
12721정성태7/20/202124123오류 유형: 738. The trust relationship between this workstation and the primary domain failed. - 세 번째 이야기
12720정성태7/19/202119198Linux: 43. .NET Core/5+ 응용 프로그램의 Ubuntu (Debian) 패키지 준비
12719정성태7/19/202118304오류 유형: 737. SharePoint 설치 시 "0x800710D8 The object identifier does not represent a valid object." 오류 발생
12718정성태7/19/202117311개발 환경 구성: 581. Windows에서 WSL로 파일 복사 시 root 소유권으로 적용되는 문제파일 다운로드1
12717정성태7/18/202118258Windows: 195. robocopy에서 파일의 ADS(Alternate Data Stream) 정보 복사를 제외하는 방법
12716정성태7/17/202118212개발 환경 구성: 580. msbuild의 Exec Task에 robocopy를 사용하는 방법파일 다운로드1
12715정성태7/17/202125663오류 유형: 736. Windows - MySQL zip 파일 버전의 "mysqld --skip-grant-tables" 실행 시 비정상 종료 [1]
12714정성태7/16/202118686오류 유형: 735. VCRUNTIME140.dll, MSVCP140.dll, VCRUNTIME140.dll, VCRUNTIME140_1.dll이 없어 exe 실행이 안 되는 경우
12713정성태7/16/202121663.NET Framework: 1077. C# - 동기 방식이면서 비동기 규약을 따르게 만드는 Task.FromResult파일 다운로드1
12712정성태7/15/202119917개발 환경 구성: 579. Azure - 리눅스 호스팅의 Site Extension 제작 방법
12711정성태7/15/202118681개발 환경 구성: 578. Azure - Java Web App Service를 위한 Site Extension 제작 방법
12710정성태7/15/202123634개발 환경 구성: 577. MQTT - emqx.io 서비스 소개
12709정성태7/14/202118613Linux: 42. 실행 중인 docker 컨테이너에 대한 구동 시점의 docker run 명령어를 확인하는 방법
12708정성태7/14/202123414Linux: 41. 리눅스 환경에서 디스크 용량 부족 시 원인 분석 방법
12707정성태7/14/202188791오류 유형: 734. MySQL - Authentication method 'caching_sha2_password' not supported by any of the available plugins.
... 46  47  48  49  50  51  [52]  53  54  55  56  57  58  59  60  ...