Port Forwarding 101: `Netsh Portproxy` vs Linux Equivalents

# Port Forwarding 101: `netsh portproxy` vs Linux Equivalents

최근 프로젝트에서 사용하는 보드를 원격으로 자리에서 편하게 디버깅하려고 여분 Windows 노트북을 보드와 연결해 두었습니다. Windows 노트북은 사내망(`172.16.10.22`)과 보드망(`192.168.0.x`)에 동시에 붙어 있는 구조였습니다. 다른 사람들도 쉽게 붙을 수 있게 `netsh interface portproxy`로 SSH 점프 포인트를 하나 만들었습니다.

```text
> netsh interface portproxy show all

Listen on ipv4:             Connect to ipv4:
Address         Port        Address         Port
--------------- ----------  --------------- ----------
172.16.10.22    2222        192.168.0.10    22

이렇게 하면 저의 민간한 Windows 계정 정보나 SSH 키를 다른 사람들에게 나눠주지 않아도, 노트북 IP(172.16.10.22)의 2222 포트로만 SSH하면 연결된 보드(192.168.0.10:22)로 바로 들어갈 수 있게 만들 수 있었습니다. 172.16.10.22:2222로 들어온 트래픽을 192.168.0.10:22로 넘기는 룰입니다. portproxy 룰만으로는 부족했고, 방화벽·라우팅 등 추가로 맞춰야 할 조건이 있었습니다. 그래서 내부 동작을 살펴보고, 회사 임베디드 보드(Linux)에서도 같은 셋업이 가능한지 확인했습니다.


1. netsh portproxy가 실제로 하는 일

처음 이를 셋업하고 사용하고 있을때는 이게 커널 라우팅으로 동작하는 줄 으례 짐작했습니다. 그런데 막상 자세히 공부해 보니 라우팅이 아니라 user-mode L4 relay였습니다. 커널이 패킷을 고쳐서 넘기는 게 아니라, iphlpsvc(IP Helper) 서비스가 TCP 소켓 두 개를 열어두고 그 사이 바이트를 그대로 복사하는(pump) 방식이었습니다. 동작 순서는 이렇습니다.

  1. iphlpsvc172.16.10.22:2222에 리스닝 소켓을 bind합니다
  2. 클라이언트가 접속하면 iphlpsvc가 새 소켓을 열어 192.168.0.10:22로 접속합니다
  3. 두 소켓 사이 데이터를 그대로 복사합니다. 클라이언트 눈에는 연결 하나로 보이지만, 실제로는 서로 다른 TCP 연결 두 개입니다
sequenceDiagram participant C as Client participant W as Windows Host
172.16.10.22
(iphlpsvc / netsh portproxy) participant T as Target
192.168.0.10:22 (sshd) C->>W: TCP connect 172.16.10.22:2222 W->>T: new TCP connect 192.168.0.10:22 Note over W: two independent sockets,
byte-copy relay in user space C-->>T: application data flows through the relay

Windows netsh portproxy relay diagram

이 구조를 이해하고 나니 왜 처음에 안 됐는지도 보였습니다. 아래 항목들이 갖춰져 있어야 합니다.

체크포인트 왜 필요한가
iphlpsvc 서비스 실행 (net start iphlpsvc) portproxy 룰이 이 서비스에 종속돼 있습니다
listen 주소(172.16.10.22)가 이 머신의 실제 NIC IP bind()는 로컬 인터페이스에만 걸립니다. 아니면 리스닝 자체가 실패합니다
방화벽 인바운드 허용 (TCP 2222, 올바른 프로파일) New-NetFirewallRule -Direction Inbound -Protocol TCP -LocalPort 2222 -Action Allow
192.168.0.10으로 가는 라우팅 대상 서브넷에 물려있는 NIC나 라우트가 있어야 두 번째 소켓 연결이 성공합니다

IPEnableRouter(커널 라우팅 플래그)는 건드릴 필요가 없었습니다. portproxy는 라우터가 아니라 소켓 릴레이 프록시이기 때문입니다.


2. Linux에서도 되는지 확인 — 방식 A: socat (같은 개념의 user-space relay)

netsh portproxy와 가장 가까운 건 socat이었습니다. 커널 포워딩이나 iptables 없이, 프로세스 하나가 소켓 두 개를 릴레이해줍니다.

socat TCP-LISTEN:2222,bind=172.16.10.22,fork,reuseaddr TCP:192.168.0.10:22
sequenceDiagram participant C as Client participant L as Linux Host
172.16.10.22
(socat process) participant T as Target
192.168.0.10:22 (sshd) C->>L: TCP connect 172.16.10.22:2222 L->>T: new TCP connect 192.168.0.10:22 Note over L: same relay concept as netsh portproxy —
no ip_forward, no iptables needed C-->>T: application data flows through the relay

Linux socat relay diagram

S32G274A 같은 임베디드 리눅스 보드에서 상시로 띄워야 한다면 systemd unit 하나만 만들면 됐습니다.

# /etc/systemd/system/ssh-portproxy.service
[Unit]
Description=SSH port relay 172.16.10.22:2222 -> 192.168.0.10:22
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/socat TCP-LISTEN:2222,bind=172.16.10.22,fork,reuseaddr TCP:192.168.0.10:22
Restart=on-failure

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now ssh-portproxy

ssh -L/-R 포트포워딩도 같은 계열(user-space relay)이지만, SSH 세션이 끊기면 같이 죽습니다. 상시 서비스라면 socat + systemd가 더 맞았습니다.


3. Linux 방식 B: iptables/nftables DNAT (커널 레벨 포워딩)

추가로 확인한 방식인데, netsh portproxy보다는 실제 NAT 라우터에 가까웠습니다. 새 소켓을 만드는 게 아니라 커널이 패킷의 목적지 주소/포트를 그 자리에서 고쳐서(rewrite) 그대로 넘깁니다. 클라이언트와 타겟 사이가 하나의 연속된 TCP 연결이라는 점이 앞의 방식과 가장 큰 차이였습니다.

# 커널 포워딩 활성화 (필수)
sysctl -w net.ipv4.ip_forward=1

# PREROUTING: 목적지를 실제 타겟으로 재작성
iptables -t nat -A PREROUTING -p tcp -d 172.16.10.22 --dport 2222 \
  -j DNAT --to-destination 192.168.0.10:22

# FORWARD: 재작성된 패킷이 지나가도록 허용
iptables -A FORWARD -p tcp -d 192.168.0.10 --dport 22 -j ACCEPT

# POSTROUTING: 리턴 트래픽이 제대로 돌아오게 하려면 (라우팅 구조에 따라 필요)
iptables -t nat -A POSTROUTING -p tcp -d 192.168.0.10 --dport 22 -j MASQUERADE
flowchart LR C[Client] -->|"dst=172.16.10.22:2222"| P["PREROUTING / DNAT
dst → 192.168.0.10:22"] P --> F["FORWARD chain
net.ipv4.ip_forward=1"] F --> M["POSTROUTING / MASQUERADE
(조건부)"] M -->|"same conn, header rewritten"| T[Target 192.168.0.10:22] T -.->|"return traffic, rewritten again"| C

Linux iptables DNAT diagram

MASQUERADE는 매번 필요하진 않았습니다. 타겟(192.168.0.10)이 리턴 패킷을 다시 이 라우터로 보낼 수 있는 구조(예: 기본 게이트웨이가 이 라우터)라면 생략해도 됩니다. 확실하지 않으면 넣어 두는 편이 안전했습니다.


4. 한눈에 비교

netsh portproxy (Windows) socat (Linux) iptables DNAT (Linux)
계층 User-space, L4 소켓 릴레이 User-space, L4 소켓 릴레이 커널, 패킷 레벨 NAT
TCP 연결 개수 2개 (독립) 2개 (독립) 1개 (헤더만 재작성)
ip_forward 필요? 불필요 불필요 필요
리스닝 IP 요건 로컬 NIC에 bind된 실제 IP 로컬 NIC에 bind된 실제 IP 없음 (패킷 가로채기라 bind 불필요)
적합한 상황 단일 서비스 점프/릴레이 단일 서비스 점프/릴레이, 데몬화 쉬움 여러 포트/서비스를 라우터처럼 통째로 포워딩

5. 트러블슈팅 체크리스트

작업하면서 안 될 때마다 아래 순서대로 확인했습니다.

  1. 리스닝이 실제로 떠 있는지 — netstat -an | findstr 2222 또는 Get-NetTCPConnection -LocalPort 2222 (Windows) / ss -tlnp | grep 2222 (Linux)
  2. 방화벽이 해당 포트/프로파일을 막고 있는지
  3. 릴레이 호스트에서 타겟으로 라우팅이 되는지 — ping 192.168.0.10, route print / ip route
  4. (iptables DNAT만) net.ipv4.ip_forward가 1인지, 리턴 경로에 MASQUERADE가 필요한 구조인지
  5. 클라이언트 쪽에서 Test-NetConnection 172.16.10.22 -Port 2222 또는 nc -zv 172.16.10.22 2222로 최종 확인