浏览代码

UPnP Port Mapping (fixes #79)

Jakob Borg 11 年之前
父节点
当前提交
9fb60d6935
共有 4 个文件被更改,包括 342 次插入6 次删除
  1. 38 4
      cmd/syncthing/main.go
  2. 14 2
      discover/discover.go
  3. 12 0
      upnp/debug.go
  4. 278 0
      upnp/upnp.go

+ 38 - 4
cmd/syncthing/main.go

@@ -15,11 +15,13 @@ import (
 	"runtime"
 	"runtime/debug"
 	"runtime/pprof"
+	"strconv"
 	"strings"
 	"time"
 
 	"github.com/calmh/syncthing/discover"
 	"github.com/calmh/syncthing/protocol"
+	"github.com/calmh/syncthing/upnp"
 	"github.com/juju/ratelimit"
 )
 
@@ -57,6 +59,7 @@ const (
                - "net"      (connecting and disconnecting, network messages)
                - "pull"     (file pull activity)
                - "scanner"  (the file change scanner)
+               - "upnp"     (the upnp port mapper)
 
  STCPUPROFILE  Write CPU profile to the specified file.`
 )
@@ -228,8 +231,39 @@ func main() {
 	m.ScanRepos()
 	m.SaveIndexes(confDir)
 
+	// UPnP
+
+	var externalPort = 0
+	if len(cfg.Options.ListenAddress) == 1 {
+		_, portStr, err := net.SplitHostPort(cfg.Options.ListenAddress[0])
+		if err != nil {
+			warnln(err)
+		} else {
+			// Set up incoming port forwarding, if necessary and possible
+			port, _ := strconv.Atoi(portStr)
+			igd, err := upnp.Discover()
+			if err == nil {
+				for i := 0; i < 10; i++ {
+					err := igd.AddPortMapping(upnp.TCP, port+i, port, "syncthing", 0)
+					if err == nil {
+						externalPort = port + i
+						infoln("Created UPnP port mapping - external port", externalPort)
+						break
+					}
+				}
+				if externalPort == 0 {
+					warnln("Failed to create UPnP port mapping")
+				}
+			} else {
+				infof("No UPnP IGD device found, no port mapping created (%v)", err)
+			}
+		}
+	} else {
+		warnln("Multiple listening addresses; not attempting UPnP port mapping")
+	}
+
 	// Routine to connect out to configured nodes
-	discoverer = discovery()
+	discoverer = discovery(externalPort)
 	go listenConnect(myID, m, tlsCfg)
 
 	for _, repo := range cfg.Repositories {
@@ -468,7 +502,7 @@ next:
 	}
 }
 
-func discovery() *discover.Discoverer {
+func discovery(extPort int) *discover.Discoverer {
 	disc, err := discover.NewDiscoverer(myID, cfg.Options.ListenAddress)
 	if err != nil {
 		warnf("No discovery possible (%v)", err)
@@ -481,8 +515,8 @@ func discovery() *discover.Discoverer {
 	}
 
 	if cfg.Options.GlobalAnnEnabled {
-		infoln("Sending external discovery announcements")
-		disc.StartGlobal(cfg.Options.GlobalAnnServer)
+		infoln("Sending global discovery announcements")
+		disc.StartGlobal(cfg.Options.GlobalAnnServer, uint16(extPort))
 	}
 
 	return disc

+ 14 - 2
discover/discover.go

@@ -27,6 +27,7 @@ type Discoverer struct {
 	registry         map[string][]string
 	registryLock     sync.RWMutex
 	extServer        string
+	extPort          uint16
 	localBcastTick   <-chan time.Time
 	forcedBcastTick  chan time.Time
 	extAnnounceOK    bool
@@ -63,8 +64,9 @@ func (d *Discoverer) StartLocal() {
 	go d.sendLocalAnnouncements()
 }
 
-func (d *Discoverer) StartGlobal(server string) {
+func (d *Discoverer) StartGlobal(server string, extPort uint16) {
 	d.extServer = server
+	d.extPort = extPort
 	go d.sendExternalAnnouncements()
 }
 
@@ -126,7 +128,17 @@ func (d *Discoverer) sendExternalAnnouncements() {
 		return
 	}
 
-	var buf = d.announcementPkt()
+	var buf []byte
+	if d.extPort != 0 {
+		var pkt = AnnounceV2{
+			Magic:     AnnouncementMagicV2,
+			NodeID:    d.myID,
+			Addresses: []Address{{Port: d.extPort}},
+		}
+		buf = pkt.MarshalXDR()
+	} else {
+		buf = d.announcementPkt()
+	}
 	var errCounter = 0
 
 	for errCounter < maxErrors {

+ 12 - 0
upnp/debug.go

@@ -0,0 +1,12 @@
+package upnp
+
+import (
+	"log"
+	"os"
+	"strings"
+)
+
+var (
+	dlog  = log.New(os.Stderr, "upnp: ", log.Lmicroseconds|log.Lshortfile)
+	debug = strings.Contains(os.Getenv("STTRACE"), "upnp")
+)

+ 278 - 0
upnp/upnp.go

@@ -0,0 +1,278 @@
+// Package upnp implements UPnP Internet Gateway upnpDevice port mappings
+package upnp
+
+// Adapted from https://github.com/jackpal/Taipei-Torrent/blob/dd88a8bfac6431c01d959ce3c745e74b8a911793/IGD.go
+// Copyright (c) 2010 Jack Palevich (https://github.com/jackpal/Taipei-Torrent/blob/dd88a8bfac6431c01d959ce3c745e74b8a911793/LICENSE)
+// Copyright (c) 2014 Jakob Borg
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+type IGD struct {
+	serviceURL string
+	ourIP      string
+}
+
+type Protocol string
+
+const (
+	TCP Protocol = "TCP"
+	UDP          = "UDP"
+)
+
+type upnpService struct {
+	ServiceType string `xml:"serviceType"`
+	ControlURL  string `xml:"controlURL"`
+}
+
+type upnpDevice struct {
+	DeviceType string        `xml:"deviceType"`
+	Devices    []upnpDevice  `xml:"deviceList>device"`
+	Services   []upnpService `xml:"serviceList>service"`
+}
+
+type upnpRoot struct {
+	Device upnpDevice `xml:"device"`
+}
+
+func Discover() (*IGD, error) {
+	ssdp := &net.UDPAddr{IP: []byte{239, 255, 255, 250}, Port: 1900}
+
+	socket, err := net.ListenUDP("udp4", &net.UDPAddr{})
+	if err != nil {
+		return nil, err
+	}
+	defer socket.Close()
+
+	err = socket.SetDeadline(time.Now().Add(3 * time.Second))
+	if err != nil {
+		return nil, err
+	}
+
+	search := []byte(`
+M-SEARCH * HTTP/1.1
+Host: 239.255.255.250:1900
+St: urn:schemas-upnp-org:device:InternetGatewayDevice:1
+Man: "ssdp:discover"
+Mx: 3
+
+`)
+
+	_, err = socket.WriteTo(search, ssdp)
+	if err != nil {
+		return nil, err
+	}
+
+	resp := make([]byte, 1500)
+	n, _, err := socket.ReadFrom(resp)
+	if err != nil {
+		return nil, err
+	}
+
+	if debug {
+		dlog.Println(string(resp[:n]))
+	}
+
+	reader := bufio.NewReader(bytes.NewBuffer(resp[:n]))
+	request := &http.Request{}
+	response, err := http.ReadResponse(reader, request)
+
+	if response.Header.Get("St") != "urn:schemas-upnp-org:device:InternetGatewayDevice:1" {
+		return nil, errors.New("no igd")
+	}
+
+	locURL := response.Header.Get("Location")
+	if locURL == "" {
+		return nil, errors.New("no location")
+	}
+
+	serviceURL, err := getServiceURL(locURL)
+	if err != nil {
+		return nil, err
+	}
+
+	// Figure out our IP number, on the network used to reach the IGD. We
+	// do this in a fairly roundabout way by connecting to the IGD and
+	// checking the address of the local end of the socket. I'm open to
+	// suggestions on a better way to do this...
+	ourIP, err := localIP(locURL)
+	if err != nil {
+		return nil, err
+	}
+
+	igd := &IGD{
+		serviceURL: serviceURL,
+		ourIP:      ourIP,
+	}
+	return igd, nil
+}
+
+func localIP(tgt string) (string, error) {
+	url, err := url.Parse(tgt)
+	if err != nil {
+		return "", err
+	}
+
+	conn, err := net.Dial("tcp", url.Host)
+	if err != nil {
+		return "", err
+	}
+	defer conn.Close()
+
+	ourIP, _, err := net.SplitHostPort(conn.LocalAddr().String())
+	if err != nil {
+		return "", err
+	}
+
+	return ourIP, nil
+}
+
+func getChildDevice(d upnpDevice, deviceType string) (upnpDevice, bool) {
+	for _, dev := range d.Devices {
+		if dev.DeviceType == deviceType {
+			return dev, true
+		}
+	}
+	return upnpDevice{}, false
+}
+
+func getChildService(d upnpDevice, serviceType string) (upnpService, bool) {
+	for _, svc := range d.Services {
+		if svc.ServiceType == serviceType {
+			return svc, true
+		}
+	}
+	return upnpService{}, false
+}
+
+func getServiceURL(rootURL string) (string, error) {
+	r, err := http.Get(rootURL)
+	if err != nil {
+		return "", err
+	}
+	defer r.Body.Close()
+	if r.StatusCode >= 400 {
+		return "", errors.New(r.Status)
+	}
+
+	var upnpRoot upnpRoot
+	err = xml.NewDecoder(r.Body).Decode(&upnpRoot)
+	if err != nil {
+		return "", err
+	}
+
+	dev := upnpRoot.Device
+	if dev.DeviceType != "urn:schemas-upnp-org:device:InternetGatewayDevice:1" {
+		return "", errors.New("No InternetGatewayDevice")
+	}
+
+	dev, ok := getChildDevice(dev, "urn:schemas-upnp-org:device:WANDevice:1")
+	if !ok {
+		return "", errors.New("No WANDevice")
+	}
+
+	dev, ok = getChildDevice(dev, "urn:schemas-upnp-org:device:WANConnectionDevice:1")
+	if !ok {
+		return "", errors.New("No WANConnectionDevice")
+	}
+
+	svc, ok := getChildService(dev, "urn:schemas-upnp-org:service:WANIPConnection:1")
+	if !ok {
+		return "", errors.New("No WANIPConnection")
+	}
+
+	if len(svc.ControlURL) == 0 {
+		return "", errors.New("no controlURL")
+	}
+
+	u, _ := url.Parse(rootURL)
+	if svc.ControlURL[0] == '/' {
+		u.Path = svc.ControlURL
+	} else {
+		u.Path += svc.ControlURL
+	}
+	return u.String(), nil
+}
+
+func soapRequest(url, function, message string) error {
+	tpl := `<?xml version="1.0" ?>
+	<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
+	<s:Body>%s</s:Body>
+	</s:Envelope>
+`
+	body := fmt.Sprintf(tpl, message)
+
+	req, err := http.NewRequest("POST", url, strings.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
+	req.Header.Set("User-Agent", "syncthing/1.0")
+	req.Header.Set("SOAPAction", `"urn:schemas-upnp-org:service:WANIPConnection:1#`+function+`"`)
+	req.Header.Set("Connection", "Close")
+	req.Header.Set("Cache-Control", "no-cache")
+	req.Header.Set("Pragma", "no-cache")
+
+	if debug {
+		dlog.Println(req.Header.Get("SOAPAction"))
+		dlog.Println(body)
+	}
+
+	r, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+
+	if debug {
+		resp, _ := ioutil.ReadAll(r.Body)
+		dlog.Println(string(resp))
+	}
+
+	r.Body.Close()
+
+	if r.StatusCode >= 400 {
+		return errors.New(function + ": " + r.Status)
+	}
+
+	return nil
+}
+
+func (n *IGD) AddPortMapping(protocol Protocol, externalPort, internalPort int, description string, timeout int) error {
+	tpl := `<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
+	<NewRemoteHost></NewRemoteHost>
+	<NewExternalPort>%d</NewExternalPort>
+	<NewProtocol>%s</NewProtocol>
+	<NewInternalPort>%d</NewInternalPort>
+	<NewInternalClient>%s</NewInternalClient>
+	<NewEnabled>1</NewEnabled>
+	<NewPortMappingDescription>%s</NewPortMappingDescription>
+	<NewLeaseDuration>%d</NewLeaseDuration>
+	</u:AddPortMapping>
+	`
+
+	body := fmt.Sprintf(tpl, externalPort, protocol, internalPort, n.ourIP, description, timeout)
+	return soapRequest(n.serviceURL, "AddPortMapping", body)
+}
+
+func (n *IGD) DeletePortMapping(protocol Protocol, externalPort int) (err error) {
+	tpl := `<u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
+	<NewRemoteHost></NewRemoteHost>
+	<NewExternalPort>%d</NewExternalPort>
+	<NewProtocol>%s</NewProtocol>
+	</u:DeletePortMapping>
+	`
+
+	body := fmt.Sprintf(tpl, externalPort, protocol)
+	return soapRequest(n.serviceURL, "DeletePortMapping", body)
+}