nim-nat-mapper/nat_mapper/tinyupnp.nim
2022-09-15 13:45:09 +02:00

348 lines
11 KiB
Nim

import std/[strutils, options, sequtils, uri, sets, tables]
import std/[xmltree, xmlparser]
import pkg/[
chronos, chronos/apps/http/httpclient,
stew/byteutils,
chronicles
]
import ./common
export common
when (NimMajor, NimMinor) < (1, 4):
{.push raises: [Defect].}
else:
{.push raises: [].}
logScope:
topics = "tinyupnp"
const
upnpServiceTypes = @[
"urn:schemas-upnp-org:device:InternetGatewayDevice:1",
"urn:schemas-upnp-org:device:InternetGatewayDevice:2",
"urn:schemas-upnp-org:service:WANIPConnection:1",
"urn:schemas-upnp-org:service:WANIPConnection:2",
"urn:schemas-upnp-org:service:WANPPPConnection:1"
]
let
ssdpMulticast = initTAddress("239.255.255.250", 1900)
type
TUpnpPortMapping* = object
externalPort*, internalPort*: int
internalClient*: IpAddress
protocol*: NatProtocolType
description*: string
leaseDuration*: Duration
TUpnpGateway = object
controlUri: Uri
serviceType: string
localIp: IpAddress
TUpnpSession* = ref object
discoveryTransp: DatagramTransport
triedPages: HashSet[string]
gateway: TUpnpGateway
gatewayFound: Future[void]
publicIp: IpAddress
proc publicIp*(sess: TUpnpSession): IpAddress =
sess.publicIp
proc localIp*(sess: TUpnpSession): IpAddress =
sess.gateway.localIp
proc same*(a, b: TUpnpPortMapping): bool =
a.externalPort == b.externalPort and
a.internalPort == b.internalPort and
a.protocol == b.protocol and
a.internalClient == b.internalClient
# Soap
type
SoapResponse = object
status: int
body: string
response: Table[string, string]
xmlTree: XmlNode
localIp: IpAddress
proc postSoap(uri: Uri, body, soapAction: string): Future[SoapResponse] {.async.} =
let
session = HttpSessionRef.new()
headers = [
("Content-Type", "text/xml; charset=utf-8"),
("SOAPAction", "\"" & soapAction & "\"")
]
request = HttpClientRequestRef.new(session,
session.getAddress(uri).tryGet(),
MethodPost,
headers = headers,
body = body.toBytes())
res = await request.send()
result.localIp = res.connection.transp.localAddress().address()
result.body = string.fromBytes(await res.getBodyBytes())
result.status = res.status
await res.closeWait()
await request.closeWait()
await session.closeWait()
proc getAllRecur(node: XmlNode, tag: string): seq[XmlNode] =
for child in node:
if child.kind == xnElement:
if cmpIgnoreCase(child.tag, tag) == 0:
result.add child
result &= child.getAllRecur(tag)
proc `[]`(node: XmlNode, tag: string): XmlNode =
if isNil(node): return nil
if node.kind == xnElement:
for child in node:
if child.kind == xnElement and cmpIgnoreCase(child.tag, tag) == 0:
return child
proc getStr(node: XmlNode): string =
if isNil(node): ""
elif node.kind == xnElement and node.len == 1:
node[0].getStr()
elif node.kind == xnText:
node.text
else: ""
proc generateSoapEnveloppe(actionName, actionPath: string, args: Table[string, string]): string =
let envelopeAttrs =
{"s:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/",
"xmlns:s": "http://schemas.xmlsoap.org/soap/envelope/"}.toXmlAttributes
var action = newElement("u:" & actionName)
action.attrs = {"xmlns:u": actionPath}.toXmlAttributes
for argKey, argVal in args:
let param = newElement(argKey)
param.add newText(argVal)
action.add(param)
let
tree = newXmlTree("s:Envelope", [
newXmlTree("s:Body", [
action
])
],
envelopeAttrs)
result = xmlHeader & $tree
proc soapRequest(gateway: TUpnpGateway, actionName: string, args = initTable[string, string]()): Future[SoapResponse] {.async.} =
let request = generateSoapEnveloppe(actionName, gateway.serviceType, args)
result = await postSoap(gateway.controlUri, request, gateway.serviceType & "#" & actionName)
try:
result.xmlTree = parseXml(result.body)["s:Body"]
if not isNil(result.xmlTree):
result.xmlTree = result.xmlTree[0]
for child in result.xmlTree:
result.response[child.tag.toLower()] = child.getStr()
except XmlError as exc:
trace "Cannot parse response XML", resp = result.body
return result
# UPNP
proc addPortMapping*(tupnp: TUpnpSession, mapping: TUpnpPortMapping) {.async.} =
debug "Adding port mapping", mapping
let res = await soapRequest(tupnp.gateway, "AddPortMapping",
{"NewRemoteHost": "",
"NewExternalPort": $mapping.externalPort,
"NewInternalPort": $mapping.internalPort,
"NewInternalClient": $mapping.internalClient,
"NewProtocol": if mapping.protocol == Tcp: "TCP" else: "UDP",
"NewEnabled": "1",
"NewPortMappingDescription": mapping.description,
"NewLeaseDuration": $mapping.leaseDuration.seconds,
}.toTable()
)
proc deletePortMapping*(tupnp: TUpnpSession, mapping: TUpnpPortMapping) {.async.} =
debug "Deleting port mapping", mapping
let res = await soapRequest(tupnp.gateway, "DeletePortMapping",
{
"NewRemoteHost": "",
"NewExternalPort": $mapping.externalPort,
"NewProtocol": if mapping.protocol == Tcp: "TCP" else: "UDP",
}.toTable()
)
proc getAllMappings*(tupnp: TUpnpSession): Future[seq[TUpnpPortMapping]] {.async.} =
debug "Getting all mappings"
for i in 0..50:
let res = await soapRequest(tupnp.gateway,
"GetGenericPortMappingEntry", {"NewPortMappingIndex": $i}.toTable())
if "newinternalport" notin res.response: break
result.add TUpnpPortMapping(
externalPort: parseInt(res.response.getOrDefault("newexternalport")),
internalPort: parseInt(res.response.getOrDefault("newinternalport")),
internalClient: parseIpAddress(res.response.getOrDefault("newinternalclient")),
protocol: if res.response.getOrDefault("newinternalclient") == "TCP": Tcp else: Udp,
description: res.response.getOrDefault("newportmappingdescription"),
leaseDuration: parseInt(res.response.getOrDefault("newleaseduration")).seconds
)
proc getIps(gateway: TUpnpGateway): Future[(IpAddress, IpAddress)] {.async.} =
let
extIpSoapResp = await soapRequest(gateway, "GetExternalIPAddress")
extIp = parseIpAddress(extIpSoapResp.response.getOrDefault("newexternalipaddress"))
return (extIpSoapResp.localIp, extIp)
proc retrievePage(uri: Uri): Future[string] {.async.} =
let session = HttpSessionRef.new()
let resp = await session.fetch(uri)
await session.closeWait()
return string.fromBytes(resp.data)
proc tryGatewayLocation(tupnp: TUpnpSession, location: Uri) {.async.} =
if $location in tupnp.triedPages: return
tupnp.triedPages.incl $location
logScope: location
debug "Trying gateway location"
let
page = await retrievePage(location)
pageXml =
try: parseXml(page)
except XmlError as exc:
debug "Can't decode XML from location", err = exc.msg
return
for service in pageXml.getAllRecur("service"):
# another gateway was found before us
if tupnp.gatewayFound.finished: return
let serviceType = service["serviceType"].getStr()
logScope: serviceType
let formattedServiceTypes =
upnpServiceTypes.filterIt(it.toLower() == serviceType.toLower())
if formattedServiceTypes.len == 0:
trace "Service type not in allowed list"
continue
let
formattedServiceType = formattedServiceTypes[0]
let controlUrl = service["controlUrl"].getStr()
if controlUrl.len == 0:
trace "Cannot find control url"
continue
var gatewayCandidate = TUpnpGateway(
controlUri: location,
serviceType: formattedServiceType
)
gatewayCandidate.controlUri.path = controlUrl
let ips =
try:
await gatewayCandidate.getIps()
except CatchableError as exc:
trace "Cannot retrieve public IP from gateway", msg=exc.msg
continue
# another gateway was found before us
if tupnp.gatewayFound.finished: return
tupnp.publicIp = ips[1]
gatewayCandidate.localIp = ips[0]
info "Found suitable gateway", controlUri = gatewayCandidate.controlUri,
localIp = gatewayCandidate.localIp
tupnp.gateway = gatewayCandidate
tupnp.gatewayFound.complete()
return
#SSDP (discovery)
proc received(tupnp: TUpnpSession) {.async.} =
const locationAnchor = "\r\nLOCATION:"
let
data = string.fromBytes(tupnp.discoveryTransp.getMessage())
dataUpper = data.toUpper()
if dataUpper.startsWith("HTTP/1.1 200 OK"):
let foundAnchor = dataUpper.find(locationAnchor)
if foundAnchor >= 0:
if upnpServiceTypes.anyIt(it in data):
let location = data[foundAnchor + locationAnchor.len .. ^1].split("\r\n", 1)[0]
await tupnp.tryGatewayLocation(parseUri(location.strip()))
else:
trace "SSDP response with useless service type", response = data
else:
trace "SSDP response with invalid Location", response = data
else:
trace "SSDP response without 200 OK", response = data
proc broadastMSearch(tupnp: TUpnpSession) {.async.} =
trace "Broadcasting MSearches"
for possibleSt in upnpServiceTypes:
let body = @[
"M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
"MAN: \"ssdp:discover\"",
"ST: " & possibleSt,
"MX: 2",
"\r\n"].join("\r\n")
await tupnp.discoveryTransp.sendTo(ssdpMulticast, body)
proc setup*(sess: TUpnpSession) {.async.} =
debug "Setting up upnp session"
proc onData(discoveryTransp: DatagramTransport, dat: TransportAddress) {.async.} =
await sess.received()
sess.discoveryTransp = newDatagramTransport(onData)
sess.gatewayFound = newFuture[void]("TUpnp Gateway")
await sess.broadastMSearch()
let foundGateway = await sess.gatewayFound.withTimeout(5.seconds)
await sess.discoveryTransp.closeWait()
if not foundGateway:
info "Couldn't find upnp gateway in time"
sess.gatewayFound.cancel()
raise newException(AsyncTimeoutError, "Cannot find upnp gateway")
proc check*(sess: TUpnpSession): Future[bool] {.async.} =
## Check that the session is still working, or restarts it if required
## Returns true if the public ip or private ip changed
let startIps = (sess.gateway.localIp, sess.publicIp)
let currentIps =
try:
await sess.gateway.getIps()
except CatchableError as exc:
debug "Cannot retrieve IPs, restarting session", err=exc.msg
# can fail
await sess.setup()
(sess.gateway.localIp, sess.publicIp)
if currentIps != startIps:
sess.gateway.localIp = currentIps[0]
sess.publicIp = currentIps[1]
return true
return false
when isMainModule:
let sess = TUpnpSession.new()
waitFor(sess.setup())
let newMapping = TUpnpPortMapping(
externalPort: 5445, internalPort: 5445, internalClient: sess.gateway.localIp,
protocol: Tcp, description: "test binding tinyupnp", leaseDuration: 5.minutes)
waitFor sess.addPortMapping(newMapping)
doAssert (waitFor sess.getAllMappings()).anyIt(it.same(newMapping))
waitFor sess.deletePortMapping(newMapping)
doAssert (waitFor sess.getAllMappings()).countIt(it.same(newMapping)) == 0