package deploy import ( "bytes" "encoding/xml" "fmt" "io" "net" "net/http" "regexp" "strconv" "strings" "time" ) const ssdpAddr = "239.255.255.250:1900" // NATPunchResult reports a UPnP port mapping attempt. type NATPunchResult struct { Success bool ExternalIP string ExternalPort int InternalPort int Method string Message string } var ( reLocation = regexp.MustCompile(`(?i)LOCATION:\s*(\S+)`) reControl = regexp.MustCompile(`(?i)([^<]+)`) reService = regexp.MustCompile(`(?i)urn:schemas-upnp-org:service:(WANIPConnection|WANPPPConnection):1`) ) // PunchUPnP maps externalPort -> internalPort on the local router via IGD UPnP. func PunchUPnP(internalPort, externalPort int, description string) (NATPunchResult, error) { if internalPort <= 0 { internalPort = 8989 } if externalPort <= 0 { externalPort = internalPort } if description == "" { description = "AetherForge" } location, err := discoverIGDLocation(4 * time.Second) if err != nil { return NATPunchResult{Method: "upnp", Message: err.Error()}, err } controlURL, err := resolveWANControlURL(location) if err != nil { return NATPunchResult{Method: "upnp", Message: err.Error()}, err } extIP, err := upnpGetExternalIP(controlURL) if err != nil { return NATPunchResult{Method: "upnp", Message: "GetExternalIP failed: " + err.Error()}, err } if err := upnpAddPortMapping(controlURL, externalPort, internalPort, description); err != nil { return NATPunchResult{ Method: "upnp", ExternalIP: extIP, ExternalPort: externalPort, InternalPort: internalPort, Message: err.Error(), }, err } msg := fmt.Sprintf("UPnP mapped %s:%d -> local :%d (%s)", extIP, externalPort, internalPort, description) return NATPunchResult{ Success: true, ExternalIP: extIP, ExternalPort: externalPort, InternalPort: internalPort, Method: "upnp", Message: msg, }, nil } // CloseUPnP removes a UPnP port mapping. func CloseUPnP(externalPort int) (string, error) { if externalPort <= 0 { return "", fmt.Errorf("external port required") } location, err := discoverIGDLocation(3 * time.Second) if err != nil { return "", err } controlURL, err := resolveWANControlURL(location) if err != nil { return "", err } if err := upnpDeletePortMapping(controlURL, externalPort); err != nil { return "", err } return fmt.Sprintf("UPnP mapping removed for external port %d", externalPort), nil } // GetPublicEndpoint returns WAN IP via UPnP when available. func GetPublicEndpoint() (string, error) { location, err := discoverIGDLocation(3 * time.Second) if err != nil { return "", err } controlURL, err := resolveWANControlURL(location) if err != nil { return "", err } return upnpGetExternalIP(controlURL) } func discoverIGDLocation(timeout time.Duration) (string, error) { conn, err := net.ListenPacket("udp4", ":0") if err != nil { return "", err } defer conn.Close() target, _ := net.ResolveUDPAddr("udp4", ssdpAddr) search := []byte("M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "MAN: \"ssdp:discover\"\r\n" + "MX: 2\r\n" + "ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" + "\r\n") _ = conn.SetDeadline(time.Now().Add(timeout)) if _, err := conn.WriteTo(search, target); err != nil { return "", err } buf := make([]byte, 4096) for { n, _, err := conn.ReadFrom(buf) if err != nil { break } body := string(buf[:n]) if m := reLocation.FindStringSubmatch(body); len(m) == 2 { return strings.TrimSpace(m[1]), nil } } return "", fmt.Errorf("no UPnP IGD found on LAN (SSDP timeout)") } func resolveWANControlURL(deviceLocation string) (string, error) { client := &http.Client{Timeout: 8 * time.Second} resp, err := client.Get(deviceLocation) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } text := string(body) if !reService.MatchString(text) { return "", fmt.Errorf("WANIPConnection service not found in IGD description") } m := reControl.FindStringSubmatch(text) if len(m) != 2 { return "", fmt.Errorf("UPnP controlURL not found") } controlPath := strings.TrimSpace(m[1]) base := deviceLocation if idx := strings.Index(base, "://"); idx >= 0 { if slash := strings.Index(base[idx+3:], "/"); slash >= 0 { base = base[:idx+3+slash] } } if strings.HasPrefix(controlPath, "http") { return controlPath, nil } if !strings.HasPrefix(controlPath, "/") { controlPath = "/" + controlPath } return base + controlPath, nil } func upnpGetExternalIP(controlURL string) (string, error) { body := ` ` resp, err := upnpSOAP(controlURL, "GetExternalIPAddress", body) if err != nil { return "", err } type envelope struct { Body struct { Response struct { IP string `xml:"NewExternalIPAddress"` } `xml:"GetExternalIPAddressResponse"` } `xml:"Body"` } var env envelope if err := xml.Unmarshal(resp, &env); err != nil { return "", err } ip := strings.TrimSpace(env.Body.Response.IP) if ip == "" { return "", fmt.Errorf("empty external IP from router") } return ip, nil } func upnpAddPortMapping(controlURL string, externalPort, internalPort int, description string) error { localIP, err := primaryLocalIPv4() if err != nil { return err } body := fmt.Sprintf(` %d TCP %d %s 1 %s 0 `, externalPort, internalPort, localIP, xmlEscape(description)) _, err = upnpSOAP(controlURL, "AddPortMapping", body) return err } func upnpDeletePortMapping(controlURL string, externalPort int) error { body := fmt.Sprintf(` %d TCP `, externalPort) _, err := upnpSOAP(controlURL, "DeletePortMapping", body) return err } func upnpSOAP(controlURL, action, body string) ([]byte, error) { req, err := http.NewRequest(http.MethodPost, controlURL, bytes.NewBufferString(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) req.Header.Set("SOAPAction", fmt.Sprintf(`"urn:schemas-upnp-org:service:WANIPConnection:1#%s"`, action)) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 || bytes.Contains(data, []byte("errorCode")) { return nil, fmt.Errorf("UPnP SOAP %s failed: %s", action, strings.TrimSpace(string(data))) } return data, nil } func primaryLocalIPv4() (string, error) { conn, err := net.Dial("udp4", "8.8.8.8:80") if err != nil { return "", err } defer conn.Close() addr := conn.LocalAddr().(*net.UDPAddr) return addr.IP.String(), nil } func xmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } // ScanLocalSubnet returns hosts with common service ports open on the local /24. func ScanLocalSubnet(maxHosts int) string { if maxHosts <= 0 { maxHosts = 64 } ips := getLocalIPs() if len(ips) == 0 { return "no local IPv4 interfaces found" } var b strings.Builder seen := 0 for _, ip := range ips { subnet := getSubnet(ip) if subnet == "" { continue } b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip)) for i := 1; i < 255 && seen < maxHosts; i++ { target := fmt.Sprintf("%s.%d", subnet, i) if target == ip { continue } open := probePorts(target, []int{445, 3389, 5985, 22}) if len(open) > 0 { b.WriteString(fmt.Sprintf(" %s open: %s\n", target, strings.Join(intSliceStr(open), ", "))) seen++ } } } if seen == 0 { b.WriteString("No hosts with SMB/RDP/WinRM/SSH responded in quick scan.") } return b.String() } func probePorts(host string, ports []int) []int { var open []int for _, p := range ports { conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(p)), 800*time.Millisecond) if err == nil { conn.Close() open = append(open, p) } } return open } func intSliceStr(v []int) []string { out := make([]string, len(v)) for i, n := range v { out[i] = strconv.Itoa(n) } return out }