[proxy,client] Add --external-ip-map flag for the embedded client (#8185)

* [proxy] Add --external-ip-map flag for the embedded client

The proxy behind a NAT with a forwarded fixed --wg-port could not advertise
its public address as an ICE candidate, so peers fell back to relay. Expose
the agent's external IP map through embed.Options and a proxy flag
(NB_PROXY_EXTERNAL_IP_MAP).

* [proxy] Validate --external-ip-map at startup

The engine drops every external IP mapping when one fails to parse, so a
typo or a space after a comma silently disabled the feature. Move the
agent's validator to shared/externalipmap, run it from the proxy at
startup, and trim entries first.

* [client,proxy] Require an external IP first in --external-ip-map

The engine reads the first component as the external IP, so a mapping like
iface/10.0.0.5 passed validation and then dropped every mapping.
This commit is contained in:
Nicolas Frati
2026-10-15 14:26:50 +02:00
committed by GitHub
parent 547dd1f177
commit 357d8a2a7c
8 changed files with 160 additions and 75 deletions
+70
View File
@@ -0,0 +1,70 @@
// Package externalipmap validates the --external-ip-map flag shared by the
// agent and the proxy.
package externalipmap
import (
"fmt"
"net"
"strings"
)
const flagName = "external-ip-map"
// Validate checks each element is formatted as "IP", "IP/IP" or
// "IP/interface". The engine drops every mapping when one element fails to
// parse, so callers should reject bad input up front.
func Validate(list []string) error {
for _, element := range list {
if element == "" {
return fmt.Errorf("empty string is not a valid input for %s", flagName)
}
subElements := strings.Split(element, "/")
if len(subElements) > 2 {
return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, flagName)
}
if !isValidIP(subElements[0]) {
return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, flagName)
}
if len(subElements) == 2 {
if err := validateInternal(subElements[1]); err != nil {
return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name: %w", subElements[1], flagName, err)
}
}
}
return nil
}
func validateInternal(element string) error {
if isValidIP(element) {
return nil
}
validIface, err := isValidInterface(element)
if err != nil {
return fmt.Errorf("validate network interface name: %w", err)
}
if !validIface {
return fmt.Errorf("invalid IP or network interface name not found")
}
return nil
}
// isValidIP uses net.ParseIP to match the engine's parser exactly.
func isValidIP(ip string) bool {
return net.ParseIP(ip) != nil
}
func isValidInterface(name string) (bool, error) {
netInterfaces, err := net.Interfaces()
if err != nil {
return false, err
}
for _, iface := range netInterfaces {
if iface.Name == name {
return true, nil
}
}
return false, nil
}
+48
View File
@@ -0,0 +1,48 @@
package externalipmap
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidate(t *testing.T) {
ifaces, err := net.Interfaces()
require.NoError(t, err)
require.NotEmpty(t, ifaces, "host must have at least one interface")
iface := ifaces[0].Name
tests := []struct {
name string
input []string
wantErr bool
}{
{name: "nil", input: nil},
{name: "single IP", input: []string{"203.0.113.5"}},
{name: "IP to IP", input: []string{"203.0.113.5/10.0.0.5"}},
{name: "IP to interface", input: []string{"203.0.113.5/" + iface}},
{name: "several", input: []string{"203.0.113.5", "203.0.113.6/10.0.0.6"}},
{name: "empty element", input: []string{""}, wantErr: true},
// The engine rejects padded values, so validation must too.
{name: "space padded", input: []string{"203.0.113.5", " 203.0.113.6"}, wantErr: true},
{name: "not an IP", input: []string{"not-an-ip"}, wantErr: true},
{name: "too many slashes", input: []string{"203.0.113.5/10.0.0.5/10.0.0.6"}, wantErr: true},
{name: "unknown interface", input: []string{"203.0.113.5/nb-no-such-iface0"}, wantErr: true},
{name: "external interface", input: []string{iface}, wantErr: true},
{name: "interface to IP", input: []string{iface + "/10.0.0.5"}, wantErr: true},
{name: "two interfaces", input: []string{iface + "/" + iface}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Validate(tt.input)
if tt.wantErr {
assert.Error(t, err, "input %q should be rejected", tt.input)
return
}
assert.NoError(t, err, "input %q should be accepted", tt.input)
})
}
}