项目

通用

个人资料

操作

WebSockets

WebSocket 回显示例

概览

什么是 WebSocket? WebSocket API

lighttpd 可以通过多种方式支持 WebSocket 连接,包括通过 mod_proxymod_cgimod_scgimod_fastcgimod_wstunnel

  • lighttpd 支持 HTTP/1.1 连接中的 Upgrade: websocket
  • lighttpd 支持 HTTP/2 扩展 CONNECT,通过单个 HTTP/2 连接在 HTTP/2 流上使用 :protocol: websocket

出于安全原因并遵循最小惊喜原则,lighttpd 对 WebSocket 的支持默认是特意禁用的。可以配置 lighttpd 动态模块以启用连接升级到 WebSocket 协议。

自 lighttpd 1.4.46 版本起,lighttpd 的 mod_cgimod_proxymod_wstunnel 模块支持 WebSocket。在 lighttpd 1.4.74 版本中,又增加了对 mod_scgimod_fastcgi 这两个动态模块的支持。

本文档演示了配置 lighttpd 模块以运行 WebSocket “回显” 示例应用程序的多种方法。

基本配置

通过 mod_wstunnel 使用 WebSockets

配置 mod_wstunnel
mod_wstunnel 是一个 WebSocket 隧道端点,用于终止来自客户端的 WebSocket 隧道。mod_wstunnel 会解码 WebSocket 帧,然后将数据(不带 WebSocket 帧)传递给后端,而在相反方向上,它会在将响应发送给客户端之前,将后端响应编码成 WebSocket 帧。

通过 mod_proxy 使用 WebSockets

配置 mod_proxy 并启用 proxy.header += ("upgrade" => "enable")
从 lighttpd 1.4.74 及更高版本开始,"upgrade" => "enable" 也是 proxy.server 中的一个主机选项。
当启用升级时,mod_proxy 可以作为 WebSocket 连接的反向代理。

通过 mod_cgi 使用 WebSockets

配置 mod_cgi 并启用 cgi.upgrade = "enable"
mod_cgi 允许目标程序升级使用 WebSocket 协议。

通过 mod_scgi 使用 WebSockets

配置 mod_scgi 并在 scgi.server 中为每个主机添加 "upgrade" => "enable" 选项(lighttpd 1.4.74 及更高版本)
mod_scgi 允许目标程序升级使用 WebSocket 协议。

通过 mod_fastcgi 使用 WebSockets

配置 mod_fastcgi 并在 fastcgi.server 中为每个主机添加 "upgrade" => "enable" 选项(lighttpd 1.4.74 及更高版本)
mod_fastcgi 允许目标程序升级使用 WebSocket 协议。
虽然 lighttpd 目前不支持在单个 FastCGI 连接上多路复用多个请求,但 FastCGI 协议本身是允许的。即使请求升级到使用 WebSocket 协议后,lighttpd 的 mod_fastcgi 仍然会从后端发送和接收 FastCGI 数据包。WebSocket 帧再次被封装到 FastCGI 数据包中。因此,这可能不如使用其他支持升级到 WebSocket 协议的 lighttpd 模块效率高。

示例:ws_echo.py

ws_echo.py - 简单的“回显”WebSocket 服务器 CGI 示例程序

将文件安装到文档根目录和 ws/ 子目录中
<docroot>/ws_echo.html
<docroot>/ws/ws_echo.py
如果使用 HTTPS,请修改 ws_echo.html 中的 URL,将 ws:// 改为 wss://。
var host = "ws://" + window.location.hostname + "/ws/ws_echo.py";

<docroot>/ws_echo.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <!-- favicon: none -->
    <link rel="icon" href="data:,">
    <!--
    <meta
      http-equiv="Content-Security-Policy" 
      content="default-src 'self' 'unsafe-inline'" />
    -->

    <title>websocket echo client</title>

    <style>
        #output {
            border: solid 1px #000;
        }
    </style>
</head>
<body>

<form id="form" accept-charset=utf-8>
    <input type="text" id="message">
    <button type="submit">Send</button>
</form>

<hr>

<div id="output"></div>

<script>

    var inputBox = document.getElementById("message");
    var output = document.getElementById("output");
    var form = document.getElementById("form");
    try {
        var host = "ws://" + window.location.hostname + "/ws/ws_echo.py";
        //var host = "wss://" + window.location.hostname + "/ws/ws_echo.py";
        //var host = "ws://" + window.location.hostname + ":8080/ws/ws_echo.py";
        //var host = "wss://" + window.location.hostname + ":8443/ws/ws_echo.py";
        console.log("Host:", host);

        var s = new WebSocket(host);

        s.onopen = function (e) {
            console.log("Socket opened.");
        };

        s.onclose = function (e) {
            console.log("Socket closed.");
        };

        s.onmessage = function (e) {
            console.log("Socket message:", e.data);
            var p = document.createElement("p");
            p.innerHTML = e.data;
            output.appendChild(p);
        };

        s.onerror = function (e) {
            console.log("Socket error:", e);
        };

    } catch (ex) {
        console.log("Socket exception:", ex);
    }
    form.addEventListener("submit", function (e) {
        e.preventDefault();
        s.send(inputBox.value);
        inputBox.value = "";
    }, false)
</script>

</body>
</html>

<docroot>/ws/ws_echo.py

#!/usr/bin/env python3
#
# ws_echo.py - websocket echo; reflect back to peer the frames sent by peer
#
# Copyright(c) 2023 Glenn Strauss gstrauss()gluelogic.com  All rights reserved
# License: BSD 3-clause (same as lighttpd)

import struct
import hashlib
import base64

def ws_upgrade(rd, wr, env):
    if env.get('HTTP_UPGRADE', '') != 'websocket':
        wr.write("Status: 204\n\n".encode())
        return False

    # (throws KeyError exception if any of these env vars are not set)
    origin = env['HTTP_ORIGIN']
    scheme = "wss" if env['REQUEST_SCHEME'] == "https" else "ws" 
    host   = env['HTTP_HOST']
    urlpath= env['SCRIPT_NAME']
    key    = env['HTTP_SEC_WEBSOCKET_KEY'].encode('utf-8')
    GUID   = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 
    digest = str(base64.b64encode(hashlib.sha1(key + GUID).digest()), 'utf-8')

    wr.write(("Status: 101\n" 
           +  "Upgrade: WebSocket\n" 
           +  "Connection: Upgrade\n" 
           + f"Sec-WebSocket-Origin: {origin}\n" 
           + f"Sec-WebSocket-Location: {scheme}://{host}{urlpath}\n" 
           + f"Sec-WebSocket-Accept: {digest}\n\n" 
    ).encode())
    return True

## +-+-+-+-+-------++-+-------------+-------------------------------+
## |F|R|R|R| opcode||M| Payload len |    Extended payload length    |
## |I|S|S|S|  (4)  ||A|     (7)     |             (16/63)           |
## |N|V|V|V|       ||S|             |   (if payload len==126/127)   |
## | |1|2|3|       ||K|             |                               |
## +-+-+-+-+-------++-+-------------+ - - - - - - - - - - - - - - - +
## +-+-+-+-+--------------------------------------------------------+
## |     Extended payload length continued, if payload len == 127   |
## + - - - - - - - - - - - - - - - +--------------------------------+
## + - - - - - - - - - - - - - - - +-------------------------------+
## |                               |Masking-key, if MASK set to 1  |
## +-------------------------------+-------------------------------+
## | Masking-key (continued)       |          Payload Data         |
## +-------------------------------- - - - - - - - - - - - - - - - +
## :                     Payload Data continued ...                :
## + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
## |                     Payload Data continued ...                |
## +---------------------------------------------------------------+

def ws_recv_and_echo(rd, wr):
    # (note: assumes well-formed websocket frame; not validating or paranoid)
    # (note: inefficient small reads rather than read/buffering larger blocks)
    # (note: no buffering; program exits if partial read of frame header)
    # (note: no buffering; program loses state if partial read of frame data
    #        and subsequently will likely thrown an exception)
    # (tl;dr: this toy program is sufficient for a demo of frames with small
    #        data payloads, but probably nothing more)

    data = rd.read(2)
    if len(data) == 0: # EOF
        ws_send(wr, b'1000', opcode=0x8) # CLOSE "1000 = Normal Closure" 
        return False
    head1, head2 = struct.unpack('!BB', data)
    fin = bool(head1 & 0b10000000)
    opcode = head1 & 0b00001111
    if opcode == 0x8: # CLOSE (0x8)
        # (note: not reading and parsing close reason sent by peer)
        ws_send(wr, b'1000', opcode=opcode) # "1000 = Normal Closure" 
        return False

    length = head2 & 0b01111111
    if length == 126:
        data = rd.read(2)
        length, = struct.unpack('!H', data)
    elif length == 127:
        data = rd.read(8)
        length, = struct.unpack('!Q', data)

    mask_bit = bool(head2 & 0b10000000)
    masking_key = rd.read(4) if mask_bit else False

    data = bytearray(rd.read(length)) if length else b''

    if opcode == 0xA: # PONG
        # receive and ignore response to PING
        return True

    if opcode == 0x9: # PING
        # reflect PING back to peer as PONG
        opcode = 0xA  # PONG

    if mask_bit:
        # unmask data
        for i in range(0, len(data)):
            data[i] ^= masking_key[i % 4]

    # echo data back to peer using same masking_key
    # (note: not validating continuation frame follows frame without fin set)
    #if opcode == 0x0: # CONTINUATION
    #if opcode == 0x1: # TEXT
    #if opcode == 0x2: # BINARY
    #if opcode == 0xA: # PONG (from PING above)
    ws_send(wr, ''.join(map(chr, data)).encode(),
            opcode=opcode, fin=fin, masking_key=masking_key)
    return True

def ws_send(wr, data, opcode=1, fin=True, masking_key=False):
    if 0x3 <= opcode <= 0x7 or 0xB <= opcode:
        raise ValueError('Invalid opcode')
    header = struct.pack('!B', ((bool(fin) << 7) | opcode))
    mask_bit = (1 << 7) if masking_key else 0
    length = len(data)
    if length < 126:
        header += struct.pack('!B', (mask_bit|length))
    elif length < (1 << 16):
        header += struct.pack('!B', (mask_bit|126)) + struct.pack('!H', length)
    elif length < (1 << 63):
        header += struct.pack('!B', (mask_bit|127)) + struct.pack('!Q', length)
    else:
        raise ValueError('Data too large')

    if mask_bit:
        # mask data
        header += masking_key
        data = bytearray(data)
        for i in range(0, length):
            data[i] ^= masking_key[i % 4]

    wr.write(header + data)

def ws_echo_app(rd, wr, env):
    if not ws_upgrade(rd, wr, env):
        return

    while True:
        if not ws_recv_and_echo(rd, wr):
            break

import os
import sys

#import errno
#import io
#from traceback import format_exc as format
#import logging
#logger = logging.getLogger('mylogger')
#logger.setLevel(logging.WARNING)

if __name__ == '__main__':
    try:
        sys.stdin  = os.fdopen(sys.stdin.fileno(),  'r+b', buffering=0)
        sys.stdout = os.fdopen(sys.stdout.fileno(), 'r+b', buffering=0)
        ws_echo_app(sys.stdin, sys.stdout, os.environ)
    #except IOError as e:
    #    if e.errno != errno.EPIPE:
    #        raise e
    except KeyboardInterrupt: # Ctrl-C or SIGINT
        try:
            sys.exit(130)
        except SystemExit:
            os._exit(130)
    except Exception as e:
        err = e.args[0]
        #logger.warning("err:{}\n".format(str(err)))

示例:配置 lighttpd 以在 /ws/ 下运行 CGI 程序
server.modules += ("mod_cgi")
$HTTP["url"] =^ "/ws/" {
  cgi.assign = ("" => "")
  cgi.upgrade = 1
  #cgi.limits = ( "read-timeout" => 60, "write-timeout" => 60 )
}
#server.max-read-idle := 60
#server.max-write-idle := 60
示例:配置 lighttpd 以在 /ws/ 下运行 SCGI 程序

使用 scgi-cgi 运行 ws_echo.py

server.modules += ("mod_scgi")
scgi.server = ("/ws/" =>
    ((
        "socket" => "/tmp/scgi-ws.sock", # should use more secure location
        "bin-path" => "/usr/local/bin/scgi-cgi"  # modify path to scgi-cgi
        "check-local" => "disable",
        "min-procs" => 1,
        "max-procs" => 1,
        "upgrade" => 1
        #"read-timeout" => 60,
        #"write-timeout" => 60,
    ))
)
#server.max-read-idle := 60
#server.max-write-idle := 60
示例:配置 lighttpd 以在 /ws/ 下运行 FastCGI 程序

使用 fcgi-cgi 运行 ws_echo.py

server.modules += ("mod_fastcgi")
fastcgi.server = ("/ws/" =>
    ((
        "socket" => "/tmp/fcgi-ws.sock", # should use more secure location
        "bin-path" => "/usr/local/bin/fcgi-cgi"  # modify path to fcgi-cgi
        "check-local" => "disable",
        "min-procs" => 1,
        "max-procs" => 1,
        "upgrade" => 1
        #"read-timeout" => 60,
        #"write-timeout" => 60,
    ))
)
#server.max-read-idle := 60
#server.max-write-idle := 60

示例:echo.pl

echo.pl - 读取并逐行回显的简单“回显”脚本
这个脚本对 WebSocket 一无所知,它可以读/写 JSON 或其他任何数据。本示例是逐行读取和回显数据。

将文件安装到脚本位置和文档根目录
/tmp/echo.pl
<docroot>/count.html

echo.pl

#!/usr/bin/perl -Tw
$SIG{PIPE} = 'IGNORE';
for (my $FH; accept($FH, STDIN); close $FH) {
    select($FH); $|=1; # $FH->autoflush;
    print $FH $_ while (<$FH>);
}

<docroot>/count.html

<!DOCTYPE html>
<!-- modified from example in https://github.com/joewalnes/websocketd README.md -->
<pre id="log"></pre>
<script>
  // helper function: log message to screen
  var logelt = document.getElementById('log');
  function log(msg) { logelt.textContent += msg + '\n'; }
  // helper function: send websocket msg with count (1 .. 5)
  var ll = 0;
  function send_msg() { if (++ll <= 5) { log('SEND: '+ll); ws.send(ll+'\n'); } }
  // setup websocket with callbacks
  var ws = new WebSocket('ws://'+location.host+'/ws/');
  ws.onopen = function()         { log('CONNECT\n'); send_msg(); };
  ws.onclose = function()        { log('DISCONNECT'); };
  ws.onmessage = function(event) { log('RECV: ' + event.data); send_msg(); };
</script>

echo-incremental.pl
这是一个替代的“回显”示例脚本,它是无缓冲的——而不是上面更简单的行缓冲示例——并且在每次读取之间会休眠 1 秒,以便您可以在浏览器中直观地看到多个 WebSocket 消息在服务器之间来回传递。此示例脚本将运行 5 秒,然后关闭 WebSocket 连接。(要运行此示例,请使用此脚本替换 echo.pl 并重新启动运行 lighttpd-wstunnel.conf 的 lighttpd。)

#!/usr/bin/perl -Tw
$SIG{PIPE} = 'IGNORE';
for (my $FH; accept($FH, STDIN); close $FH) {
    foreach (1..5) {
        sleep 1;
        my $foo = "";
        sysread($FH, $foo, 1024);
        if (length($foo)) { syswrite($FH, $foo); }
    }
    close $FH;
}

示例:配置 lighttpd 作为 /ws/ 下 URL 的 WebSocket 端点

这是一个简单的示例,展示了 lighttpd 的 mod_wstunnel 如何终止 WebSocket 隧道并将负载发送/接收到后端“回显”脚本。对于此示例,请在备用端口上使用 mod_wstunnel 运行 lighttpd,此示例将在下面使用 mod_proxy 的示例中重复使用。

lighttpd-wstunnel.conf(在端口 8081 监听;使用 lighttpd -D -f /dev/shm/lighttpd-wstunnel.conf 启动;按 Ctrl-C 退出)

server.document-root = "/tmp"  # place count.html here (better: use a more secure location)
server.bind = "127.0.0.1"      # comment out if accessing from remote machine
server.port = 8081

server.modules += ("mod_wstunnel")
wstunnel.server = (
  "/ws/" => (
    (
      "socket" => "/tmp/echo.sock", # should use more secure location
      "bin-path" => "/tmp/echo.pl", # should use more secure location
      "max-procs" => 1
    )
  )
)

在本地浏览器中加载为 http://localhost:8081/count.html,并观察它从 1 数到 5。如果您是从远程系统连接,则修改 lighttpd-wstunnel.conf,注释掉 server.bind = "127.0.0.1" 并替换 count.html URL 中的 localhost。如果从远程机器访问,请替换 URL 中的 localhost

示例:配置 lighttpd 反向代理 /ws/ 下的 URL

这是一个简单的示例,展示了 lighttpd 的 mod_proxy 如何支持 WebSocket 连接到另一个 lighttpd 实例,该实例使用 mod_wstunnel 终止 WebSocket 隧道并将负载发送/接收到后端“回显”脚本。它使用了上面 mod_wstunnel 的示例,因此请确保该示例也正在运行。

lighttpd-proxy.conf(在端口 8080 监听;在另一个 shell 中使用 lighttpd -D -f /dev/shm/lighttpd-proxy.conf 启动;按 Ctrl-C 退出)

server.document-root = "/tmp"  # place count.html here (better: use a more secure location)
server.bind = "127.0.0.1"      # comment out if accessing from remote machine
server.port = 8080

server.modules += ("mod_proxy")
proxy.server = ( "/" => (( "host" => "127.0.0.1", "port" => "8081" )))
proxy.header = ( "upgrade" => "enable" )

在本地浏览器中加载为 http://localhost:8080/count.html,并观察它从 1 数到 5。如果您是从远程系统连接,则修改 lighttpd-proxy.conf,注释掉 server.bind = "127.0.0.1" 并替换 count.html URL 中的 localhost。如果从远程机器访问,请替换 URL 中的 localhost

请注意,在此示例中,count.html 的目标现在是 http://localhost:8080/count.html,使用端口 8080(在上面的示例中从 8081 更改)。

gstrauss1 年多前更新 · 6 次修订