nim-websock/websock/websock.nim

370 lines
10 KiB
Nim

## nim-websock
## Copyright (c) 2021 Status Research & Development GmbH
## Licensed under either of
## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
## * MIT license ([LICENSE-MIT](LICENSE-MIT))
## at your option.
## This file may not be copied, modified, or distributed except according to
## those terms.
{.push gcsafe, raises: [].}
import std/[tables,
strutils,
strformat,
sequtils,
uri]
import pkg/[chronos,
chronos/apps/http/httptable,
chronos/streams/asyncstream,
chronos/streams/tlsstream,
chronicles,
httputils,
stew/byteutils,
stew/base64,
stew/base10,
nimcrypto/sha]
import ./frame, ./session, /types, ./http, ./extensions/extutils
export session, frame, types, http, httptable
logScope:
topics = "websock ws-server"
type
WSServer* = ref object of WebSocket
protocols: seq[string]
factories: seq[ExtFactory]
func toException(e: cstring): ref WebSocketError =
(ref WebSocketError)(msg: $e)
func contains(extensions: openArray[Ext], extName: string): bool =
for ext in extensions:
if ext.name == extName:
return true
proc getFactory(factories: openArray[ExtFactory], extName: string): ExtFactoryProc =
for n in factories:
if n.name == extName:
return n.factory
proc selectExt(isServer: bool,
extensions: var seq[Ext],
factories: openArray[ExtFactory],
exts: openArray[string]): string {.raises: [WSExtError].} =
var extList: seq[AppExt]
var response = ""
for ext in exts:
# each of "Sec-WebSocket-Extensions" can have multiple
# extensions or fallback extension
if not parseExt(ext, extList):
raise newException(WSExtError, "extension syntax error: " & ext)
for i, ext in extList:
if extensions.contains(ext.name):
# don't accept this fallback if prev ext
# configuration already accepted
trace "extension fallback not accepted", ext=ext.name
continue
# now look for right factory
let factory = factories.getFactory(ext.name)
if factory.isNil:
# no factory? it's ok, just skip it
trace "no extension factory", ext=ext.name
continue
let extRes = factory(isServer, ext.params)
if extRes.isErr:
# cannot create extension because of
# wrong/incompatible params? skip or fallback
trace "skip extension", ext=ext.name, msg=extRes.error
continue
let ext = extRes.get()
doAssert(not ext.isNil)
if i > 0:
# add separator if more than one exts
response.add ", "
response.add ext.toHttpOptions
# finally, accept the extension
trace "extension accepted", ext=ext.name
extensions.add ext
# HTTP response for "Sec-WebSocket-Extensions"
response
proc connect*(
_: type WebSocket,
host: string | TransportAddress,
path: string,
hostName: string = "", # override used when the hostname has been externally resolved
protocols: seq[string] = @[],
factories: seq[ExtFactory] = @[],
hooks: seq[Hook] = @[],
secure = false,
flags: set[TLSFlags] = {},
version = WSDefaultVersion,
frameSize = WSDefaultFrameSize,
onPing: ControlCb = nil,
onPong: ControlCb = nil,
onClose: CloseCb = nil,
rng = HmacDrbgContext.new()): Future[WSSession] {.async.} =
let
key = Base64Pad.encode(WebSecKey.random(rng[]))
hostname = if hostName.len > 0: hostName else: $host
let client = if secure:
await TlsHttpClient.connect(host, tlsFlags = flags, hostName = hostname)
else:
await HttpClient.connect(host)
let headerData = [
("Connection", "Upgrade"),
("Upgrade", "websocket"),
("Cache-Control", "no-cache"),
("Sec-WebSocket-Version", $version),
("Sec-WebSocket-Key", key),
("Host", hostname)]
var headers = HttpTable.init(headerData)
if protocols.len > 0:
headers.add("Sec-WebSocket-Protocol", protocols.join(", "))
var extOffer = ""
for i, f in factories:
if i > 0:
extOffer.add ", "
extOffer.add f.clientOffer
if extOffer.len > 0:
headers.add("Sec-WebSocket-Extensions", extOffer)
for hp in hooks:
if hp.append == nil: continue
let res = hp.append(hp, headers)
if res.isErr:
raise newException(WSHookError,
"Header plugin execution failed: " & res.error)
let response = try:
await client.request(path, headers = headers)
except CatchableError as exc:
trace "Websocket failed during handshake", exc = exc.msg
await client.close()
raise exc
if response.code != Http101.toInt():
raise newException(WSFailedUpgradeError,
&"Server did not reply with a websocket upgrade: " &
&"Header code: {response.code} Header reason: {response.reason} " &
&"Address: {client.address}")
let proto = response.headers.getString("Sec-WebSocket-Protocol")
if proto.len > 0 and protocols.len > 0:
if proto notin protocols:
raise newException(WSFailedUpgradeError,
&"Invalid protocol returned {proto}!")
for hp in hooks:
if hp.verify == nil: continue
let res = await hp.verify(hp, response.headers)
if res.isErr:
raise newException(WSHookError,
"Header verification failed: " & res.error)
var extensions: seq[Ext]
let exts = response.headers.getList("Sec-WebSocket-Extensions")
discard selectExt(false, extensions, factories, exts)
# Client data should be masked.
let session = WSSession(
stream: client.stream,
readyState: ReadyState.Open,
masked: true,
extensions: system.move(extensions),
rng: rng,
frameSize: frameSize,
onPing: onPing,
onPong: onPong,
onClose: onClose)
for ext in session.extensions:
ext.session = session
return session
proc connect*(
_: type WebSocket,
uri: Uri,
protocols: seq[string] = @[],
factories: seq[ExtFactory] = @[],
hooks: seq[Hook] = @[],
flags: set[TLSFlags] = {},
version = WSDefaultVersion,
frameSize = WSDefaultFrameSize,
onPing: ControlCb = nil,
onPong: ControlCb = nil,
onClose: CloseCb = nil,
rng = HmacDrbgContext.new()): Future[WSSession]
{.raises: [WSWrongUriSchemeError].} =
## Create a new websockets client
## using a Uri
##
let secure = case uri.scheme:
of "wss": true
of "ws": false
else:
raise newException(WSWrongUriSchemeError,
"uri scheme has to be 'ws' or 'wss'")
var uri = uri
if uri.port.len <= 0:
uri.port = if secure: "443" else: "80"
return WebSocket.connect(
host = uri.hostname & ":" & uri.port,
path = uri.path,
hostName = uri.hostname,
protocols = protocols,
factories = factories,
hooks = hooks,
secure = secure,
flags = flags,
version = version,
frameSize = frameSize,
onPing = onPing,
onPong = onPong,
onClose = onClose,
rng = rng)
proc handleRequest*(
ws: WSServer,
request: HttpRequest,
version: uint = WSDefaultVersion,
hooks: seq[Hook] = @[]): Future[WSSession]
{.
async:
(raises: [
CancelledError,
CatchableError,
WSHandshakeError,
WSProtoMismatchError])
.} =
## Creates a new socket from a request.
##
if not request.headers.contains("Sec-WebSocket-Version"):
raise newException(WSHandshakeError, "Missing version header")
ws.version = Base10.decode(
uint,
request.headers.getString("Sec-WebSocket-Version"))
.tryGet() # this method throws
if ws.version != version:
await request.stream.writer.sendError(Http426)
trace "Websocket version not supported", version = ws.version
raise newException(WSVersionError,
&"Websocket version not supported, Version: {version}")
ws.key = request.headers.getString("Sec-WebSocket-Key").strip()
let wantProtos = if request.headers.contains("Sec-WebSocket-Protocol"):
request.headers.getList("Sec-WebSocket-Protocol")
else:
@[""]
let protos = wantProtos.filterIt(
it in ws.protocols
)
for hp in hooks:
if hp.verify == nil: continue
let res = await hp.verify(hp, request.headers)
if res.isErr:
raise newException(WSHookError,
"Header verification failed: " & res.error)
let
cKey = ws.key & WSGuid
acceptKey = Base64Pad.encode(
sha1.digest(cKey.toOpenArray(0, cKey.high)).data)
var headers = HttpTable.init([
("Connection", "Upgrade"),
("Upgrade", "websocket"),
("Sec-WebSocket-Accept", acceptKey)])
let protocol = if protos.len > 0: protos[0] else: ""
if protocol.len > 0:
headers.add("Sec-WebSocket-Protocol", protocol) # send back the first matching proto
else:
trace "Didn't match any protocol", supported = ws.protocols, requested = wantProtos
# it is possible to have multiple "Sec-WebSocket-Extensions"
let exts = request.headers.getList("Sec-WebSocket-Extensions")
let extResp = selectExt(true, ws.extensions, ws.factories, exts)
if extResp.len > 0:
# send back any accepted extensions
headers.add("Sec-WebSocket-Extensions", extResp)
for hp in hooks:
if hp.append == nil: continue
let res = hp.append(hp, headers)
if res.isErr:
raise newException(WSHookError,
"Header plugin execution failed: " & res.error)
try:
await request.sendResponse(Http101, headers = headers)
except CancelledError as exc:
raise exc
except CatchableError as exc:
raise newException(WSHandshakeError,
"Failed to sent handshake response. Error: " & exc.msg)
let session = WSSession(
readyState: ReadyState.Open,
stream: request.stream,
proto: protocol,
extensions: system.move(ws.extensions),
masked: false,
rng: ws.rng,
frameSize: ws.frameSize,
onPing: ws.onPing,
onPong: ws.onPong,
onClose: ws.onClose)
for ext in session.extensions:
ext.session = session
return session
proc new*(
_: typedesc[WSServer],
protos: openArray[string] = [""],
factories: openArray[ExtFactory] = [],
frameSize = WSDefaultFrameSize,
onPing: ControlCb = nil,
onPong: ControlCb = nil,
onClose: CloseCb = nil,
rng = HmacDrbgContext.new()): WSServer =
return WSServer(
protocols: @protos,
masked: false,
rng: rng,
frameSize: frameSize,
factories: @factories,
onPing: onPing,
onPong: onPong,
onClose: onClose)