Trying Out the New AWS PrivateLink Feature "Tunnel Endpoints"

Trying Out the New AWS PrivateLink Feature "Tunnel Endpoints"

I tested the new tunnel endpoints in AWS PrivateLink. They give private cross-account access to CIDR-range networks; the source side needs a GENEVE relay, but apps need no changes. I checked HTTP, DNS and CIDR boundaries.
2026.09.19

This page has been translated by machine translation. View original

Introduction

On September 18, 2026, tunnel endpoints were added to AWS PrivateLink.

https://aws.amazon.com/jp/about-aws/whats-new/2026/9/privatelink-tunnel-endpoint/

Assuming a use case where a monitoring or file distribution platform accesses many instances in another account, I shared a CIDR-type Resource Configuration between two accounts in different Organizations using AWS RAM, and tried accessing it through a tunnel endpoint.

What is a tunnel endpoint?

According to the definition in the official documentation, it is an endpoint that privately tunnels to a network segment (a list of CIDR ranges) shared from another VPC or account. On the source side, you prepare a mechanism that runs a TUN device and a GENEVE relay, and access IP addresses within the shared CIDR ranges. The relay can run on the source EC2 instance, or be deployed as a dedicated relay EC2 instance.

https://docs.aws.amazon.com/vpc/latest/privatelink/concepts.html

You create a Resource Gateway and a CIDR-type Resource Configuration on the destination side, and create a tunnel endpoint on the source side. Packets from the source reach the destination side via the following path.

接続元 EC2 → TUN デバイス → GENEVE リレー → Tunnel Endpoint ENI → Resource Gateway → 接続先 EC2

The destination port of GENEVE packets is UDP 6081, the VNI is fixed at 0, and the inner packet is an L3 IP packet.

Note that only the source side can initiate connections. The destination side cannot initiate new connections to the source side; only responses to communication from the source are returned.

Verification configuration

Account Resource Value
Destination (Account A) VPC 10.0.0.0/16
Destination (Account A) Resource Gateway subnets 10.0.2.0/24 (apne1-az4), 10.0.3.0/24 (apne1-az1)
Destination (Account A) EC2 (EC2-A) 10.0.0.152, python3 -m http.server 8080, Amazon Linux 2023
Source (Account B) VPC 10.1.0.0/16
Source (Account B) Tunnel endpoint subnets 10.1.2.0/24 (apne1-az4), 10.1.3.0/24 (apne1-az1)
Source (Account B) EC2 10.1.0.200, Amazon Linux 2023

Destination-side setup (Account A)

I created the Resource Gateway in two private subnets of the destination VPC. The AZs where the Resource Gateway is placed are the AZs where the tunnel endpoint can be placed on the source side.

Resource Gateway creation command
aws vpc-lattice create-resource-gateway \
  --region ap-northeast-1 \
  --name <name> \
  --vpc-identifier <vpc-id> \
  --subnet-ids <dst-prv-az4-id> <dst-prv-az1-id> \
  --security-group-ids <rgw-sg-id>

The range to share is specified in a CIDR-type Resource Configuration.

aws vpc-lattice create-resource-configuration \
  --region ap-northeast-1 \
  --name <name> \
  --type CIDR \
  --resource-gateway-identifier <rgw-id> \
  --protocol TCP_UDP \
  --port-ranges "1-65535" \
  --resource-configuration-definition \
    "cidrResource={cidrRanges=[\"<CIDR>\"]}"

This time I created it with --protocol set to TCP_UDP. Application traffic is TCP only, but DNS queries flow as inner UDP, so I specified TCP_UDP to allow them through.

For connectivity testing, I set the Resource Configuration's --port-ranges to 1-65535 and allowed all traffic in the Resource Gateway's SG as well. The Resource Configuration's --port-ranges and the Resource Gateway's SG are controls at different layers, and in production it is recommended to restrict both to only the necessary ports.

To use a Resource Configuration in another account, share it with AWS RAM. As in this case, when the source account and destination account belong to different AWS Organizations, you need to allow external principals when creating the share and accept the invitation in the source account.

# 接続先側: RAM 共有の作成
aws ram create-resource-share \
  --region ap-northeast-1 \
  --name <share-name> \
  --resource-arns <rcfg-arn> \
  --principals <src-account-id> \
  --allow-external-principals

# 接続元側: 招待の承諾
aws ram accept-resource-share-invitation \
  --region ap-northeast-1 \
  --resource-share-invitation-arn <invitation-arn>

Security group design

In this configuration, the outer GENEVE traffic and the inner application traffic are controlled by different SGs. SGs apply to communication between ENIs on the VPC; the processing of the TUN device and GENEVE relay inside the source EC2 instance is completed within the host, so it is outside the scope of SGs.

[接続元 EC2 ホスト内]  ← SG 対象外
  アプリ → tun0 → GENEVE リレー
        ↓(UDP 6081 で送出)
────────────────────────────────
[VPC 上の通信]  ← ここから SG が効く

外側: GENEVE(UDP 6081)
  接続元 EC2 の ENI [SG: outbound UDP 6081]
    → Tunnel Endpoint ENI [SG: inbound UDP 6081]

内側: アプリケーション通信(例: TCP 8080)
  Resource Gateway の ENI [SG: outbound TCP 8080]
    → 接続先 EC2 の ENI [SG: inbound TCP 8080]

The Resource Gateway's SG is stateful and has rules for both directions, but the rules that take effect on this path are the outbound ones (Resource Gateway → destination resource). "Allow all traffic" in the earlier step means that I did not restrict anything, including this outbound side.

The source of the traffic as seen from the destination EC2 instance is not the source VPC's CIDR but the IP of the Resource Gateway's ENI. Because the Resource Gateway has an ENI in each subnet (AZ) where it is placed, if you allow only a specific IP as the source, some connections may work while connections using a different IP may fail to communicate.

For the source of the destination EC2 instance's SG, you can specify the SG associated with the Resource Gateway. If you do not use SG references, you can specify the CIDRs of the subnets where the Resource Gateway is placed (in this case, 10.0.2.0/24 and 10.0.3.0/24).

Source-side setup (Account B)

Once you accept the RAM invitation, you can see the shared Resource Configuration in the source account. The tunnel endpoint is created as a Tunnel-type VPC endpoint, and you specify the ARN of the Resource Configuration created in the destination account in --resource-configuration-arn.

aws ec2 create-vpc-endpoint \
  --vpc-endpoint-type Tunnel \
  --vpc-id <vpc-id> \
  --subnet-ids <src-prv-az4-id> <src-prv-az1-id> \
  --security-group-ids <tep-sg-id> \
  --resource-configuration-arn arn:aws:vpc-lattice:<region>:<dst-account-id>:resourceconfiguration/<rcfg-id>

The subnets you specify must be placed in the same AZs as the subnets where the Resource Gateway is placed. Because the mapping between AZ names and physical AZs differs per account, cross-account you match them by AZ ID (in this case apne1-az4 / apne1-az1).

The tunnel endpoint's SG needs a rule that allows inbound UDP 6081 from the source EC2's CIDR. This time I allowed UDP 6081 from 10.1.0.0/16.

GENEVE relay

On the source EC2 instance, I ran a TUN device and a userspace relay. The relay is given the source EC2's primary IP (10.1.0.200 in this case) as the source IP. The relay's roles are as follows.

  • Create tun0
  • Direct routes to the destination VPC's 10.0.0.0/16 and the DNS resolver's 169.254.168.253/32 to tun0
  • Prepend an 8-byte GENEVE header (VNI=0, Protocol Type 0x0800) to the L3 packets read from tun0, and send them over UDP 6081 to the tunnel endpoint's ENI
  • Strip the GENEVE header from packets received on UDP 6081 and write them back to tun0

Return packets are also written back to tun0 by the relay, so the kernel's TCP stack can be used as is. No changes are needed on the application side, and you can run curl and dig as they are.

The L2 overlay device created with Linux's ip link add type geneve uses Protocol Type 0x6558 (Transparent Ethernet Bridging) and carries Ethernet frames in the payload. The tunnel endpoint expects 0x0800 (IPv4), which carries IP packets directly in the payload, so it cannot be used as is. In actual measurements too, there was no connectivity with ip link add type geneve, and I confirmed HTTP 200 for the first time with a custom relay specifying 0x0800.

geneve_tun.py (full GENEVE relay script)
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
- Reads L3 IP packets from tun0, wraps in GENEVE (VNI=0, proto=0x0800), sends UDP to tunnel EP
- Receives UDP from tunnel EP, strips GENEVE header, writes inner IP to tun0

Usage (run as root):
  python3 geneve_tun.py <tunnel_ep_ip> <local_src_ip>

  local_src_ip: source IP of this EC2 (its primary private IP, e.g. 10.1.0.200)

Example:
  python3 geneve_tun.py 10.1.2.24 10.1.0.200
"""

import sys, os, struct, socket, fcntl, select

TUNNEL_PORT = 6081
GENEVE_HDR  = struct.pack('!BBH', 0, 0, 0x0800) + b'\x00\x00\x00\x00'  # 8 bytes, VNI=0

# Linux TUN/TAP constants
TUNSETIFF   = 0x400454ca
IFF_TUN     = 0x0001
IFF_NO_PI   = 0x1000

def open_tun(name='tun0'):
    tun = open('/dev/net/tun', 'r+b', buffering=0)
    ifr = struct.pack('16sH', name.encode(), IFF_TUN | IFF_NO_PI)
    fcntl.ioctl(tun, TUNSETIFF, ifr)
    return tun

def setup_route(tun_name, src_ip):
    os.system(f'ip link set {tun_name} up')
    os.system(f'ip route add 10.0.0.0/16 dev {tun_name} src {src_ip}')
    os.system(f'ip route add 169.254.168.253/32 dev {tun_name} src {src_ip}')
    print(f'Routes added: 10.0.0.0/16 and 169.254.168.253/32 -> {tun_name}')

def teardown_route(tun_name):
    os.system(f'ip route del 10.0.0.0/16 dev {tun_name} 2>/dev/null')
    os.system(f'ip route del 169.254.168.253/32 dev {tun_name} 2>/dev/null')
    os.system(f'ip link set {tun_name} down 2>/dev/null')

def main():
    if len(sys.argv) < 3:
        print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <local_src_ip>')
        sys.exit(1)

    tunnel_ep = sys.argv[1]
    src_ip    = sys.argv[2]
    tun_name  = 'tun0'

    print(f'Tunnel EP: {tunnel_ep}:{TUNNEL_PORT}')
    print(f'Source IP: {src_ip}')

    tun = open_tun(tun_name)
    print(f'Opened {tun_name}')

    setup_route(tun_name, src_ip)

    udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp.bind(('0.0.0.0', TUNNEL_PORT))
    print(f'Listening on UDP :{TUNNEL_PORT}')

    print('Relay running. Ctrl+C to stop.')

    try:
        while True:
            rlist, _, _ = select.select([tun, udp], [], [], 1.0)
            for fd in rlist:
                if fd is tun:
                    pkt = tun.read(65536)
                    if pkt:
                        udp.sendto(GENEVE_HDR + pkt, (tunnel_ep, TUNNEL_PORT))
                elif fd is udp:
                    data, addr = udp.recvfrom(65536)
                    if len(data) >= 8:
                        inner = data[8:]  # strip 8-byte GENEVE header
                        tun.write(inner)
    except KeyboardInterrupt:
        print('\nStopping relay...')
    finally:
        teardown_route(tun_name)
        tun.close()
        udp.close()

if __name__ == '__main__':
    main()

Connectivity check

I ran curl -v from the source EC2 instance to the destination EC2 instance.

curl -v http://10.0.0.152:8080/
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25

This is the tcpdump captured on the destination EC2 instance at the same time.

04:19:40 ens5 In  IP 10.0.2.85.12907 > 10.0.0.152.8080: Flags [S]  (SYN)
04:19:40 ens5 Out IP 10.0.0.152.8080 > 10.0.2.85.12907: Flags [P.] length 157: HTTP/1.0 200 OK

The source is the IP of the Resource Gateway's ENI (10.0.2.85). It is not the requesting source EC2 instance (10.1.0.200).

Name resolution with the destination VPC's resolver

I created a Route 53 private hosted zone internal.pl-test.local in the destination VPC, associated it with the destination VPC, and registered 10.0.0.152 in the A record for provider-ec2.internal.pl-test.local. This is the result of querying the destination VPC's resolver from the source EC2 instance.

$ dig +short @169.254.168.253 provider-ec2.internal.pl-test.local A
10.0.0.152

To query the destination VPC's DNS resolver through the tunnel endpoint, specify 169.254.168.253 as the name server. This is a dedicated address defined in the official documentation, and it differs from the normal VPC DNS resolver address 169.254.169.253.

DNS queries are also encapsulated in GENEVE and sent. To allow this inner UDP through, I created the Resource Configuration with TCP_UDP as described above.

Verifying the CIDR range

I prepared tunnel endpoints with 10.0.0.0/16 and 10.0.0.0/24 specified in their respective Resource Configurations, and ran curl against 10.0.0.152 and 10.0.1.218. The only difference between the two sets is the CIDR specified in the Resource Configuration.

curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://<host>:8080/
/16 EP → 10.0.0.152:8080  (CIDR 内)          200  ✅ 期待どおり疎通
/16 EP → 10.0.1.218:8080  (CIDR 内・対照)     200  ✅ 期待どおり疎通
/24 EP → 10.0.0.152:8080  (CIDR 内)          200  ✅ 期待どおり疎通
/24 EP → 10.0.1.218:8080  (CIDR 外)          000  ✅ 期待どおり接続不可(タイムアウト)

✅ indicates that the result was as expected (the HTTP code 000 is the value when curl times out, and here it means the connection to an address outside the CIDR was not possible). 10.0.1.218 is a control host that is inside the CIDR with /16 and outside the CIDR with /24. I confirmed that the same destination is reachable or not depending solely on the specified CIDR.

I was able to confirm HTTP status code 200 only within the CIDR range specified in the Resource Configuration.

Constraints and notes

CIDR-type Resource Configurations are dedicated to tunnel endpoints. They cannot be accessed from resource endpoints, and they cannot be associated with a service network.

https://docs.aws.amazon.com/vpc/latest/privatelink/resource-configuration.html

Private DNS names for the tunnel endpoint itself are not supported. As described above, it is possible to resolve names by querying the destination VPC's resolver, but this is separate from the Private DNS feature for the endpoint itself (the feature that assigns its own DNS name, as with interface endpoints), and the latter is not provided.

https://docs.aws.amazon.com/vpc/latest/privatelink/privatelink-access-cidr-ranges.html

Pricing

The hourly charge and data processing charge for tunnel endpoints are incurred by the source account. The destination account incurs the Resource Gateway's data processing charge. In addition, if you run the GENEVE relay on EC2 or similar on the source side, the compute charges for that EC2 instance and the monitoring costs when monitoring with CloudWatch and the like are also borne by the source. For unit prices and the latest pricing structure, refer to the AWS PrivateLink pricing page.

https://aws.amazon.com/jp/privatelink/pricing/

Conclusion

Tunnel endpoints have been added to AWS PrivateLink. When performing cross-account monitoring or file distribution, the source can access destinations permitted on a per-CIDR basis by placing a Resource Gateway in the VPC where the target resources are located and configuring a TUN device and GENEVE relay on the source EC2 instance. Even if the monitoring targets increase or decrease dynamically, they can be accessed without individual registration as long as they are within the CIDR range.

With conventional PrivateLink, you place an NLB on the destination side to publish the service. With tunnel endpoints, on the other hand, a GENEVE encapsulation/decapsulation mechanism is required on the source side. Since you need to either distribute a relay to each source or operate a shared relay that takes availability and route control into account, I recommend also evaluating the network implementation and operational burden on the source side when adopting it.

https://dev.classmethod.jp/articles/privatelink-tunnel-endpoint-cidr-overlap/

Share this article

AWSのお困り事はクラスメソッドへ