jsnbd介绍
本文主要介绍一个名为jsnbd的开源项目,位于GitHub - openbmc/jsnbd,它实现了一个前端(包含HTML和JS文件)页面,作为存储服务器,可以指定存储内容;还包含一个后端的代理,这里所谓的代理实际上的作用是将nbd-client和前端连接起来,作为客户端,可以通过网络下载前面指定的存储内容。该开源项目在OpenBMC(OpenBMC · GitHub)中有使用。
在介绍jsnbd之前先介绍整体原理,这需要从NBD开始。
NBD全称N etwork B lock Device,可以让用户(下图左侧部分)将一个远程主机(下图右侧部分)的磁盘空间当作一个块设备来使用,就像使用本地的硬盘一样。其大致框架如下图所示:
两台服务器之间通过网络通信,一台安装有nbd-client作为客户端,另一台安装有nbd-server,这样作为客户端就可以从服务端获取服务端硬盘的数据,而且由于NBD的封装,从客户端用户来看,就像是操作普通硬盘设备一样。为此,无论是客户端还是服务端,它们的操作系统都还需要包含NBD模块,用于底层的操作。其结构如下:
这一部分由操作系统提供,并不会在本文中详细介绍。
而对于jsnbd来说,服务端稍有不同,它不再使用NBD Server应用,而是替换成一个Web程序,所以第一张图变为如下的形式:
但是实际使用时并没有这么简单,因为Web端程序无法直接通过网络跟nbd-client交互,Web需要首先跟Web服务器交互,而Web服务器又需要通过代理转到nbd-client上,所以这里还有两层,实际的框图应该是这样的:
而本文介绍的重点就是上图的橙色部分,这三个部分分别是:
- 前端:这里由一个JS文件和一个HTML文件完成,前者提供一个WebSocket的实现,后者提供一个简单的操作界面,用于选择文件作为存储"设备"供客户端访问。
- Web服务器(lighttpd):前端(作为服务端)需要与后端(作为客户端)的Web服务器沟通,这里使用lighttpd作为Web服务器,除了作为Web服务器,它还需要实现WebSocket的转发,这可以通过lighttpd自带的mod来实现。
- nbd-proxy:作为jsnbd的后端部分,其代码也已经由jsnbd项目提供,作为nbd-client和lighttpd之间的代理工具,lighttpd的转发会被它处理,而它的实现简单来说就是接收数据和操作nbd-client。
当然从图中还可以看到额外的一些内容:
-
Linux:由于jsnbd只实现了Linux版本,所以需要在Linux下测试(本文使用Windows操作系统的VMware虚拟机安装Ubuntu来作为测试用Linux)。
-
NBD模块 :它是Linux内核模块,通过
CONFIG_BLK_DEV_NBD=y
包含,需要注意WSL(Windows自带的虚拟机)中没有NBD模块,所以无法使用WSL来测试NBD,会报错:bashjw@HOME:~/code/nbdjs$ sudo modprobe nbd modprobe: FATAL: Module nbd not found in directory /lib/modules/5.15.90.1-microsoft-standard-WSL2
-
nbd-client:Linux下的应用,完成与底层NBD的交互。
它们可能会在本文提到,但并不会重点介绍。
本文使用的代码已经上传https://gitee.com/jiangwei0512/nbdjs.git,经过测试可以完全基本的使用。
lighttpd
这里首先介绍lighttpd,因为它是前后端交互的基础。
lighttpd是一款轻量级的开源Web服务器,跟Apache、Nginx功能差不多,对应的官网http://www.lighttpd.net/。
lighttpd目前只支持Linux,所以这里在虚拟机Linux上编译和使用lighttpd,对应的Linux版本:
bash
jw@ubuntu:~/code/www/html$ uname -a
Linux ubuntu 5.15.0-82-generic #91~20.04.1-Ubuntu SMP Fri Aug 18 16:24:39 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
编译和使用
目前下载到的最新版本是lighttpd-1.4.71.tar.gz。
- 首先解压缩源代码:
bash
jw@ubuntu:~/code$ tar -xzvf lighttpd-1.4.71.tar.gz
- 安装依赖:
bash
jw@ubuntu:~/code/lighttpd-1.4.71$ sudo apt install zlib1g-dev libpcre2-dev
- 进入解压缩得到的目录,然后进行configure:
bash
jw@ubuntu:~/code/lighttpd-1.4.71$ ./configure --prefix=/usr/local/lighttpd
- 编译:
bash
jw@ubuntu:~/code/lighttpd-1.4.71$ make
- 安装:
bash
jw@ubuntu:~/code/lighttpd-1.4.71$ sudo make install
安装的位置是:
bash
jw@ubuntu:~/code/lighttpd-1.4.71$ ls -al /usr/local/lighttpd/
total 20
drwxr-xr-x 5 root root 4096 Sep 3 07:03 .
drwxr-xr-x 11 root root 4096 Sep 3 07:03 ..
drwxr-xr-x 2 root root 4096 Sep 3 07:03 lib
drwxr-xr-x 2 root root 4096 Sep 3 07:03 sbin
drwxr-xr-x 3 root root 4096 Sep 3 07:03 share
- 进入到lighttpd程序所在的目录,后续以root进行操作:
bash
root@ubuntu:/usr/local/lighttpd/sbin# ll
total 1992
drwxr-xr-x 2 root root 4096 Sep 3 07:03 ./
drwxr-xr-x 5 root root 4096 Sep 3 07:03 ../
-rwxr-xr-x 1 root root 2004088 Sep 3 07:03 lighttpd*
-rwxr-xr-x 1 root root 23048 Sep 3 07:03 lighttpd-angel*
- 为了使用lighttpd,需要有配置文件,下面是一个最简单的例子(test.conf):
bash
server.document-root = "/home/jw/code/www/html"
server.port = 80
mimetype.assign = (
".html" => "text/html",
".txt" => "text/plain",
".jpg" => "image/jpeg",
".png" => "image/png"
)
index-file.names = ( "index.html" )
这些配置的意义如下:
- server.document-root:指定了Web服务器目录,我们需要在这里放浏览器可以访问的文件,后续使用的jsnbd前端代码都会放到这里。
- server.port:指定端口,默认非安全的Web服务器端口就是80。
- mimetype.assign:指定支持的文件。
- index-file.names:指定入口文件,就是浏览器输入IP之后首先看到的页面。
- 在
server.document-root
指定的目录中存放html文件,下面是一个例子(index.html ):
html
<html>
<body>
Hello Wolrd!
</body>
</html>
当通过浏览器登录服务器时,首先访问到的就是这个文件。
启动lighttpd的应用程序的命令如下:
bash
root@ubuntu:/usr/local/lighttpd/sbin# ./lighttpd -D -f test.conf
2023-09-03 07:17:49: (server.c.1909) server started (lighttpd/1.4.71)
启动之后该服务器会持续运行,此时就可以通过浏览器访问,输入的IP就是Linux系统的IP,端口可以不写,默认就是80。
测试结果如下图所示:
到这里一个简单的lighttpd服务器就已经开启了。
当然这只是一个开始,此时浏览器只能访问lighttpd中的简单html文件,要想访问后续的nbd-proxy,还需要修改配置文件,这个将在本文后续说明。这里简单介绍lighttpd如何将内容转发到Linux下的程序,这依赖于lighttpd的ws_tunnel插件。
lighttpd和WebSocket
- 为了使lighttpd支持WebSocket,首先需要修改它的配置,以下是修改之后的test.conf :
bash
server.modules += (
"mod_wstunnel"
)
server.document-root = "/home/jw/code/www/html"
server.port = 80
mimetype.assign = (
".html" => "text/html",
".txt" => "text/plain",
".jpg" => "image/jpeg",
".png" => "image/png"
)
static-file.exclude-extensions = ( ".fcgi", ".php", ".rb", "~", ".inc" )
index-file.names = ( "index.html" )
$HTTP["url"] =~ "^/websocket.test" {
wstunnel.server = (
"" => ((
"host" => "127.0.0.1",
"port" => "888"
))
)
wstunnel.frame-type = "text"
}
这里的改动有以下的几个:
-
通过
server.modules
引入lighttpd插件,在lighttpd中,通过插件的方式可以引入很多新的特性,比如这里的WebSocket(对应插件mod_wstunnel),还有CGI,代理,等等。 -
配置wstunnel,所有的参数可以在Docs ConfigurationOptions - Lighttpd - lighty labs找到,这里的配置主要针对特定格式的WebSocket,其配置有两个:一个是转发的地址和端口,指向了localhost(127.0.0.1)和888端口,注意它们需要跟转发过去的程序有相同的配置,否则该程序接收不到转发的内容;另一个是WebSocket的数据格式,这里指定的是文本格式。
配置修改之后重新打开lighttpd:
bash
root@ubuntu:/usr/local/lighttpd/sbin# ./lighttpd -D -f test.conf
2023-09-09 22:20:15: (server.c.1909) server started (lighttpd/1.4.71)
此时可以查看到网络状态:
bash
root@ubuntu:/usr/local/lighttpd/sbin# netstat -ntlv
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN
tcp6 0 0 ::1:631 :::* LISTEN
这里显示的第一行就是lighttpd服务器,它监听80端口,IP没有限制。
- 然后是编写Linux端的转发处理程序,下面是一个示例:
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
// Should be same with the one in lihttpd.conf and index.html.
#define DEFAULT_PORT 888
// Should be same with the one in lihttpd.conf.
#define DEFAULT_IP "127.0.0.1"
int main(int argc, char **argv)
{
int server_socket = -1;
int client_socket = -1;
struct sockaddr_in server_addr;
struct sockaddr_in client_addr;
char received_buffer[1024]; // Buffer for received.
int received_len = -1;
int sended_len = -1;
int res = -1;
socklen_t addr_len = sizeof(struct sockaddr);
int index;
// Create a socket.
server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket < 0)
{
printf("Create socket failed: %s\n", strerror(errno));
return -1;
}
// Bind the created socket on special IP and port.
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(DEFAULT_PORT);
server_addr.sin_addr.s_addr = inet_addr(DEFAULT_IP);
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
printf("Bind server failed: %s\n", strerror(errno));
return -2;
}
printf("Socket[%d] has bond on port[%d] for IP address[%s]!\n",
server_socket, DEFAULT_PORT, DEFAULT_IP);
// Listen on the created socket.
listen(server_socket, 10);
while (1)
{
printf("Waiting and accept new client connect...\n");
client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &addr_len);
if (client_socket < 0)
{
printf("Accept client socket failed: %s\n", strerror(errno));
return -3;
}
printf("Accept new client[%d] socket[%s:%d]\n", client_socket,
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
while (1)
{
memset(received_buffer, 0, sizeof(received_buffer));
received_len = read(client_socket, received_buffer, sizeof(received_buffer));
if (received_len < 0)
{
printf("Read data from client [%d] failed: %s\n", client_socket, strerror(errno));
close(client_socket);
break;
}
else if (0 == received_len)
{
printf("Client [%d] disconnected!\n", client_socket);
close(client_socket);
break;
}
else
{
printf("Read %d bytes from client[%d] and the data is : %s\n",
received_len, client_socket, received_buffer);
// Send back the received buffer to client.
sended_len = write(client_socket, received_buffer, received_len);
if (sended_len < 0)
{
printf("Write data back to client[%d] failed: %s \n", client_socket,
strerror(errno));
close(client_socket);
break;
}
}
}
sleep(1);
}
if (client_socket)
{
close(client_socket);
}
close(server_socket);
return 1;
}
这里使用了socket编程,注意socket和前面提到的WebSocket虽然都用来网络通信,但是它们不是同一个东西,关于它们的具体差别涉及到socket和WebSocket的基础,这里不展开。
这个程序的实现很简单,就是将服务器获取到的数据直接返回给发送端。编译然后使用该程序:
bash
root@ubuntu:/home/jw/code/www/html# gcc websocket_server.c
root@ubuntu:/home/jw/code/www/html# ./a.out
Socket[3] has bond on port[888] for IP address[127.0.0.1]!
Waiting and accept new client connect...
再次查看网络状态:
bash
root@ubuntu:/usr/local/lighttpd/sbin# netstat -ntlv
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:888 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN
tcp6 0 0 ::1:631 :::* LISTEN
可以看到又多监听了一个端口888,IP是localhost(127.0.0.1),由于ligtttpd的配置,前端连接过来的特定WebSocket(满足^/websocket.test
的格式)就会被本程序处理。
- 发送端代码的编写。前面的两步都在Linux系统中(本例使用了虚拟机中的Ubuntu系统),而这里的操作可以在任意的系统中使用,只要存在浏览器,且跟Linux系统可以通过网络通信即可。不过这里编写的Web程序最后还是会在Linux系统中,且在lighttpd指定的目录下,其示例代码(index.html):
html
<h1>Websocket Test</h1>
<pre id="messages" style="height: 400px; overflow: scroll"></pre>
<input type="text" id="messageBox" placeholder="Type your message here"
style="display: block; width: 100%; margin-bottom: 10px; padding: 10px;" />
<button id="send" title="Send Message!" style="width: 100%; height: 30px;">Send Message</button>
<script>
(function () {
const sendBtn = document.querySelector('#send');
const messages = document.querySelector('#messages');
const messageBox = document.querySelector('#messageBox');
let ws;
function showMessage(message) {
messages.textContent += `\nReceived: ${message}`;
messages.scrollTop = messages.scrollHeight;
messageBox.value = '';
}
function init() {
if (ws) {
ws.onerror = ws.onopen = ws.onclose = null;
ws.close();
}
ws = new WebSocket("ws://" + location.host + "/websocket.test");
ws.onopen = () => {
console.log('Connection opened!');
}
ws.onmessage = ({ data }) => showMessage(data);
ws.onclose = function () {
console.log('Connectino closed!');
ws = null;
}
}
sendBtn.onclick = function () {
if (!ws) {
showMessage("No WebSocket connection :(");
return;
}
ws.send(messageBox.value);
console.log("Sended: " + messageBox.value);
}
init();
})();
</script>
注意这里的:
javascript
ws = new WebSocket("ws://" + location.host + "/websocket.test");
location.host
对应的是Linux的IP,整个URL满足lighttpd中ws_tunnel的转发要求,所以会被第二步中的程序接收到。
通过浏览器访问location.host
对应的地址(也就是Linux系统的IP地址),执行结果如下:
图中的虚拟机安装有Ubuntu20.04,开启两个进程,上面的是lighttpd作为Web服务器,下面是socket编写的服务器程序;虚拟机外面是浏览器,输入Ubuntu20.04系统的IP即可访问lighttpd,并显示指定目录下的index.html文件,在该界面下输入的内容会被lighttpd转发给服务器程序,而后者打印传递过来的内容然后返回,最后在浏览器显示出来。
nbd-proxy
代码
代码如下:
bash
jw@HOME:~/code/nbdjs$ ll
total 92
drwxr-xr-x 5 jw jw 4096 Sep 1 22:17 ./
drwxr-xr-x 4 jw jw 4096 Sep 1 22:09 ../
-rw-r--r-- 1 jw jw 3693 Sep 1 22:17 .clang-format
drwxr-xr-x 8 jw jw 4096 Sep 1 22:17 .git/
-rw-r--r-- 1 jw jw 368 Sep 1 22:17 .gitignore
-rw-r--r-- 1 jw jw 11358 Sep 1 22:10 LICENCE
-rw-r--r-- 1 jw jw 211 Sep 1 22:10 Makefile.am
-rw-r--r-- 1 jw jw 1562 Sep 1 22:10 OWNERS
-rw-r--r-- 1 jw jw 2307 Sep 1 22:10 README
-rwxr-xr-x 1 jw jw 73 Sep 1 22:10 bootstrap.sh*
-rw-r--r-- 1 jw jw 362 Sep 1 22:10 config.sample.json
-rw-r--r-- 1 jw jw 446 Sep 1 22:10 configure.ac
drwxr-xr-x 2 jw jw 4096 Sep 1 22:10 m4/
-rw-r--r-- 1 jw jw 1067 Sep 1 22:10 meson.build
-rw-r--r-- 1 jw jw 103 Sep 1 22:10 meson_options.txt
-rw-r--r-- 1 jw jw 20297 Sep 1 22:10 nbd-proxy.c
drwxr-xr-x 3 jw jw 4096 Sep 1 22:17 web/
这里其实包含两个部分:
- 一部分是前端Web程序,位于web目录下,它们是NBD服务器的一部分,可以在任意浏览器中使用。
- 剩下的属于另外一部分(主要就是nbd-proxy.c),属于NBD客户端的一部分,最终会生成一个nbd-proxy工具,目前只有Linux版本。
编译
nbd-proxy在Linux环境下编译,对应的环境:
bash
jw@ubuntu:~$ uname -a
Linux ubuntu 5.15.0-82-generic #91~20.04.1-Ubuntu SMP Fri Aug 18 16:24:39 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
- 编译之前先安装依赖:
bash
jw@ubuntu:~$ sudo apt install libjson-c-dev libudev-dev autoconf
- 然后是编译:
bash
jw@ubuntu:~/code/nbdjs$ autoscan
configure.ac: warning: missing AC_CHECK_FUNCS([dup2]) wanted by: nbd-proxy.c:156
configure.ac: warning: missing AC_CHECK_FUNCS([memset]) wanted by: nbd-proxy.c:263
configure.ac: warning: missing AC_CHECK_FUNCS([socket]) wanted by: nbd-proxy.c:89
configure.ac: warning: missing AC_CHECK_FUNCS([strchr]) wanted by: nbd-proxy.c:412
configure.ac: warning: missing AC_CHECK_FUNCS([strdup]) wanted by: nbd-proxy.c:653
configure.ac: warning: missing AC_CHECK_HEADERS([fcntl.h]) wanted by: nbd-proxy.c:25
configure.ac: warning: missing AC_CHECK_HEADERS([limits.h]) wanted by: nbd-proxy.c:29
configure.ac: warning: missing AC_CHECK_HEADERS([stdint.h]) wanted by: nbd-proxy.c:32
configure.ac: warning: missing AC_CHECK_HEADERS([stdlib.h]) wanted by: nbd-proxy.c:34
configure.ac: warning: missing AC_CHECK_HEADERS([string.h]) wanted by: nbd-proxy.c:35
configure.ac: warning: missing AC_CHECK_HEADERS([sys/socket.h]) wanted by: nbd-proxy.c:37
configure.ac: warning: missing AC_CHECK_HEADERS([unistd.h]) wanted by: nbd-proxy.c:42
configure.ac: warning: missing AC_CHECK_HEADER_STDBOOL wanted by: nbd-proxy.c:47
configure.ac: warning: missing AC_CHECK_MEMBERS([struct stat.st_rdev]) wanted by: nbd-proxy.c:812
configure.ac: warning: missing AC_FUNC_FORK wanted by: nbd-proxy.c:137
configure.ac: warning: missing AC_FUNC_MALLOC wanted by: nbd-proxy.c:869
configure.ac: warning: missing AC_TYPE_PID_T wanted by: nbd-proxy.c:58
configure.ac: warning: missing AC_TYPE_SIZE_T wanted by: nbd-proxy.c:63
configure.ac: warning: missing AC_TYPE_SSIZE_T wanted by: nbd-proxy.c:196
configure.ac: warning: missing AC_TYPE_UINT8_T wanted by: nbd-proxy.c:62
jw@ubuntu:~/code/nbdjs$ aclocal
jw@ubuntu:~/code/nbdjs$ autoreconf --install
configure.ac:9: installing './ar-lib'
configure.ac:8: installing './compile'
configure.ac:5: installing './install-sh'
configure.ac:5: installing './missing'
Makefile.am: installing './depcomp'
jw@ubuntu:~/code/nbdjs$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /usr/bin/mkdir -p
checking for gawk... no
checking for mawk... mawk
checking whether make sets $(MAKE)... yes
checking whether make supports nested variables... yes
checking whether make supports nested variables... (cached) yes
checking for gcc... gcc
checking whether the C compiler works... yes
checking for C compiler default output file name... a.out
checking for suffix of executables...
checking whether we are cross compiling... no
checking for suffix of object files... o
checking whether we are using the GNU C compiler... yes
checking whether gcc accepts -g... yes
checking for gcc option to accept ISO C89... none needed
checking whether gcc understands -c and -o together... yes
checking whether make supports the include directive... yes (GNU style)
checking dependency style of gcc... gcc3
checking for ar... ar
checking the archiver (ar) interface... ar
checking whether make sets $(MAKE)... (cached) yes
checking whether C compiler accepts -Wall... yes
checking whether C compiler accepts -Werror... yes
checking for splice... yes
checking for pkg-config... /usr/bin/pkg-config
checking pkg-config is at least version 0.9.0... yes
checking for JSON... yes
checking for UDEV... yes
checking that generated files are newer than configure... done
configure: creating ./config.status
config.status: creating Makefile
config.status: creating config.h
config.status: executing depfiles commands
jw@ubuntu:~/code/nbdjs$ make
make all-am
make[1]: Entering directory '/home/jw/code/nbdjs'
CC nbd_proxy-nbd-proxy.o
CCLD nbd-proxy
make[1]: Leaving directory '/home/jw/code/nbdjs'
最终生成的nbd-proxy就是我们需要的工具:
bash
jw@ubuntu:~/code/nbdjs$ ll nbd-proxy
-rwxrwxr-x 1 jw jw 64184 Sep 1 09:45 nbd-proxy*
使用
- 在使用之前还需要安装另外一个工具nbd-client:
bash
root@ubuntu:/home/jw/code/nbdjs# sudo apt install nbd-client
不过光安装应用还不够,还需要使用到Linux的NBD模块,所以还需要执行如下的命令:
bash
root@ubuntu:/home/jw/code/nbdjs# sudo modprobe nbd
为了确定NBD模块是否加载,可以使用如下的命令查看:
bash
jw@ubuntu:~/code/nbdjs$ ls /dev/nbd*
/dev/nbd0 /dev/nbd10 /dev/nbd12 /dev/nbd14 /dev/nbd2 /dev/nbd4 /dev/nbd6 /dev/nbd8
/dev/nbd1 /dev/nbd11 /dev/nbd13 /dev/nbd15 /dev/nbd3 /dev/nbd5 /dev/nbd7 /dev/nbd9
如果有上述的一系列/dev/nbd*
设备,表示NBD模块已经成功加载。
- 之后创建一个配置文件config.json,内容如下:
bash
jw@ubuntu:~/code/nbdjs$ cat config.json
{
"timeout": 30,
"configurations": {
"0": {
"nbd-device": "/dev/nbd0",
"metadata": {
"description": "Virtual media device"
}
}
}
}
将该文件放到/usr/local/etc/nbd-proxy
目录下:
bash
jw@ubuntu:~/code/nbdjs$ sudo mkdir /usr/local/etc/nbd-proxy
jw@ubuntu:~/code/nbdjs$ sudo cp config.json /usr/local/etc/nbd-proxy/
- 之后还需要修改代码:
c
// static const char* sockpath_tmpl = RUNSTATEDIR "/nbd.%d.sock";
static const char* sockpath_tmpl = "/tmp/nbd.%d.sock";
因为RUNSTATEDIR
对应的/usr/local/var/run无法直接访问。
- 修改之后就可以执行nbd-proxy工具:
c
root@ubuntu:/home/jw/code/nbdjs# ./nbd-proxy
此时不会有什么输出内容,也不能直接交互。这需要使用到Web前端内容,而为了启动前端,这里先使用websocketd工具,它可以完成整个jsnbd的测试。
- 下载工具:
bash
root@ubuntu:/home/jw/code/nbdjs# apt install websocketd
- 启动该工具:
bash
root@ubuntu:/home/jw/code/nbdjs# websocketd --port=8000 --staticdir=web --binary ./nbd-proxy
Sun, 12 Nov 2023 15:03:10 +0800 | INFO | server | | Serving using application : ./nbd-proxy
Sun, 12 Nov 2023 15:03:10 +0800 | INFO | server | | Serving static content from : web
Sun, 12 Nov 2023 15:03:10 +0800 | INFO | server | | Starting WebSocket server : ws://ubuntu:8000/
Sun, 12 Nov 2023 15:03:10 +0800 | INFO | server | | Serving CGI or static files : http://ubuntu:8000/
参数说明:
- port:指定端口,浏览器打开IP时需要增加该端口。
- staticdir:指定前端页面入口,浏览器默认会去获取该目录下的index.html,该文件也已经在开源代码中。
- binary:指定二进制模式,数据会被转到nbd-proxy程序中。
- 打开浏览器,界面如下,点击"Browse"可以选择文件,"Serve Image"后开始服务:
- 通过/dev/nbd0就可以查看到远端的文件:
bash
root@ubuntu:/home/jw# fdisk -l /dev/nbd0
Disk /dev/nbd0: 1 MiB, 1048576 bytes, 2048 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
- 而点击浏览器的"Stop"之后,就无法再看到设备了:
bash
root@ubuntu:/home/jw# fdisk -l /dev/nbd0
fdisk: cannot open /dev/nbd0: Inappropriate ioctl for device
到这里jsnbd的前后端就联系起来了。
不过这里使用的是websocketd工具,它将数据直接传递给了nbd-proxy,对于lighttpd并不能直接使用这一套东西。为此需要修改相关的代码,为此需要先了解nbd-proxy的实现。
代码说明
nbd-proxy的实现主要是以下的几个部分。
- 首先初始化,这需要读取外部的文件,这个文件在之前已经介绍过,就是config.json。读取文件和初始化的流程大致如下:
获取参数 由参数确定配置项 初始化上下文 初始化数据缓存 初始化配置 指定配置项
关于配置项的初始化依赖于两个函数config_init()
和config_select()
,后者通过入参来指定配置项。
配置项的一个示例如下:
json
{
"timeout": 30,
"configurations": {
"0": {
"nbd-device": "/dev/nbd0",
"metadata": {
"description": "Virtual media device"
}
},
"1": {
"nbd-device": "/dev/nbd1",
"metadata": {
"description": "Dump Offload"
}
}
}
}
整个初始化和选择配置结果是:
- 由参数决定具体使用哪个配置。
- 如果没有参数指定,则使用配置中的默认项。
- 如果连配置项也没有,则第一项就是默认项。
后续简化了这个配置文件,直接留一项,这样就会只使用这项配置:
json
{
"timeout": 30,
"configurations": {
"0": {
"nbd-device": "/dev/nbd0",
"metadata": {
"description": "Virtual media device"
}
}
}
}
- 创建用于nbd-proxy和nbd-client的socket,对应的函数是
open_nbd_socket()
,其主体的代码:
c
static int open_nbd_socket(struct ctx* ctx)
{
rc = asprintf(&path, sockpath_tmpl, getpid());
if (rc < 0)
return -1;
// 创建socket,参数说明如下:
// SOCK_STREAM:
// Provides sequenced, reliable, two-way, connection-based
// byte streams. An out-of-band data transmission mechanism
// may be supported.
// SOCK_CLOEXEC:
// Set the close-on-exec (FD_CLOEXEC) flag on the new file
// descriptor. See the description of the O_CLOEXEC flag in
// open(2) for reasons why this may be useful.
sd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
// S_IRUSR: 00400 user has read permission
// S_IWUSR: 00200 user has write permission
rc = fchmod(sd, S_IRUSR | S_IWUSR);
addr.sun_family = AF_UNIX; // 表示是PF_UNIX类型的socket
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
// addr表示的是本地地址
rc = bind(sd, (struct sockaddr*)&addr, sizeof(addr));
rc = listen(sd, 1);
ctx->sock = sd;
ctx->sock_path = path;
}
socket有两种类型,这里使用的是AF_UNIX类型的,重点就是这里的ctx->sock_path
,它会被nbd-client使用到,这样socket就与nbd-client联系起来了,该操作在之后的代码中会进一步说明。
- 开启nbd-client,对应的函数是
start_nbd_client()
:
c
static int start_nbd_client(struct ctx* ctx)
{
pid = fork();
// 子进程开始执行nbd-client命令
if (pid == 0)
{
// 执行的命令大致是:
// nbd-client -u /usr/local/var/run/nbd.xxx.sock -n -L -t 30 /dev/nbd0
// 关于参数的说明:
// -u:指定UNIX域的socket,这个socket就是nbd-client拿数据的地方
// -n: 表示nofor
// -L:表示nonetlink
// -t:指定超时时间
// 最后是NDB设备
execlp("nbd-client", "nbd-client", "-u", ctx->sock_path, "-n", "-L",
"-t", timeout_str, ctx->config->nbd_device, NULL);
err(EXIT_FAILURE, "can't start ndb client");
}
ctx->nbd_client_pid = pid;
}
这里操作的命令就是:
bash
nbd-client -u /usr/local/var/run/nbd.xxx.sock -n -L -t 30 /dev/nbd0
-u
之后的就是前面提到的AF_UNIX类型的sockekt,最后的是nbd设备,也是我们最终访问的设备。
- 最重要的数据处理位于
run_proxy()
函数,其实现基础就是数据的搬运(通过copy_fd()
函数):
c
static int run_proxy(struct ctx* ctx)
{
/* main proxy: forward data between stdio & socket */
pollfds[0].fd = ctx->sock_client;
pollfds[0].events = POLLIN;
pollfds[1].fd = STDIN_FILENO;
pollfds[1].events = POLLIN;
// 数据处理的主题,还有其它信号和设备操作的处理
for (;;)
{
errno = 0;
rc = poll(pollfds, n_fd, -1);
if (rc < 0)
{
if (errno == EINTR)
continue;
warn("poll failed");
break;
}
if (pollfds[0].revents)
{
rc = copy_fd(ctx, ctx->sock_client, STDOUT_FILENO);
if (rc <= 0)
break;
}
if (pollfds[1].revents)
{
rc = copy_fd(ctx, STDIN_FILENO, ctx->sock_client);
if (rc <= 0)
break;
}
}
}
这里的数据搬运涉及到的两端,一端是前面的socket对应的文件描述符,另一端是标准输入输出,由于websocketd接管了nbd-proxy程序的标准输入输出,所以实际上就是websocketd与socket的通信。
最终数据的传输如下图所示:
/dev/nbd0 nbd-client socket websocketd
当然nbd-client中还有一些其它的处理代码,它们跟信号、udev等有关,不过这些部分不影响代码修改,所以暂时不关注。
代码修改
为了使nbd-proxy能够与lighttpd连接,只需要根据lighttpd和WebSocket的示例代码进行修改即可,修改的重点在于下图:
/dev/nbd0 nbd-client socket 新增socket lighttpd
下面是具体的操作:
- 首先是新增socket,用于在lighttpd和原有的socket之间的数据通信:
c
static int open_web_socket(struct ctx* ctx)
{
sd = socket(AF_INET, SOCK_STREAM, 0);
rc = fchmod(sd, S_IRUSR | S_IWUSR);
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(DEFAULT_PORT);
server_addr.sin_addr.s_addr = inet_addr(DEFAULT_IP);
rc = bind(sd, (struct sockaddr *)&server_addr, sizeof(server_addr));
rc = listen(sd, 1);
ctx->web_sock = sd;
}
与nbd-proxy原始代码中的socket类型不同,这个是AF_INET类型的,因为它需要与lighttpd通信,这里的DEFAULT_PORT
和DEFAULT_IP
需要跟lighttpd中的配置一致:
json
$HTTP["url"] =~ "^/websocket.test" {
wstunnel.server = (
"" => ((
"host" => "127.0.0.1",
"port" => "888"
))
)
wstunnel.frame-type = "binary"
}
该配置在前面也出现过,不同的是frame-type不再是text而是binary。
- 然后是建立lighttpd和socket的连接:
c
static int wait_for_web_socket(struct ctx* ctx)
{
pollfds[0].fd = ctx->web_sock;
pollfds[0].events = POLLIN;
for (;;)
{
rc = poll(pollfds, 1, -1);
if (pollfds[0].revents)
{
rc = accept4(ctx->web_sock, NULL, NULL, SOCK_CLOEXEC);
ctx->web_sock_fd = rc;
break;
}
}
return 0;
}
连接成功之后得到文件描述符ctx->web_sock_fd,后续数据通信需要依赖于它。
- 修改原始代码中的数据传输代码:
c
static int run_proxy(struct ctx* ctx)
{
if (pollfds[0].revents)
{
rc = copy_fd(ctx, ctx->sock_client, ctx->web_sock_fd);
if (rc <= 0)
break;
}
if (pollfds[1].revents)
{
rc = copy_fd(ctx, ctx->web_sock_fd, ctx->sock_client);
if (rc <= 0)
break;
}
}
}
该代码在前面已经出现过,不过copy_fd()
的参数有做修改,不再是标准输入输出,而是前面代码中得到的文件描述符。
- 前端的代码也需要稍微修改,主要是WebSocket需要修改:
html
function start_server()
{
server = new NBDServer("ws://" + location.host + "/websocket.test", file);
}
这里增加了websocket.test标识符,这样lighttpd才能够转发。
再次使用
之后就可以使用lighttpd服务器来使用nbd-proxy了,操作如下:
- 打开lighttpd(注意test.conf已经跟lighttpd和WebSocket中的不同):
bash
root@ubuntu:/usr/local/lighttpd/sbin# ./lighttpd -D -f test.conf
-
重新编译nbd-proxy并开启服务:
root@ubuntu:/home/jw/code/nbdjs# ./nbd-proxy
-
打开浏览器上传文件:
指定文件,并点击"Server Image"后就开启了服务器,之后就可以正常查看/dev/nbd0了,表示已经连接上。
注意目前的代码修改非常的原始,还存在不少的问题,只能作为示例使用。
前端
前端代码没有编译之类的部分,只是包含一个html和一个js文件,把它们放在服务器配置指定的位置能够让服务器找到即可,这样打开浏览器输入正确的网址之后就会直接访问到html文件。html的实现没有多少可以介绍的,这里详细说明js文件。
代码
js代码中包含了一个状态机来处理数据,其初始化和状态机处理流程如下:
代码说明已经包含在注释中:
js
/* handshake flags */
const NBD_FLAG_FIXED_NEWSTYLE = 0x1;
const NBD_FLAG_NO_ZEROES = 0x2;
/* transmission flags */
const NBD_FLAG_HAS_FLAGS = 0x1;
const NBD_FLAG_READ_ONLY = 0x2;
/* option negotiation */
const NBD_OPT_EXPORT_NAME = 0x1;
const NBD_REP_FLAG_ERROR = 0x1 << 31;
const NBD_REP_ERR_UNSUP = NBD_REP_FLAG_ERROR | 1;
/* command definitions */
const NBD_CMD_READ = 0;
const NBD_CMD_WRITE = 1;
const NBD_CMD_DISC = 2;
const NBD_CMD_FLUSH = 3;
const NBD_CMD_TRIM = 4;
/* errno */
const EPERM = 1;
const EIO = 5;
const ENOMEM = 12;
const EINVAL = 22;
const ENOSPC = 28;
const EOVERFLOW = 75;
const ESHUTDOWN = 108;
/* internal object state */
const NBD_STATE_UNKNOWN = 1;
const NBD_STATE_OPEN = 2;
const NBD_STATE_WAIT_CFLAGS = 3;
const NBD_STATE_WAIT_OPTION = 4;
const NBD_STATE_TRANSMISSION = 5;
// 定义对象构造器,后续会创建该构造类型的对象,为了web\index.html
function NBDServer(endpoint, file) {
// index.html中得到的文件
this.file = file;
this.endpoint = endpoint;
this.ws = null;
// NBD的状态,不同的状态下nbd有不同的操作,该操作在recv_handlers中定义
// 默认初始化的是无效状态,该状态下什么也不会做,因为recv_handlers中没有它的操作函数
this.state = NBD_STATE_UNKNOWN;
// 存放获取到的数据
this.msgbuf = null;
// start函数,用于创建WebSocket
this.start = function () {
// 当NBD开始执行时的状态,该状态本身也没有多大的意义
this.state = NBD_STATE_OPEN;
// WebSocket的构造函数:WebSocket(url[, protocols]),这里没有使用protocols,所以就只使用了url
this.ws = new WebSocket(this.endpoint);
this._log("WebSocket created");
// 传输的数据格式按ArrayBuffer对象来,表示对固定长度的连续内存的引用
// The ArrayBuffer object is used to represent a generic raw binary data buffer
// 构造函数:ArrayBuffer()
// 具体参考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
this.ws.binaryType = 'arraybuffer';
// WebSocket通过onmessage事件来接收服务器返回的数据
// 注意这里使用了bind函数
// bind()方法创建一个新的函数,在bind()被调用时,这个新函数的this被指定为bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用
// 简单说就是onmessage回调函数对应到_on_ws_message
this.ws.onmessage = this._on_ws_message.bind(this);
// WebSocket连接建立时触发
this.ws.onopen = this._on_ws_open.bind(this);
}
// stop函数主要就是关闭WebSocket
this.stop = function () {
this.ws.close();
this.state = NBD_STATE_UNKNOWN;
}
this._log = function (msg) {
// 在NBDServer创建成功之前先用console.log,后续会在index.html的代码中设置this.onlog
// 这样就会在页面直接显示出来结果
if (this.onlog) {
this.onlog(msg);
} else {
console.log(msg);
}
}
/* websocket event handlers */
// ws.onopen的实现,会在建立WebSocket连接时执行
// 本函数跟后端nbd-server进行协商,不过似乎也不是协商,因为就是前端发过去数据,并没有其它的交互
// 具体的实现主要在_negotiate函数
this._on_ws_open = function (ev) {
this.client = {
flags: 0,
};
this._negotiate();
}
// 入参是MessageEvent,它包含属性:
// data: 返回DOMString, Blob或者ArrayBuffer,包含来自发送者的数据。对于本例疾就是ArrayBuffer
this._on_ws_message = function (ev) {
// 获取到ArrayBuffer
var data = ev.data;
if (this.msgbuf == null) {
// 最开始msgbuf中是没有数据的,所以直接就可以拿来用
this.msgbuf = data;
} else {
// 之后有数据了,就要将原本的数据放到最开始,然后往后面添加数据
// Uint8Array数组类型表示一个8位无符号整型数组,创建时内容被初始化为0
// 创建完后,可以以对象的方式或使用数组下标索引的方式引用数组中的元素
// 这里创建一个足够大的内存空间,包括原有的数据和新增的数据,所以长度是两者相加
var tmp = new Uint8Array(this.msgbuf.byteLength + data.byteLength);
// 从源数据,比如this.msgbuf和data,拷贝数据到新的内存空间
tmp.set(new Uint8Array(this.msgbuf), 0);
tmp.set(new Uint8Array(data), this.msgbuf.byteLength);
// 新的空间被赋值该this.msgbuf,因为后续处理数据的对象就是它
this.msgbuf = tmp.buffer;
}
for (; ;) {
// 根据不同的状态来获取处理函数
// 当NBD开始工作的时候,初始的状态是_negotiate函数中设置的NBD_STATE_WAIT_CFLAGS
// 所以最开始执行的操作是_handle_cflags
// 当一次_handle_cflags函数处理之后,值又被设置成了NBD_STATE_WAIT_OPTION
// 之后就是执行_handle_option
// 当一次_handle_option函数处理之后,值可能变成NBD_STATE_TRANSMISSION,那么就会执行_handle_cmd
// 或者不变,那么还是执行_handle_option
// 而这个变或者不变的条件是本次的数据msgbuf
// 如果是_handle_cmd,则还要根据msgbuf中的请求类型执行不同的操作:
// NBD_CMD_READ:对应_handle_cmd_read操作,它是当前代码唯一支持的操作,就是后台读数据,这也是我们需要的
// NBD_CMD_DISC:表示nbd-server想要端口,所以最终就是端口WebSocket
// NBD_CMD_WRITE:并没事实际支持
// NBD_CMD_TRIM:不支持
var handler = this.recv_handlers[this.state];
if (!handler) {
this._log("no handler for state " + this.state);
this.stop();
break;
}
var consumed = handler(this.msgbuf);
if (consumed < 0) {
this._log("handler[state=" + this.state +
"] returned error " + consumed);
this.stop();
break;
}
if (consumed == 0)
break;
if (consumed > 0) {
if (consumed == this.msgbuf.byteLength) {
this.msgbuf = null;
break;
}
this.msgbuf = this.msgbuf.slice(consumed);
}
}
}
this._negotiate = function () {
// 相当与一段内存
var buf = new ArrayBuffer(18);
// 但是这段内存不能直接使用,必须要通过"View Object"来操作
// DataView就是一种"View Object",指的是自定义的解析器
var data = new DataView(buf, 0, 18);
// 后面就是创建数据并通过WebSocket发送,数据总共18个字节
// 首先是魔术字
/* NBD magic: NBDMAGIC */
data.setUint32(0, 0x4e42444d);
data.setUint32(4, 0x41474943);
/* newstyle negotiation: IHAVEOPT */
data.setUint32(8, 0x49484156);
data.setUint32(12, 0x454F5054);
/* flags: fixed newstyle negotiation, no padding */
// 这里的值需要从nbd-server/nbd-client代码中去找,为了cliserv.h
// #define NBD_FLAG_FIXED_NEWSTYLE (1 << 0) /**< new-style export that actually supports extending */
// #define NBD_FLAG_NO_ZEROES (1 << 1) /**< we won't send the 128 bits of zeroes if the client sends NBD_FLAG_C_NO_ZEROES */
// 实际上这些数据最终都会送到后台的nbd-server/nbd-client中,而本源码中的nbd-proxy只是一个中转站
data.setUint16(16, NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES);
// 当与后台的nbd-server建立连接之后的状态
// 该状态下涉及处理函数_handle_cflags
this.state = NBD_STATE_WAIT_CFLAGS;
this.ws.send(buf);
}
/* handlers */
this._handle_cflags = function (buf) {
if (buf.byteLength < 4)
return 0;
var data = new DataView(buf, 0, 4);
this.client.flags = data.getUint32(0);
this._log("client flags received: 0x" +
this.client.flags.toString(16));
this.state = NBD_STATE_WAIT_OPTION;
return 4;
}
this._handle_option = function (buf) {
if (buf.byteLength < 16)
return 0;
var data = new DataView(buf, 0, 16);
if (data.getUint32(0) != 0x49484156 ||
data.getUint32(4) != 0x454F5054) {
this._log("invalid option magic");
return -1;
}
var opt = data.getUint32(8);
var len = data.getUint32(12);
this._log("client option received: 0x" + opt.toString(16));
if (buf.byteLength < 16 + len)
return 0;
switch (opt) {
case NBD_OPT_EXPORT_NAME:
this._log("negotiation complete, starting transmission mode");
var n = 10;
if (!(this.client.flags & NBD_FLAG_NO_ZEROES))
n += 124;
var resp = new ArrayBuffer(n);
var view = new DataView(resp, 0, 10);
/* export size. */
var size = this.file.size;
view.setUint32(0, Math.floor(size / (2 ** 32)));
view.setUint32(4, size & 0xffffffff);
/* transmission flags: read-only */
view.setUint16(8, NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY);
this.ws.send(resp);
this.state = NBD_STATE_TRANSMISSION;
break;
default:
/* reject other options */
var resp = new ArrayBuffer(20);
var view = new DataView(resp, 0, 20);
view.setUint32(0, 0x0003e889);
view.setUint32(4, 0x045565a9);
view.setUint32(8, opt);
view.setUint32(12, NBD_REP_ERR_UNSUP);
view.setUint32(16, 0);
this.ws.send(resp);
}
return 16 + len;
}
this._create_cmd_response = function (req, rc, data = null) {
var len = 16;
if (data)
len += data.byteLength;
var resp = new ArrayBuffer(len);
var view = new DataView(resp, 0, 16);
view.setUint32(0, 0x67446698);
view.setUint32(4, rc);
view.setUint32(8, req.handle_msB);
view.setUint32(12, req.handle_lsB);
if (data)
new Uint8Array(resp, 16).set(new Uint8Array(data));
return resp;
}
this._handle_cmd = function (buf) {
if (buf.byteLength < 28)
return 0;
var view = new DataView(buf, 0, 28);
if (view.getUint32(0) != 0x25609513) {
this._log("invalid request magic");
return -1;
}
var req = {
flags: view.getUint16(4),
type: view.getUint16(6),
handle_msB: view.getUint32(8),
handle_lsB: view.getUint32(12),
offset_msB: view.getUint32(16),
offset_lsB: view.getUint32(20),
length: view.getUint32(24),
};
/* we don't support writes, so nothing needs the data at present */
/* req.data = buf.slice(28); */
var err = 0;
var consumed = 28;
/* the command handlers return 0 on success, and send their
* own response. Otherwise, a non-zero error code will be
* used as a simple error response
*/
switch (req.type) {
case NBD_CMD_READ:
err = this._handle_cmd_read(req);
break;
case NBD_CMD_DISC:
err = this._handle_cmd_disconnect(req);
break;
case NBD_CMD_WRITE:
/* we also need length bytes of data to consume a write
* request */
if (buf.byteLength < 28 + req.length)
return 0;
consumed += req.length;
err = EPERM;
break;
case NBD_CMD_TRIM:
err = EPERM;
break;
default:
this._log("invalid command 0x" + req.type.toString(16));
err = EINVAL;
}
if (err) {
var resp = this._create_cmd_response(req, err);
this.ws.send(resp);
}
return consumed;
}
this._handle_cmd_read = function (req) {
var offset;
offset = (req.offset_msB * 2 ** 32) + req.offset_lsB;
if (offset > Number.MAX_SAFE_INTEGER)
return ENOSPC;
if (offset + req.length > Number.MAX_SAFE_INTEGER)
return ENOSPC;
if (offset + req.length > file.size)
return ENOSPC;
this._log("read: 0x" + req.length.toString(16) +
" bytes, offset 0x" + offset.toString(16));
var blob = this.file.slice(offset, offset + req.length);
var reader = new FileReader();
// 发送获取到的文件内容
reader.onload = (function (ev) {
var reader = ev.target;
if (reader.readyState != FileReader.DONE)
return;
var resp = this._create_cmd_response(req, 0, reader.result);
this.ws.send(resp);
}).bind(this);
reader.onerror = (function (ev) {
var reader = ev.target;
this._log("error reading file: " + reader.error);
var resp = this._create_cmd_response(req, EIO);
this.ws.send(resp);
}).bind(this);
// 前面的blob加上这个函数,用来读取文件的内容
reader.readAsArrayBuffer(blob);
return 0;
}
this._handle_cmd_disconnect = function (req) {
this._log("disconnect received");
this.stop();
return 0;
}
// NBD处于不同状态下时,对应的处理函数
// 不同的状态由state控制
this.recv_handlers = Object.freeze({
[NBD_STATE_WAIT_CFLAGS]: this._handle_cflags.bind(this),
[NBD_STATE_WAIT_OPTION]: this._handle_option.bind(this),
[NBD_STATE_TRANSMISSION]: this._handle_cmd.bind(this),
});
}