开启掘金生长之旅!这是我参加「掘金日新计划 12 月更文挑战」的第6天,点击查看活动概况
完成: 运用C语言完成最简略的HTTP服务器 (源代码附在文章底部)
要求:
- 一起支撑HTTP(80端口)和HTTPS(443端口)运用两个线程别离监听各自端口
- 只需支撑GET办法,解析恳求报文,回来相应应对及内容
- 支撑的状况码
试验流程:
- 依据上述要求,完成HTTP服务器程序
- 执行sudo python topo.py指令,生成包括两个端节点的网络拓扑
- 在主机h1上运转HTTP服务器程序,一起监听80和443端口 h1 # ./http-server
- 在主机h2上运转测验程序,验证程序正确性 h2 # python3 test/test.py 假如没有呈现AssertionError或其他过错,则阐明程序完成正确
- 在主机h1上运转http-server,所在目录下有一个小视频(30秒左右)
- 在主机h2上运转vlc(留意切换成普通用户),经过网络获取并播映该小视频 媒体 -> 翻开网络串流 -> 网络 -> 请输入网络URL -> 播映
socket编程基础
名词解析:socket有“插座”的意思,在linux环境下socket是进程间网络通讯的特殊文件类型。编程中一般运用文件描述符fd来引用套接字,fd是一个int类型的文件描述符。简略解说下socket怎么使得两个进程彼此通讯。 socket端:
- 运用sockaddr_in结构创立服务器地址server_addr,对地址进行初始化
- 设置地址的协议族,IP号、端口号
- 创立一个socket,并回来到sfd,sfd此时能引用这个server sockt
- 将server_addr和服务端文件描述符(sfd)绑定起来
- 运用listen函数对sfd进行监听,也便是对指定的IP和端口号进行监听
- 堵塞等候接纳客户端的恳求(客户端只要恳求正确的IP和端口才干正常衔接服务器,所以客户端会有一个和服务器端相同的server_addr)
- 比及client拿自己的cfd(客户端文件描述符)去衔接server,server会新建一个socket ,这个socket指向connfd, server可以read这个connfd的数据,然后再将信息write到这个connfd上,connfd和cfd实际上指向的是同一个socket,客户端可以从这个cfd上读取server写进去的数据。这样就完成了c/s的通讯。
- 封闭socket衔接
client端:
- 创立socket,回来到cfd
- 运用sockaddr_in结构创立服务器地址server_addr,对地址进行初始化。并将成员值设置为和上面server_addr相同
- 运用cfd和server_addr作为参数去衔接指定的server端口。
- 衔接成功后,client能向cfd指向的socket写数据,server树立衔接后会创立新的socket,这个socket实际和cfd指向的是socket是一致的。client可以向这个socket写入数据和读取数据。
- 封闭socket衔接
经过socket完成能传输简略信息的http服务器
server端
int main() {
struct sockaddr_in server_addr, client_addr;
int listenfd, connfd; //监听套接字和衔接套接字
char buffer[MAXLINE], first_line[MAXLINE], left_line[MAXLINE], method[MAXLINE], url[MAXLINE], version[MAXLINE];
char str[INET_ADDRSTRLEN];
socklen_t client_addr_len;
char filename[MAXLINE];
long n;
int i, pid;
listenfd = socket(AF_INET, SOCK_STREAM, 0); //创立套接字
bzero(&server_addr, sizeof(server_addr)); //初始化server_addr
server_addr.sin_family = AF_INET; //设置协议族(IPV4)
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);//具体IP地址
server_addr.sin_port = htons(PORT1); //设置端口号
printf("threadID: %d, server_addr: %s, port: %d", pthread_self(), inet_ntop(AF_INET, &server_addr.sin_addr, str, sizeof(str)), ntohs(server_addr.sin_port));
int bind_status = bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr)); //绑定套接字
if(bind_status < 0) printf("bind error!\n");
int listen_status = listen(listenfd, 20); //监听套接字,等候用户发起恳求
if(listen_status < 0) printf("listen error!\n");
printf("Accepting connections ...");
while(1) {
//堵塞等候承受客户端的恳求
client_addr_len = sizeof(client_addr);
connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len); //承受客户端的恳求
n = read(connfd, buffer, MAXLINE);
if (n == 0) {
printf("client has been closed");
break;
}
printf("port %d Received from %s at PORT %d, message is %s\n",
ntohs(server_addr.sin_port),
inet_ntop(AF_INET, &client_addr.sin_addr, str, sizeof(str)),
ntohs(client_addr.sin_port), buffer);
//解析恳求报文
for (int i = 0; i < n; i++) {
buffer[i] = toupper(buffer[i]);
}
write(connfd, buffer, n);
close(connfd);
}
}
为了便利测验,在topo.py中将两个host改为3个host,并设置衔接。修正如下 (topo.py用来设置网络参数并发动mininet,参见网络广播试验/post/717200…
class MyTopo(Topo):
def build(self):
h1 = self.addHost('h1')
h2 = self.addHost('h2')
h3 = self.addHost('h3')
s1 = self.addSwitch('s1')
self.addLink(h1, s1, bw=100, delay='10ms')
self.addLink(h2, s1)
self.addLink(h3, s1)
在h1中运转./server,在h2和h3中别离运转./client1和./client2。client1和client2的区别是恳求的端口不同,但主机IP是相同的,可以用来测验server是否能一起检测两个端口。结果展现如下
client1.c如下所示
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//read办法需求的头文件
#include <unistd.h>
//socket办法需求的头文件
#include <sys/socket.h>
#include <sys/types.h>
//htonl 办法需求的头文件
#include <netinet/in.h>
//inet_ntop办法需求的头文件
#include <arpa/inet.h>
#define MAXLINE 100
#define CLI_PORT 80
//webserver 主程序
int main(int argc, const char * argv[]) {
struct sockaddr_in servaddr;
char buf[MAXLINE];
int clientfd;
long n;
//client socket衔接
clientfd = socket(AF_INET, SOCK_STREAM, 0);
char *str = "hello world";
//sockaddr_in结构体初始化
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(CLI_PORT);
//connect()办法
connect(clientfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
//write()办法是client 向 server 写数据
write(clientfd, str, strlen(str));
//最好用strlen,否则server接纳的数据会少
printf("write to server : %s\n",str);
//read()办法是从server接纳数据
n = read(clientfd, buf, sizeof(buf));
//留意最好用sizeof而不是strlen。试验证明用strlen时承受会犯错。bug调试好久才找到
printf("%ld\n",n);
if(n == 0) {
printf("the other side has been close\n");
}else {
printf("Response from server: %s\n",buf);
write(STDOUT_FILENO, buf, n);
printf("\n");
}
close(clientfd);
}
现在客户端能和服务器进行简略的通讯,下一步便是要支撑略微复杂些的通讯。
支撑并发的http服务器
- 首先将socket函数封装为带有容错机制的函数,运转进程中犯错的话可以更好的定位过错。
int Socket(int family, int type, int protocol) {
int sockfd;
if((sockfd = socket(family, type, protocol)) < 0)
{
perror("socket error");
exit(1);
}
return sockfd;
}
void Bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
if(bind(sockfd, addr, addrlen) < 0)
{
perror("bind error");
exit(1);
}
}
void Listen(int sockfd, int backlog) {
if(listen(sockfd, backlog) < 0)
{
perror("listen error");
exit(1);
}
}
int Accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
int connfd;
if((connfd = accept(sockfd, addr, addrlen)) < 0)
{
perror("accept error");
exit(1);
}
return connfd;
}
void Connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
if(connect(sockfd, addr, addrlen) < 0)
{
perror("connect error");
exit(1);
}
}
long Read(int fd, void *buf, size_t count) {
long n;
if((n = read(fd, buf, count)) < 0)
{
perror("read error");
exit(1);
}
return n;
}
void Write(int fd, void *buf, size_t count) {
if(write(fd, buf, count) < 0)
{
perror("write error");
exit(1);
}
}
void Close(int fd) {
if(close(fd) < 0)
{
perror("close error");
exit(1);
}
}
- 使服务器带有并发才干,也便是http server接纳到一个恳求就会fork()一个进程去处理恳求。 下面是部分逻辑代码: 当server Accept一个恳求时,fork一个进程,假如是子进程,则对该恳求处理。假如没有正常fork,pid仍是本来大于0的父进程,则直接封闭socket。
//死循环中进行accept()
while (1) {
cliaddr_len = sizeof(cliaddr);
//accept()函数回来一个connfd描述符
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr,
&cliaddr_len);
pid = fork();
if(pid < 0) {
printf("fork error");
return 1;
} else if(pid == 0) { //pid=0表示子进程
while (1) {
n = Read(connfd, buf, MAXLINE);
if (n == 0) {
printf("the other side has been closed.\n");
break;
}
printf("received from %s at PORT %d,message is %s,\
message size is %ld\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str,
sizeof(str)),
ntohs(cliaddr.sin_port),buf, n);
for (int i = 0; i < n; i++)
buf[i] = toupper(buf[i]);
Write(connfd, buf, n);
}
Close(connfd);
exit(0);
}else { //pid>0表示父进程
Close(connfd);
}
}
从下图可以看出,运用了多进程并发机制后,当有多个恳求时,会fork子进程,服务器端的进程对应也在添加。
支撑html页面的http server
- 制作html页面,试验初始代码中包括国科大官网的页面,部分源码如下:
- 翻开服务器,运用firefox输入 http://localhost:80/index.html ,查看web发到服务器中的内容。 整个头部信息如下所示,在代码中被存在buf中。**留意我们在read() socket时,是要将内容放到buf中,可以设置buf的大小,假如buf设置太小,则需求循环read,直到connfd中的内容被读空
运用下面代码来解析恳求
sscanf(buf, "%s %s %s", method, uri, version); //对buf进行解析
printf("method:%s\n", method);
printf("uri:%s\n", uri);
printf("version:%s\n", version);
- 依据恳求中的url来查找服务器本地文件。假如在文件夹下找到文件名,则获取文件类型,拼接response回来给服务器终端。别的需求读取文件信息并将其输入给浏览器上,首要的函数是mmap(0, sbuf.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
呼应函数response如下所示:
void response(int connfd, char *filename) {
struct stat sbuf; //文件状况结构体
int fd;
char *srcp;
char response[MAXLINE], filetype[20];
if (stat(filename, &sbuf) < 0) {
//文件不存在
sprintf(response, "HTTP/1.1 404 Not Found\r");
exit(1);
}
else {
get_filetype(filename, filetype); //获取文件类型
//Open File
fd = open(filename, O_RDONLY);
//Send response 这是是在进行拼接,一定要回来头部,
//客户端会先识别头部然后对数据部分进行个性化解析
strcat(response, "HTTP/1.1 200 OK\r\n");
strcat(response, "Server: LongXing's Tiny Web Server\r\n");
sprintf(response, "Content-length: %ld\r\n", sbuf.st_size);
sprintf(response, "Content-type: %s\r\n\r\n", filetype);
Write(connfd, response, strlen(response));
printf("Response headers:\n");
printf("%s", response);
//mmap()读取filename 的内容写给浏览器
srcp = mmap(0, sbuf.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
//内存映射,直接将fd指向的文件映射到srcp上,而不先将磁盘上的文件读取到内核缓冲区,
//再从内核缓冲区将文件进程虚拟地址空间。它映射完了就可以直接运用,只要一次读取。
Close(fd);
Write(connfd, srcp, sbuf.st_size);
munmap(srcp, sbuf.st_size);
}
}
现在可以在本地浏览器中输入恳求并得到呼应,由于html中的一些图标文件在服务器本地是缺失的,所以有些文件不会显示。
HTTP恳求和呼应展现
linux虚拟机中HTTP恳求index.html
虚拟机外部的主机浏览器HTTP恳求index.html
支撑HTTPS的服务器
HTTPS基础
现在大部分网站都支撑https,这是一种安全的http协议。不同于HTTP的明文传输,HTTPS运用SSL/TSL来加密数据包,一般是运用协商的对称加密算法和密钥加密,确保数据机密性。假如不运用HTTPS,当攻击者截取了Web浏览器和网站服务器之间的传输报文,就可以直接读懂其间的信息,这对大多用户来说都是不肯面对的。
运用 HTTPS 协议需求到 CA(Certificate Authority,数字证书认证机构) 申请证书,然后在网页服务器代码中进行SSL编程,假如不运用A证书,只运用自己的用户证书(.cert)和用户私钥(.prokey),安全证书不会经过,导致浏览器衔接时需额定确认是否需求进行不安全衔接,在curl时需求加上--insecure参数才干进行恳求。
HTTPS树立进程和HTTP不同,后者只需求三次握手就能树立,而HTTPS需求额定九次SSL握手,所以一共是12个包。因此,HTTP的呼应速度是要比HTTP快。别的,HTTP运用80端口,而HTTPS运用443端口。
HTTPS的恳求树立进程
HTTPS中SSL部分代码
//封装的部分SSL代码
long SSL_Read(SSL *ssl, void *buf, size_t count) {
long n;
if((n = SSL_read(ssl, buf, count)) < 0)
{
perror("read error");
exit(1);
}
return n;
}
void SSL_Write(SSL *ssl, void *buf, size_t count) {
if(SSL_write(ssl, buf, count) < 0)
{
perror("write error");
exit(1);
}
}
//SSL编程流程
//再收到客户端来的https恳求,先accept回来connfd,之后再
//1. 初始化SSL
SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();
//2.加载用户证书和私钥以及对其进行查看
SSL_CTX *ctx = SSL_CTX_new(SSLv23_server_method()); //创立服务端SSL会话环境
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
if(SSL_CTX_use_certificate_file(ctx, "cnlab.cert", SSL_FILETYPE_PEM) <= 0) {
//加载公钥证书
printf("load public key error");
exit(1);
}
printf("加载私钥...\n");
if(SSL_CTX_use_PrivateKey_file(ctx, "cnlab.prikey", SSL_FILETYPE_PEM) <= 0) {
//加载私钥
printf("load private key error");
exit(1);
}
printf("验证私钥...\n");
if(SSL_CTX_check_private_key(ctx) <= 0) {
//查看私钥
printf("check private key error");
exit(1);
}
//3. 依据ctx创立一个ssl
SSL *ssl = SSL_new(ctx);
//4. 将fd与ssl绑定
SSL_set_fd(ssl, fd)
//5.再次进行Accept,运用SSL_accept(ssl)获取客户端的恳求
if(SSL_accept(ssl) == -1) {
ERR_print_errors_fp(stderr);
}
//进行呼应,运用SSL_Read进行读,运用SSL_Write进行写操作
下面是针对HTTPS的呼应函数
void https_response(SSL *ssl, int connfd, char *filename) {
struct stat sbuf; //文件状况结构体
int fd;
char *srcp;
char response[MAXLINE], filetype[20];
if (stat(filename, &sbuf) < 0) {
//文件不存在
sprintf(response, "HTTP/1.1 404 Not Found\r");
printf("找不到文件\n");
exit(1);
}
else {
get_filetype(filename, filetype); //获取文件类型
//Open File
fd = open(filename, O_RDONLY);
//Send response 这是是在进行拼接
strcat(response, "HTTP/1.0 200 OK\r\n");
SSL_Write(ssl, response, strlen(response));
strcat(response, "Server: LongXing's Tiny Web Server\r\n");
SSL_Write(ssl, response, strlen(response));
sprintf(response, "Content-length: %ld\r\n", sbuf.st_size);
SSL_Write(ssl, response, strlen(response));
sprintf(response, "Content-type: %s\r\n\r\n", filetype);
SSL_Write(ssl, response, strlen(response));
printf("Response headers:\n");
printf("%s", response);
//mmap()读取filename 的内容写给浏览器
srcp = mmap(0, sbuf.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
SSL_Write(ssl, srcp, sbuf.st_size);
munmap(srcp, sbuf.st_size);
Close(fd);
}
}
HTTPS恳求和呼应展现
HTTPS在443端口恳求index.html,留意有不安全提示是因为运用了自签证书而不是官方证书
HTTPS在443端口恳求video.mp4
运用telnet模仿浏览器对index.html恳求
一起支撑HTTP和HTTPS的服务器
思路:将支撑HTTPS的代码从新建socket、绑定、监听、Accept、呼应等操作全部封装到https_server函数中。将支撑HTTP恳求的所有代码封装到http_server函数中。在主函数main中,树立两个子进程,子进程1运转http_server函数,子进程2运转https_server函数。这样在同一个服务器中能有两个socket,一个监听支撑HTTP恳求的80端口,别的一个监听支撑HTTPS恳求的443端口。mian函数如下:
int main(int argc, char * argv[]) {
pid_t pid1, pid2;
pid1 = fork();
if(pid1 < 0) printf("fork1 error\n");
if(pid1 == 0) http_server();
pid2 = fork();
if(pid2 < 0) printf("fork2 error\n");
if(pid2 == 0) https_server();
int st1, st2;
waitpid(pid1, &st1, 0);
waitpid(pid2, &st2, 0);
return 0;
}
经过多进程,当客户端进行HTTP恳求,进程1会监听到并进行呼应,当客户端进行HTTPS恳求,进程2会监听到并进行呼应。完成了一起支撑HTTP和HTTPS的服务器,而且可以传输正常HTML页面以及视频流。
遇到的问题
- 不了解socket编程
- 不了解https的作业机制
- 不了解response中报文头部的构造及重要性 在查阅材料和不断试验后,对上述问题有了开始的了解并加以解决了。
总结
构造一个简略的http服务器,尽管试验项目不大,但却使自己对计算机网络教材中的概念更加明晰。实践是检验真理的唯一标准,只要经过实际操作才干检验自己对讲义上的知识是否真的了了解。别的,这次的试验也锻炼自己发现和解决问题的才干。总而言之,获益匪浅。
Github源码地址: github.com/LongxingHu/…