织梦CMS - 轻松建站从此开始!

罗索

Proxy源代码分析--谈谈如何学习linux网络编程(2)

jackyhwei 发布于 2010-10-19 11:21 点击:次 
struct hostent { char *h_name; char **h_aliases; int h_addrtype; int h_length; char **h_addr_list; }; #define h_addr h_addrlist[0] ----------------------------------------------------------------- hos
TAG:


struct hostent {
char *h_name;
char **h_aliases;
int h_addrtype;
int h_length;
char **h_addr_list;
};
#define h_addr h_addrlist[0]
-----------------------------------------------------------------
hostent成员的含义是h_name代表主机在网络上的的正式名称,h_aliases是所有主机别名的列表,h_addrtype是指主机的地 址类型,一般设置为TCP/IP协议族AF_INET,h_length是主机的地址长度,一般设置为4个字节。h_addr_list是主机的IP地址 列表。
我们要用它来传递我们期望绑定的远程主机名或是IP地址。因为命令行中的主机名参数已经被存储进pargs.isolated_host,所以我们就 调用inet_addr()函数对主机名或主机的IP地址进行二进制和字节顺序转换。inet_addr()函数的描述为:
-----------------------------------------------------------------
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
unsigned long int inet_addr(const char *cp)
-----------------------------------------------------------------
inet_addr()的作用就是将参数cp指向的Internet主机地址从数字/点的形式转换成二进制形式并同时转换为网络字节顺序,并将转换结果直接返回。如果cp指向的IP地址不可用,则函数返回INADDR_NONE或"-1"。
虽然Carl Harris在编写这段程序时使用了这个inet_addr()函数,但是我还是建议大家在编写自己的程序时使用另外一个函数inet_aton()来完 成这些功能。原因是inet_addr()在IP地址不可用时返回"-1",但我们想想,IP地址255.255.255.255绝对是一个有效地址,那 么其二进制返回值也将是"-1",因此inet_addr()无法对这个IP地址进行处理。而函数inet_aton()则采用了一种更好的方法来返回出 错信息,它的具体描述为:
-----------------------------------------------------------------
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp)
-----------------------------------------------------------------
函数执行成功时返回非零,转换结果存入指针inp指向的in_addr结构。这个结构定义我们在前面的文章里已经介绍过了。如果参数cp指向的IP地址不可用,则返回"0"。这就避免发生inet_addr()那样的问题。
如果说用户在命令行中键入的是远程主机的IP地址,那么只用inet_addr()就算完成任务了,但如果用户键入的是主机域名那该怎么办呢?所以我们在例程中可以看到这样的语句:
-----------------------------------------------------------------
if ((inaddr = inet_addr(pargs.isolated_host)) != INADDR_NONE)
bcopy(&inaddr,&hostaddr.sin_addr,sizeof(inaddr));
else if ((hostp = gethostbyname(pargs.isolated_host)) != NULL)
bcopy(hostp->h_addr,&hostaddr.sin_addr,hostp->h_length);
else {
printf("%s: unknown host\r\n",pargs.isolated_host);
exit(1);
}
-----------------------------------------------------------------
其中gethostbyname()函数就是用来转换主机域名的。它的具体描述为:
-----------------------------------------------------------------
#include <netdb.h>
struct hostent *gethostbyname(const char *hostname);
-----------------------------------------------------------------
参数hostname指向我们需要转换的域名地址,函数直接返回转换结果,如果函数执行成功,则结果直接返回到一个指向hostent结构的指针中,否则返回空指针NULL。
例程就是这样调用inet_addr()和gethostbyname()将命令行参数中的主机域名或是主机IP地址传递给全局变量hostaddr的成员sin_addr以便代理执行函数do_proxy()调用。
下面是传递服务名或是服务端口号。这里要用到结构servent做传递中介,struct servent的详细描述为:
-----------------------------------------------------------------
struct servent {
char *s_name;
char **s_aliases;
int s_port;
char *s_proto;
};
-----------------------------------------------------------------
其各成员的含义是s_name为服务的正式名称,如ftp、http等,s_aliases是服务的别名列表,s_port是服务的端口号,例如在一 般情况下ftp的端口号为21,http服务的端口号为80,注意此端口号应该存储为网络字节顺序,s_proto是应用协议的类型。
例程中使用getservbyname()函数转换命令行参数中的服务名,此函数的详细描述为:
-----------------------------------------------------------------
#include <netdb.h>
struct servent * getservbyname(const char *servname, const char *protoname);
-----------------------------------------------------------------
它的作用就是转换指针servname指向的服务名为相应的整数表示的端口号,参数protoname表示服务使用的协议,例程中protoname 参数的值为TCP_PROTO,这表示使用TCP协议。函数成功时就返回一个struct servent型的指针,其中的s_port成员就是我们关心的服务端口号。如果用户在命令中键入的是端口号而不是服务名,那么和处理代理端口信息一样, 使用下面的语句进行处理:
hostaddr.sin_port = htons(atoi(pargs.service_name));
到这里,命令行的参数已经全部被转换成为网络通信所要求的字节顺序和数字类型,并且存储在三个全局变量中,就等着do_proxy()函数来调用了。

◆daemonize()函数创建守护进程

   在对main()函数进行介绍的时候我就提到过,一般服务器程序在接收客户机连接请求之前,都要创建一个守护进程。守护进程是linux/Unix编程 中一个非常重要的概念,因为在创建一个守护进程的时候,我们要接触到子进程、进程组、会晤期、信号机制以及文件、目录、控制终端等多个概念,因此详细地讨 论一下守护进程,对初学者学习进程间关系是非常有帮助的。下面就是例程中的daemonize()函数:
-----------------------------------------------------------------
/****************************************************************
function:   daemonize
description: detach the server process from the current context, creating a pristine, predictable        environment in which it will execute.
arguments:  servfd file descriptor in use by server.
return value: none.
calls:    none.
globals:   none.
****************************************************************/
void daemonize (servfd)
int servfd;
{
int childpid, fd, fdtablesize;
/* ignore terminal I/O, stop signals */
signal(SIGTTOU,SIG_IGN);
signal(SIGTTIN,SIG_IGN);
signal(SIGTSTP,SIG_IGN);
/* fork to put us in the background (whether or not the user
specified '&' on the command line */
if ((childpid = fork()) < 0) {
fputs("failed to fork first child\r\n",stderr);
exit(1);
}
else if (childpid > 0)
exit(0); /* terminate parent, continue in child */
/* dissociate from process group */
if (setpgrp(0,getpid())<0) {
fputs("failed to become process group leader\r\n",stderr);
exit(1);
}
/* lose controlling terminal */
if ((fd = open("/dev/tty",O_RDWR)) >= 0) {
ioctl(fd,TIOCNOTTY,NULL);
close(fd);
}
/* close any open file descriptors */
for (fd = 0, fdtablesize = getdtablesize(); fd < fdtablesize; fd++)
if (fd != servfd)
close(fd);
/* set working directory to allow filesystems to be unmounted */
chdir("/");
/* clear the inherited umask */
umask(0);
/* setup zombie prevention */
signal(SIGCLD,(Sigfunc *)reap_status);
}
-----------------------------------------------------------------

此函数的作用就是创建一个守护进程。在Linux系统中,如果要将一个普通进程转换成为守护进程,必须要执行下面的步骤:
1. 调用函数fork()创建子进程,然后父进程终止,保留子进程继续运行。之所以要让父进程终止是因为,当一个进程是以前台进程方式由shell启动时,在 父进程终止之后子进程自动转为后台进程。另外,我们在下一步要创建一个新的会晤期,这就要求创建会晤期的进程不是一个进程组的组长进程。当父进程终止,子 进程运行,这就保证了进程组的组ID与子进程的进程ID不会相等。
函数fork()的定义为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
-----------------------------------------------------------------
该函数被调用一次,但是返回两次,这两次返回的区别是子进程的返回值为"0",而父进程的返回值为子进程的ID。如果出错则返回"-1"。

2. 保证进程不会获得任何控制终端。通常的做法是调用函数setsid()创建一个新的会晤期。setsid()的详细描述为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <unistd.h>
pid_t setsid(void);
-----------------------------------------------------------------
第一步的操作已经保证调用此函数的进程不是进程组的组长,那么此函数将创建一个新的会晤,其结果是:首先,此进程变成该会晤期的首进程 (session leader,系统默认会晤期的首进程是创建该会晤期的进程)。而且,此进程是该会晤期中的唯一进程。然后,此进程将成为一个新的进程组的组长进程,新进 程组的组ID就是该进程的进程ID。最后,保证此进程没有控制终端,即使在调用setsid()之前此进程拥有控制终端,在创建会晤期后这种联系也将被解 除。如果调用该函数的进程为一个进程组的组长,那么函数将返回出错信息"-1"。
当然我们还有其他的办法让进程无法获得控制终端,就象例程中所做的那样,
-----------------------------------------------------------------
if ((fd = open("/dev/tty",O_RDWR)) >= 0) {
ioctl(fd,TIOCNOTTY,NULL);
close(fd);
}
-----------------------------------------------------------------
其中/dev/tty是一个流设备,也是我们的终端映射。调用close()函数将终端关闭。

3.信号处理。一般是要忽略掉某些信号。这里就涉及到信号的概念了。信号其实相当于软件中断,Linux/Unix下的信号机制提供了一种处理异步事 件的方法,终端用户键入印发中断的键,或是系统异常发出信号,这都会通过信号处理机制终止一个或多个程序的运行。
不同情况下引发的信号是不同的。不过所有的信号都有自己的名字,所有的名字都是以"SIG"开头的,只是后面有所不同,我们可以通过这些名字了解到系统中到底发生了些什么事。

当信号出现时,我们可以要求系统进行以下三种操作:
◇忽略信号。大多数信号都是采取这种方式进行处理的,在例程中我们就可以见到这种用法。但值得注意的是有两个例外,那就是对SIGKILL和SIGSTOP信号不能做忽略处理。
◇捕捉信号。这是一种最为灵活的操作方式。这种处理方式的意思就是,当某种信号发生时,我们可以调用一个函数对这种情况进行相应的处理。最常见的情况 就是,如果捕捉到SIGCHID信号,则表示子进程已经终止,然后可在此信号的捕捉函数中调用waitpid()函数取得该子进程的进程ID以及它的终止 状态。在我们这段例程中,就有这种用法的一个实例。还有就是如果进程创建了临时文件,那么就要为进程终止信号SIGTERM编写一个信号捕捉函数来清除这 些临时文件。
◇执行系统的默认动作。对绝大多数信号而言,系统的默认动作都是终止该进程。
在Linux下,信号有很多种,我在这里就不一一介绍了,如果想详细地对这些信号进行了解,可以查看头文件<sigal.h>,这些信号 都被定义为正整数,也就是它们的信号编号。在对信号进行处理时,必须要用到函数signal(),此函数的详细描述为:
-----------------------------------------------------------------
#include <signal.h>
void (*signal (int signo, void (*func)(int)))(int);
-----------------------------------------------------------------
其中参数signo为信号名,参数func的值根据我们的需要可以是以下几种情况:(1)常数SIG_DFL,表示执行系统的默认动作。(2)常数 SIG_IGN,表示忽略信号。(3)收到信号后需要调用的处理函数的地址,此信号捕捉程序应该有一个整型参数但是没有返回值。signal()函数返回 一个函数指针,而该指针指向的函数应该无返回值(void),这个指针其实指向以前的信号捕捉程序。
下面 回到我们的daemonize()函数上来。这个函数在创建守护进程时忽略了三个信号:
signal(SIGTTOU,SIG_IGN);
signal(SIGTTIN,SIG_IGN);
signal(SIGTSTP,SIG_IGN);
这三个信号的含义分别是:SIGTTOU表示后台进程写控制终端,SIGTTIN表示后台进程读控制终端,SIGTSTP表示终端挂起。

4.关闭不再需要的文件描述符,并为标准输入、标准输出和标准错误输出打开新的文件描述符(也可以继承父进程的标准输入、标准输出和标准错误输出文件 描述符,这个操作是可选的)。在我们这段例程中,因为是代理服务器程序,而且是在执行了listen()函数之后执行这个daemonize()的,所以 要保留已经转换成功的倾听套接字,所以我们可以见到这样的语句:
if (fd != servfd)
close(fd);

5.调用函数chdir("/")将当前工作目录更改为根目录。这是为了保证我们的进程不使用任何目录。否则我们的守护进程将一直占用某个目录,这可能会造成超级用户不能卸载一个文件系统。

6.调用函数umask(0)将文件方式创建屏蔽字设置为"0"。这是因为由继承得来的文件创建方式屏蔽字可能会禁止某些许可权。例如我们的守护进程 需要创建一组可读可写的文件,而此守护进程从父进程那里继承来的文件创建方式屏蔽字却有可能屏蔽掉了这两种许可权,则新创建的一组文件其读或写操作就不能 生效。因此要将文件方式创建屏蔽字设置为"0"。
在daemonize()函数的最后,我们可以看到这样的信号捕捉处理语句:
signal(SIGCLD,(Sigfunc *)reap_status);
这不是创建守护进程过程中必须的一步,它的作用是调用我们自定义的reap_status()函数来处理僵死进程。reap_status()在例程中的定义为:
-----------------------------------------------------------------
/****************************************************************
function:    reap_status
description:   handle a SIGCLD signal by reaping the exit status of the perished child, and            discarding it.
arguments:    none.
return value:  none.
calls:      none.
globals:     none.
****************************************************************/
void reap_status()
{
int pid;
union wait status;
while ((pid = wait3(&status,WNOHANG,NULL)) > 0)
; /* loop while there are more dead children */
}
-----------------------------------------------------------------
上面信号捕捉语句的原文为:
signal(SIGCLD, reap_status);
我们刚才说过,signal()函数的第二个参数一定要有有一个整型参数但是没有返回值。而reap_status()是没有参数的,所以原来的语句 在编译时无法通过。所以我在预编译部分加入了对Sigfunc()的类型定义,在这里用做对reap_status进行强制类型转换。而且在BSD系统中 通常都使用SIGCHLD信号来处理子进程终止的有关信息,SIGCLD是System V中定义的一个信号名,如果将SIGCLD信号的处理方式设定为捕捉,那么内核将马上检查系统中是否存在已经终止等待处理的子进程,如果有,则立即调用信 号捕捉处理程序。
一般在信号捕捉处理程序中都要调用wait()、waitpid()、wait3()或是wait4()来返回子进程的终止状态。这些"等待"函数的 区别是,当要求函数"等待"的子进程还没有终止时,wait()将使其调用者阻塞;而在waitpid()的参数中可以设定使调用者不发生阻塞,wait ()函数不被设置为等待哪个具体的子进程,它等待调用者所有子进程中首先终止的那个,而在调用waitpid()时却必须在参数中设定被等待的子进程 ID。而wait3()和wait4()的参数分别比wait()和waitpid()还要多一个"rusage"。例程中的reap_status() 就调用了函数wait3(),这个函数是BSD系统支持的,我们把它和wait4()的定义一起列出来:
-----------------------------------------------------------------
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
-----------------------------------------------------------------
其中指针statloc如果不为"NULL",那么它将指向返回的子进程终止状态。参数pid是我们指定的被等待的子进程的进程ID。参数 options是我们的控制选择项,一般为WNOHANG或是WUNTRACED。例程中使用了选项WNOHANG,意即如果不能立即返回子进程的终止状 态(譬如由于子进程还未结束),那么等待函数不阻塞,此时返回"0"。      WUNTRACED选项的意思是如果系统支持作业控制,如果要等待的子进程的状态已经暂停,而且其状态自从暂停以来还从未报告过,则返回其状 态。参数rusage如果不为"NULL",则它将指向内核返回的由终止进程及其所有子进程使用的资源摘要,该摘要包括用户CPU时间总量、缺页次数、接 收到信号的次数等。

◆代理服务程序do_proxy()

  在例程main()函数快要结束时,我们看到,在服务器接受了客户机的连接请求后,将为其创建子进程,并在子进程中执行代理服务程序do_proxy()。
-----------------------------------------------------------------/****************************************************************
function:    do_proxy
description:  does the actual work of virtually connecting a client to the telnet service on the          isolated host.
arguments:   usersockfd socket to which the client is connected. return value: none.
calls:     none.
globals:     reads hostaddr.
****************************************************************/
void do_proxy (usersockfd)
int usersockfd;
{
int isosockfd;
fd_set rdfdset;
int connstat;
int iolen;
char buf[2048];
/* open a socket to connect to the isolated host */
if ((isosockfd = socket(AF_INET,SOCK_STREAM,0)) < 0)
errorout("failed to create socket to host");
/* attempt a connection */
connstat = connect(isosockfd,(struct sockaddr *) &hostaddr, sizeof(hostaddr));
switch (connstat) {
case 0:
break;
case ETIMEDOUT:
case ECONNREFUSED:
case ENETUNREACH:
strcpy(buf,sys_myerrlist[errno]);
strcat(buf,"\r\n");
write(usersockfd,buf,strlen(buf));
close(usersockfd);
exit(1);
/* die peacefully if we can't establish a connection */
break;
default:
errorout("failed to connect to host");
}
/* now we're connected, serve fall into the data echo loop */
while (1) {
/* Select for readability on either of our two sockets */
FD_ZERO(&rdfdset);
FD_SET(usersockfd,&rdfdset);
FD_SET(isosockfd,&rdfdset);
if (select(FD_SETSIZE,&rdfdset,NULL,NULL,NULL) < 0)
errorout("select failed");
/* is the client sending data? */
if (FD_ISSET(usersockfd,&rdfdset)) {
if ((iolen = read(usersockfd,buf,sizeof(buf))) <= 0)
break; /* zero length means the client disconnected */
rite(isosockfd,buf,iolen);
/* copy to host -- blocking semantics */
}
/* is the host sending data? */
if (FD_ISSET(isosockfd,&rdfdset)) {
f ((iolen = read(isosockfd,buf,sizeof(buf))) <= 0)
break; /* zero length means the host disconnected */
rite(usersockfd,buf,iolen);
/* copy to client -- blocking semantics */
}
}
/* we're done with the sockets */
close(isosockfd);
lose(usersockfd);
}
-----------------------------------------------------------------
在我们这段代理服务器例程中,真正连接用户主机和远端主机的一段操作,就是由这个do_proxy()函数来完成的。回想一下我们一开始对这段 proxy程序用法的介绍。先将我们的proxy与远端主机绑定,然后用户通过proxy的绑定端口与远端主机建立连接。而在main()函数中,我们的 proxy由一段服务器程序与用户主机建立了连接,而在这个do_proxy()函数中,proxy将与远端主机的相应服务端口(由用户在命令行参数中指 定)建立连接,并负责传递用户主机和远端主机之间交换的数据。
由于要和远端主机建立连接,所以我们看到do_proxy()函数的前半部分实际上相当于一段标准的客户机程序。首先创建一个新的套接字描述符 isosockfd,然后调用函数connect()与远端主机之间建立连接。函数connect()的定义为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *servaddr, int addrlen);
-----------------------------------------------------------------
参数sockfd是调用函数socket()返回的套接字描述符,参数servaddr指向远程服务器的套接字地址结构,参数addrlen指定这个 套接字地址结构的长度。函数connect()执行成功时返回"0",如果执行失败则返回"-1",并将全局变量errno设置为相应的错误类型。在例程 中的switch()函数调用中对以下三种出错类型进行了处理: ETIMEDOUT、ECONNREFUSED和ENETUNREACH。这三个出错类型的意思分别为:ETIMEDOUT代表超时,产生这种情况的原因 有很多,最常见的是服务器忙,无法应答客户机的连接请求;ECONNREFUSED代表连接拒绝,即服务器端没有准备好的倾听套接字,或是没有对倾听套接 字的状态进行监听;ENETUNREACH表示网络不可达。
在本例中,connect()函数的第二个参数servaddr是全局变量hostaddr,其中存储着函数parse_args()转换好的命令行 参数。如果连接建立失败,在例程中就调用我们自定义的函数errorout()输出信息"failed to connect to host"。errorout()函数的定义为:
-----------------------------------------------------------------
/****************************************************************
function:  errorout
description: displays an error message on the console and kills the current process.
arguments:  msg -- message to be displayed.
return value: none -- does not return.
calls:    none.
globals:   none.
****************************************************************/
void errorout (msg)
char *msg;
{
FILE *console;
console = fopen("/dev/console","a");
fprintf(console,"proxyd: %s\r\n",msg);
fclose(console);
exit(1);
}
-----------------------------------------------------------------
do_proxy()函数的后半部分是通过proxy建立用户主机与远端主机之间的连接。我们既有proxy与用户主机连接的套接字 (do_proxy()函数的参数usersockfd),又有proxy与远端主机连接的套接字isosockfd,那么最简单直接的通信建立方式就是 从一个套接字读,然后直接写到另一个套接字去。如:
-----------------------------------------------------------------
int n;
char buf[2048];
while((n=read(usersockfd, buf, sizeof(buf))>0)
if(write(isosockfd, buf, n)!=n)
err_sys("write wrror\n");
-----------------------------------------------------------------
这种形式的阻塞I/O在单向数据传递的时候是非常有效的,但是在我们的proxy操作中是要求用户主机和远端主机双向通信的,这样就要求我们对两个套 接字描述符既能够读由能够写。如果还是采用这种方式的阻塞I/O的话,很有可能长时间阻塞在一个描述符上。因此例程在处理这个问题的时候调用了 select()函数,这个函数允许我们执行I/O多路转接。其具体含义就是select()函数可以构造一个表,在这个表中包含了我们所有要用到的文件 描述符。然后我们可以调用一个函数,这个函数可以检测这些文件描述符的状态,当某个(我们指定的)文件描述符准备好进行I/O操作时,此函数就返回,告知 进程哪个文件描述符已经可以执行I/O操作了。这样就避免了长时间的阻塞。
还有一个函数poll()可以实现I/O多路转接,由于在例程中调用的是select(),我们就只对select()进行一下比较详细的介绍。select()系列函数的详细描述为:
-----------------------------------------------------------------
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int n, fd_set *readfds, fd_set *writefds, fd_est *exceptfds, struct timeval *timeout);
FD_CLR(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);
-----------------------------------------------------------------
select()函数将创建一个我们所关心的文件描述符表,它的参数将在内核中为这些文件描述符设置我们所关心的条件,例如是否是可读、是否可写以及 是否异常,而且在参数中还可以设置我们希望等待的最大时间。在select()成功执行时,它将返回目前已经准备好的描述符数量,同时内核可以告诉我们各 个描述符的状态信息。如果超时,则返回"0",如果出错,则函数返回"-1",并同时设置errno为相应的值。
select()的最后一个参数timeout将设置等待时间。其中结构timeval是在文件<bits/time.h>中定义的。
-----------------------------------------------------------------
struct timeval
{
__time_t tv_sec; /* Seconds */
__time_t tv_usec; /* Microseconds */
};
-----------------------------------------------------------------
参数timeout的设置有三种情况。象例程中这样timeout==NULL时,这表示用户希望永远等待,直到我们指定的文件描述符中的一个已准备 好,或者是捕捉到一个信号。如果是由于捕捉到信号而中断了这个无限期的等待过程的话,select()将返回"-1",同时设置errno的值为 EINTR。
如果timeout->tv_sec==0&&timeout->tv_usec==0,那么这表示完全不等待。 Select()测试了所有指定文件描述符后立即返回。这是得到多个描述符状态而不阻塞select()函数的轮询方法。 (qipnx)

本站文章除注明转载外,均为本站原创或编译欢迎任何形式的转载,但请务必注明出处,尊重他人劳动,同学习共成长。转载请注明:文章转载自:罗索实验室 [http://www.rosoo.net/a/201010/10335.html]
本文出处:CSDN博客 作者:qipnx
顶一下
(0)
0%
踩一下
(0)
0%
------分隔线----------------------------
发表评论
请自觉遵守互联网相关的政策法规,严禁发布色情、暴力、反动的言论。
评价:
表情:
用户名: 验证码:点击我更换图片
栏目列表
将本文分享到微信
织梦二维码生成器
推荐内容