diff --git a/cmd/syncthing/main.go b/cmd/syncthing/main.go index 261e75a9..2c2bcd0a 100644 --- a/cmd/syncthing/main.go +++ b/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 diff --git a/discover/discover.go b/discover/discover.go index 8b57e5de..4a2f90ea 100644 --- a/discover/discover.go +++ b/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 { diff --git a/upnp/debug.go b/upnp/debug.go new file mode 100644 index 00000000..c6916a89 --- /dev/null +++ b/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") +) diff --git a/upnp/upnp.go b/upnp/upnp.go new file mode 100644 index 00000000..0a7e8843 --- /dev/null +++ b/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 := ` + + %s + +` + 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 := ` + + %d + %s + %d + %s + 1 + %s + %d + + ` + + 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 := ` + + %d + %s + + ` + + body := fmt.Sprintf(tpl, externalPort, protocol) + return soapRequest(n.serviceURL, "DeletePortMapping", body) +}