NBNS客户端的C语言实现

接上一篇【网络协议详解1 - NBNS】对NBNS的介绍,这一篇将要讲述使用C语言如何实现一个NBNS客户端,用于向局域网内其它设备发送NBNS NODE STATUS QUERY,并将接收到的RESPONSE信息打印出来。其实也就是一个UDP socket的实例。

UDP socket flowchart

编写一个客户端,首先要清楚它要完成什么任务,进而确定完成任务的方法和步骤。其任务很简单,就是NBNS数据包的发送和接收,也就是一个简单的socket收发进程。实现主要分以下几步:

  1. 组包得到NBNS NODE STATUS QUERY
  2. 发送NBNS NODE STATUS QUERY
  3. 接收RESPONSE并解析

include & define

首先来看看编写该客户端会用到的头文件及相关宏定义

#include <stdio.h>
#include <stdlib.h> /* for exit */
#include <string.h> /* for memset */
#include <unistd.h> /* for close */
#include <sys/socket.h> /* for socket, bind */
#include <sys/time.h> /* for timeval */
#include <netinet/in.h> /* for socksaddr_in */
#include <arpa/inet.h> /* for htons */

#define NBNS_PORT 137
#define NBNS_TYPE_NB 0x0020
#define NBNS_TYPE_NBSTAT 0x0021
#define NBNS_CLASS_IN 0x0001
#define BUFFER_SIZE 1024 /* buffer size for recv packet */
#define IP_SIZE 16 /* length of IP address like 192.168.1.1 */

关于NBNS,其公知的端口号是137,在发送请求时会在目的地址中用到。

组包结构体

根据上一篇的内容,我们可以知道NBNS NODE STATUS的请求包和响应包的数据格式,据此可以编写相应的结构体。

typedef unsigned int uint32;
typedef unsigned short uint16;
typedef unsigned char uint8;

struct nbns_header {
    uint16 tid; /* Transaction ID */
    uint16 flags;
    uint16 question;
    uint16 answer;
    uint16 authority;
    uint16 additional;
} __attribute__ ((packed));

// NBNS NODE STATUS QUERY FORMAT
struct nbns_request {
    struct nbns_header header;

    char qname[34];
    uint16 qtype;
    uint16 qclass;
} __attribute__ ((packed));

struct nbns_name {
    char name[16];
    uint16 flags;
} __attribute__ ((packed));

// NBNS NODE STATUS RESPONSE FORMAT
struct nbns_response_header {
    struct nbns_header header;

    char rname[34];
    uint16 rtype;
    uint16 rclass;

    uint32 ttl;
    uint16 length;
    uint8 num_of_names;
} __attribute__ ((packed));

注意每个结构体后的__attribute__ ((packed)),这是为了告诉编译器不要对其进行对齐优化,保持数据的紧凑性,以防数据解析出错。

名称编解码

在进入正题之前,我们先来看下NetBIOS名称的编解码,这在后续发送请求和解析响应中都会用到。从RFC1001可知,NBNS请求包中的Name是被编码的,在上一篇中已经讲述过了。

编码

对于NODE STATUS QUERY, 通常将NBNS name设为*, 就是一个通配符,使用下面的函数可以对其编码。

// encode "*" to "CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
void encode_name(char *name)
{
    int i;

    name[0] = 0x20; /* length of name */
    name[1] = (('*' >> 4) & 0x0F) + 'A';
    name[2] = ('*' & 0x0F) + 'A';

    for (i = 3; i < 33; i++)
        name[i] = 'A';

    name[i] = '\0'; /* end of name */
}

解码

解码也很简单,就是编码的逆过程。上面的编码函数只针对*,但这里的解码函数却是针对所有满足格式要求的字符串。

void decode_name(char *name, char *dst)
{
    int i;

    if (name[0] != 0x20)
        return;
    for (i = 1; i < 0x20; i+=2)
        dst[i/2] = (((name[i]-'A') << 4) & 0xF0) + ((name[i+1]-'A') & 0x0F);  
    dst[i/2] = '\0';
}

create socket

socket

好了,进入正题。在执行数据收发之前需要创建UDP socket, 这里会用到socket函数。

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

RETURN VALUE
       On success, a file descriptor for the new socket is returned.  
       On error, -1 is returned, and errno is set appropriately.

下面简要介绍下相关参数。

  • domain
    常见的网络通信域domain主要有AF_UNIX, AF_INET, AF_INET6, AF_NETLINK,对于常见的TCP/UDP传输,基于IPv4协议,我们选择AF_INET即可。

  • type
    type代表socket类型,常用的包含针对TCP的SOCK_STREAM、针对UDP的SOCK_DGRAM,以及针对需要访问源网络数据(应用层以下数据)的SOCK_RAW。显然本文使用的是SOCK_DGRAM.

  • protocol
    protocol通常设为0,当然也可以针对协议进行配置,比如UDP对应的IPPROTO_UDP,其中IPPROTO是IP Protocol的缩写。

创建socket

下面来实现一个open_socket函数,用于打开一个socket,可以把它当做一个信封,等待后面写好信件放进去。

int open_socket(void)
{
    int sock;
    struct sockaddr_in saddr;

    sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if (sock < 0)
        handle_error("socket");

    memset(&saddr, 0, sizeof(saddr));
    saddr.sin_family = AF_INET;
    saddr.sin_port = 0;
    saddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(sock, (struct sockaddr *)&saddr, sizeof(saddr)) == -1)
        handle_error("bind");

    return sock;
}

这里要注意几点:

  1. 网络字节序和主机字节序不一定一样,需要使用htonl, htons函数进行转换
    • h - host
    • n - network
    • s - short 2个字节
    • l - long 4个字节
  2. 源地址的端口port设为0,代表由网络协议栈分配
  3. bind函数用于绑定发送地址,类似于往信封上写好寄信人的地址信息
  4. handle_error是一个异常处理函数,检测到错误后调用其打印错误信息并退出程序。

发送请求

sendto

发送函数包括组包和发送两部分,将请求信息按照组包格式逐一赋值,然后通过sendto发送数据包。

#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

RETURN VALUE
       On success, these calls return the number of bytes sent.
       On error, -1 is returned, and errno is set appropriately.

发送成功则返回真实的发送字节数,否则返回-1并更新errno.

发送数据

int send_nbns_query(int sock, struct in_addr dest_ip)
{
    struct sockaddr_in dest;
    struct nbns_request req;

    memset(&dest, 0, sizeof(dest));
    dest.sin_family = AF_INET;
    dest.sin_port = htons(NBNS_PORT);
    dest.sin_addr = dest_ip;

    req.header.tid = htons(1);
    req.header.flags = htons(0x0000);
    req.header.question = htons(1);
    req.header.answer = 0;
    req.header.authority = 0;
    req.header.additional = 0;
    encode_name(req.qname);
    req.qtype = htons(NBNS_TYPE_NBSTAT);
    req.qclass = htons(NBNS_CLASS_IN);

    return sendto(sock, (char *)&req, sizeof(req), 0,
           (struct sockaddr *)&dest, sizeof(dest));
}

注意,除了0、单字节数据、单字节数组外,其余数据都需要使用htonshtonl进行转换,将主机字节序转换为网络字节序。

接收响应

recvfrom

发送请求后,便可以使用recvfrom函数接收响应数据了。

#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

RETURN VALUE
    These calls return the number of bytes received, or -1 if an error occurred.
    In the event of an error, errno is set to indicate the error.
    Datagram sockets in various domains permit zero-length datagrams.
    When such a datagram is received, the return value is 0.

接收成功返回接收到的字节数,否则返回-1。当然如何接收字节为0则返回0.

接收数据

void recv_nbns_response(int sock)
{
    struct timeval tv;
    struct sockaddr_in src_addr;
    socklen_t src_len = sizeof(src_addr);
    ssize_t recv_len = 0;
    char res_buf[BUFFER_SIZE];
    char src_ip[IP_SIZE] = {0};

    tv.tv_sec = 5;
    tv.tv_usec = 0;
    /* set timeout 5s for some not exist PC */
    if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0)
        handle_error("setsockopt");
    recv_len = recvfrom(sock, (char *)res_buf, BUFFER_SIZE, 0,
        (struct sockaddr *)&src_addr, &src_len);
    if (recv_len < 0)
        handle_error("recvfrom");
    else if (recv_len < sizeof(struct nbns_response_header))
        return;

    inet_ntop(AF_INET, (struct in_addr *)&src_addr.sin_addr,
        (char *)&src_ip, IP_SIZE);
    printf("recv response from %s : %ld Bytes\n", src_ip, recv_len);
    show_nbns_response(res_buf);
}

该函数通过recvfrom接收数据并存入字符数组res_buf中,同时可以保存发送方的地址信息并通过inet_ntop转换为点分十进制IP地址。最后通过调用函数show_nbns_response打印解析数据。

接收过程中,要考虑到数据来源的一方不存在的情况,也就是永远收不到数据的情况,比如请求IP地址为192.168.1.10的设备,但此刻该设备关机了。为此,我们需要设置超时时间,通过setsockopt函数可以配置socket相关的可选项,当发生超时时,进行错误处理并退出。

打印输出

接收数据后会调用show_nbns_response函数进行打印输出。

void show_nbns_response(char *buf)
{
    struct nbns_response_header *res;
    struct nbns_name *pname, *pend;
    char rname[18] = {0};
    char echar = '\0';

    res = (struct nbns_response_header *)buf;
    decode_name(res->rname, rname);

    printf(
        "Transaction: %d\n" \
        "Flags: 0x%04x\n" \
        "Questions: %d\n" \
        "Answers: %d\n" \
        "Authority: %d\n" \
        "Additional: %d\n" \
        "RR_Name: %s (%s)\n" \
        "RR_Type: %s\n" \
        "RR_Class: %s\n" \
        "TTL: %d\n" \
        "Data length: %d\n" \
        "\nNumber of names: %d\n",
        ntohs(res->header.tid),
        ntohs(res->header.flags),
        ntohs(res->header.question),
        ntohs(res->header.answer),
        ntohs(res->header.authority),
        ntohs(res->header.additional),
        res->rname + 1, rname,
        ntohs(res->rtype) == NBNS_TYPE_NBSTAT ? "NBSTAT (33)" : "NB (32)",
        ntohs(res->rclass) == NBNS_CLASS_IN ? "IN (1)" : "UNKNOWN",
        ntohl(res->ttl),
        ntohs(res->length),
        res->num_of_names);

    pname = (struct nbns_name *)&buf[sizeof(struct nbns_response_header)];
    pend = pname + res->num_of_names;
    printf("---------------------------\n");
    for (; pname < pend; pname++ ) {
        /* ignore the last byte of name*/
        echar = pname->name[15];
        pname->name[15] = '\0';
        printf("Name: %s<%02x>\n", pname->name, echar);
        printf("Name flags: 0x%04x\n", ntohs(pname->flags));
        printf("---------------------------\n");
    }
}

由于不清楚发送方究竟会发送多少组名称,所以先从缓存数组中读取头部信息,然后根据头部信息提供的num_of_names确定名称格式,再逐一打印出来。读取数据的过程就像是收件方接收到信件然后进行阅读一样。

注意,这里有用到ntohs, ntohl进行字节序的转换,同时有用到decode_name进行名称的解码。

异常处理

最后简单描述下异常处理函数handle_error, 其实很简单,就是在发生错误的情况下关闭资源,打印错误信息,最后退出程序。

int nbns_sock; /* global value */
static inline void handle_error(char *msg)
{
    if (nbns_sock >= 0)
        close(nbns_sock);
    perror(msg);
    exit(EXIT_FAILURE);
}

测试

程序编写完成,使用gcc编译即可,下面给出测试用例↓

➜ nbnstat 192.168.1.1
Send nbns query to 192.168.1.1
recv response from 192.168.1.1 : 247 Bytes
Transaction: 1
Flags: 0x8580
Questions: 0
Answers: 1
Authority: 0
Additional: 0
RR_Name: CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (*)
RR_Type: NBSTAT (33)
RR_Class: IN (1)
TTL: 0
Data length: 191

Number of names: 8
---------------------------
Name: SMBSHARE       <00>
Name flags: 0x4400
---------------------------
Name: SMBSHARE       <03>
Name flags: 0x4400
---------------------------
Name: SMBSHARE       <1f>
Name flags: 0x4400
---------------------------
Name: SMBSHARE       <20>
Name flags: 0x4400
---------------------------
Name: __MSBROWSE__<01>
Name flags: 0xc400
---------------------------
Name: WORKGROUP      <00>
Name flags: 0xc400
---------------------------
Name: WORKGROUP      <1d>
Name flags: 0x4400
---------------------------
Name: WORKGROUP      <1e>
Name flags: 0xc400
---------------------------

除了flags信息使用原有的16进制表示外,其余数据均以整型或字符串形式显示。

上面是正常情况,下面给出两个异常情况的例子。

➜  nbnstat 192.168.1.10
Send nbns query to 192.168.1.10
recvfrom: Resource temporarily unavailable
➜  nbnstat 192.16
Not in presentation format
  1. 第1个是指定IP设备不存在,然后产生超时错误;
  2. 第2个是指定IP格式不正确,提示格式错误。

小结

本文介绍了一个简单NBNS客户端的具体实现,该客户端用于向指定IP设备发送NBNS NODE STATUS QUERY,并对请求结果予以分析和打印。涉及到的Socket API函数有socket, bind, sendto, recvfrom, htons, htonl, ntohs, ntohl, inet_pton, setsockopt等。