AbelFile

AbelFile只在linux机器上以python3运行。

代码与报告(包括README.md文件的渲染阅读)可以在我的gitdub上找到备份托管:https://github.com/yangminz/AbelFile

通过以下命令可以使用:

cd ~
git clone https://github.com/yangminz/AbelFile.git
cd AbelFile
sudo bash abelEnv.txt
# terminal 1
cd AbelFile/server
python3 run.py
# terminal 2
cd AbelFile/client
python3 run.py

简介

AbelFile是非常简单的类FTP的project与协议。在实现AbelFile时,因为我想增加对服务器的控制功能,在client上直接使用命令执行server的命令,因此不宜使用GUI。虽然用GUI亦无不可,然而终有损韵味。另外,AbelFile有批量执行命令的功能,所以使用GUI也不怎么好。另外,我写AbelFile的初衷是实现一个外观上颇像git或ftp的project,而它们最初都是直接在terminal上运行的。基于上述种种原因,总之没有GUI。

AbelFile的名称来自于伟大的挪威数学家Niels Henrik Abel,他与同时代的法国天才数学家Évariste Galois各自做出了划时代的贡献,为数学开创了抽象代数。为了纪念他,这份project的名称为AbelFile,虽然project和Abel与群论并没有任何关系。

组织

整个project的文件与相应的功能如下:

AbelFile
│  abelbash.txt  # 批量处理命令
│  abelEnv.txt   # 安装环境命令
│  hello.py      # 远程执行脚本样例
│  README.md     # README文件,以本报告为准
│  report.html   # 本报告
│  
├─client     # 客户端
│      basic_ops.py    # 基本命令,UI中与socket无关的命令,bash, help, exit
│      client_ops.py   # 通信命令,cd, ls, push, pull, server
│      config.json     # 配置文件,包括主机地址与控制、数据端口
│      errordict.py    # 错误信息文件,处理用户UI的错误
│      help.txt        # help文件,可以通过help命令在终端打印
│      protocol.py     # 协议脚本,用来设置与读取协议内容
│      run.py          # 执行脚本,用户UI输入并调用命令脚本中的函数
│      tools.py        # 工具脚本,包括AES加密、正则匹配字符串、zip压缩、打印进度条
│      
├─HOSTclient # 客户端虚拟主机
│  │  bing.jpg
│  │  file.txt
│  │  
│  ├─folder0
│  │      file.txt
│  │      
│  └─folder1
│      │  bing.jpg
│      │  file.txt
│      │  
│      └─subfolder
│              file.txt
│              
├─HOSTserver # 服务器虚拟主机
│      placeholder.txt
│      
├─img        # README.md与report.html用到的图
│      server.png
│      tdreading.png
│      
└─server     # 服务器
    │  config.json     # 配置文件,包括主机地址与控制、数据端口
    │  protocol.py     # 协议脚本,用来设置与读取协议内容
    │  run.py          # 执行脚本,开辟线程,与client的通信命令文件中的函数
    │  server_ops.py   # 与用户输入相应的命令
    │  tools.py        # 工具脚本,包括AES加密、正则匹配字符串、zip压缩、打印进度条
    │  
    └─database  # 数据库
            clients.db   # 储存有用户名、密码的简单数据库,用于用户登录
            create.py    # 创造clients.db的脚本
            sql.py       # 查询用户名与密码


其中主要的代码./server和./client两个目录下,./server/database目录存放用户数据库,用作用户登录的确认。HOSTserver和HOSTclient两个目录作为模拟主机,上面是用来通信的文件。在AbelFile主目录下,abelbash.txt是批量运行的命令文件,abelEnv.txt是运行AbelFile所必需的环境,可以通过:

sudo bash abelEnv.txt

以完成安装。

协议

协议非常简单,用一个8位比特串在server和client之间传递控制命令。协议的使用在./server/protocol.py和./client/protocol.py中,这两个文件内容相同,里面只有一个协议类CTRLPTC,它的成员函数控制对协议的修改与读取。














7 6 5 4 3 2 1 0
操作符类型 覆写 终止 目录/文件
0/1 0/1 0/1 0/1 0/1 0/1 0/1 0/1

四位操作符表示client请求server执行的操作类型;覆写表示是否要覆盖现有的文件/目录;终止是client希望server退出当前正在执行的命令,这一位专门是为了远程控制功能存在的;目录/文件类型表明当前传输的是文件还是目录:’00’表示为空,也就是目录/文件不存在,’01’表示传输对象是文件类型,’10’表示传输对象是目录类型。

AbelFile一共有两个端口:控制端口CTRL_PORT,数据端口DATA_PORT,DATA_PORT传输的数据基本都是经过AES加密的,client和server在各自的确认文件config.json中指定。上述的8位字符串只在CTRL_PORT中传输,附带的信息则在DATA_PORT中传输,例如地址、文件内容等。

运行

启动

先后通过执行python3脚本开始启动:

# terminal 1
cd AbelFile/server
python3 run.py
# terminal 2
cd AbelFile/client
python3 run.py

启动时双方都从json文件中读取配置好的主机地址和控制、数据端口号。

多线程循环

server随后会进入一个线程的循环,这是为了方便多线程处理:

def main():
    while True:
        ctrl_conn, ctrl_addr = ctrl_skt.accept()
        data_conn, data_addr = data_skt.accept()
        #while True:
        t = threading.Thread(target=process_thread, args=(ctrl_conn, ctrl_addr, data_conn, data_addr))
        t.start()

多线程处理时,会将相关的变量作为多线程的局部变量threading.local()保存,这样每个线程都有自己的局部变量,就不会引起混乱,例如:

可以看到,在这里,abel先cd到/home/yangminz/AbelFile/server/database,而newton当前在/home/yangminz/AbelFile/server。newton输入cdup时,如果和abel的目录混淆,会切换到/home/yangminz/AbelFile/server,但实际上newton切换到了/home/yangminz/AbelFile,这是每个线程使用自己私有变量的特点,对于多用户的情况下也是非常必要的。

数据库确认

进入线程以后,server会等待client的连接。此时用户会被要求输入用户名和密码。我在数据库中设置了{yangminz, abel, newton, descartes, gauss, einstein, landau, kepler}这些用户名,他们的密码都是’password’。

通过CTRL_PORT和DATA_PORT将用户名和密码传给server以后,server会通过./server/database/sql.py根据json文件中指定的数据库名称进行数据库查询,确认用户是否存在。

UI循环

server和client确认用户存在以后,会初始化三个类:SERVER, BASIC, CLIENT,分别在./server/server_ops.py, ./client/basic_ops, ./client/client_ops.py中:

srv = SERVER(ctrl_conn, data_conn)
AbelFile/server/run.py
clt = CLIENT(ctrl_skt, data_skt)
bsc = BASIC(clt)
AbelFile/client/run.py

这三个class就是UI所在。创建三个class以后,client, server双双进入UI循环:

while True:
    argstr = get_cmd(clt)
    argkey, argvar = argsplit(argstr)
    # run basic operation
    if argkey in bsc.ops.keys():
        bsc.ops[argkey](argvar)
    # run client operation
    elif argkey in clt.ops.keys():
        clt.ops[argkey](argvar)
    else:
        print('%sCMD ERROR! Type \'help\' to check!%s'%(clr.R, clr.E))
AbelFile/client/run.py

其中clr类是tools.py中的颜色控制,因为是linux的终端颜色,所以不知道能不能在windows上使用,我没有试过。颜色有:clr.S (sky blue), clr.Y (yellow), clr.G (grass green), clr.R (red), clr.B (bold), clr.U (underline)

while True:
    print('%s : server dir:%s\n%s : client dir:%s'%(clt_name,srv.SERVER_DIR,clt_name,srv.CLIENT_DIR))
    # receiving request from client
    cmdcode = deAES(ctrl_conn.recv(32))
    print(cmdcode, len(cmdcode))
    time.sleep(1)
    if not cmdcode:
        break
    # split request cmd
    ptc = CTRLPTC(cmdcode)
    # call corresponding function
    print('%s : request'%clt_name,ptc.deop())
    srv.ops[ptc.deop()](ptc)
AbelFile/server/run.py

这样按照UI格式,用户每输入一行命令,会经过正则表达式函数argsplit()分析。argsplit()在tools.py中,它总是分析这样的字符串:

>>> argstr = 'op var1 var2 var3'
>>> argkey, argvar = argsplit(argstr)
>>> argkey
op
>>> argvar
['var1', 'var2', 'var3']

在client中,会根据得到的命令操作符关键字argkey,在BASIC或CLIENT两个类保存的关键字字典中去调用成员函数:

clt.ops[argkey](argvar)

相应的成员函数会通过控制端口CTRL_PORT发送协议命令给server,通过协议类CTRLPTC的成员函数解析出操作符:

srv.ops[ptc.deop()](ptc)

以此调用SERVER类的成员函数作为响应。

SERVER, CLIENT同步目录

在初始化SERVER和CLIENT这两个类的时候,它们会同步目录。这是为了在ls, push, pull, del命令做准备,所以这是必须的。同时,为了方便用户查看目录,需要直接在屏幕上打印地址,因此client知道自己以及server的目录是很重要的。这一步在class的初始化中完成,并且在cd中也需要。

实现的方法就是通过socket互相发送目录:

class CLIENT(object):
    def __init__(self, ctrl_skt, data_skt):
        self.ctrl_skt = ctrl_skt # control transfer socket
        self.data_skt = data_skt # data transfer socket
        # 保存真实的脚本工作目录、client, server目录
        self.WORK_DIR = os.path.dirname(os.path.realpath(__file__))
        self.SERVER_DIR = None
        self.CLIENT_DIR = self.WORK_DIR
        # 控制端口协议
        self.ptc = CTRLPTC('00000000')
        # 同步client, server目录
        self.syn_dir()
        # UI操作符字典
        self.ops = {'cd' : self.cd, 'ls' : self.ls,
                    'pull' : self.pull, 'push' : self.push,
                    'del':self.delete,
                    'server' : self.server,
                   }

    def syn_dir(self):
        # 设置协议操作符为‘同步目录’
        self.ptc.enop('syndir')
        # 发送协议与本地client目录
        self.ctrl_skt.send(enAES(self.ptc.tostr())) # 用来启动server的syndir()成员函数
        self.data_skt.send(enAES(self.CLIENT_DIR))
        # 设置server目录
        self.SERVER_DIR = deAES(self.data_skt.recv(1024))

这样就能在client端看到本地与server的AbelFile运行的虚拟目录了:

在这个project中,目录一致性是非常重要的,任何一个操作都要保持client和server的目录一致性。

UI功能

用户在client端输入的下面这些指令,会根据上面描述的流程工作。当然对于用户输入的错误的UI,会在错误处理脚本./client/errordict.py中做相应的处理。

AbeFile > help

因为没有涉及到socket通信,所以这是在./client/basic_ops.py中的基本命令。很简单,就是读取./client/help.txt并且打印到屏幕上:

AbeFile > cd [c/s] absolute/dir

0001 0 0 00

cd的c/s两种模式都必须保证服务器与客户端目录的一致性。

cd我能想到的有两种实现方式:


  • 对已经获得的字符串进行解析,例如分割’/‘来获取新的目录

  • 直接利用python的os.chdir()与os.popen()来获取目录

这两种方式各有优劣。对于第一种而言,坏处是对于复杂的目录切换很需要精力去设计,因为这是一个文件目录树的遍历问题。例如对于这样无聊的命令:

AbelFile > cd s ../../Documents/././../Pictures/./

进行字符串解析与目录树的遍历是很繁琐的。

第二种方法通过下面这些代码就能很简单地通过系统命令完成了,并不需要额外设计目录树与遍历算法:

if os.path.isdir(dirname):
    os.chdir(dirname)
    tmp = os.popen('pwd').readlines()[0].rstrip('\n')
    # os.chdir(self.WORK_DIR) 不将目录恢复为AbelFile/client
    self.CLIENT_DIR = tmp
    self.syn_dir()                
else:
    error['cd:client dir']()

缺点是目前只在linux上使用,windows因为有些cmd命令的格式不太一样,我没有测试,所以不知道能不能行。但是这样一来,上面那个既无聊又复杂的目录切换就可以实现了:

另外注意,这里由于cd时调用os.chdir()而没有恢复os的目录,也就是说os的目录和self.CLIENT_DIR保存的目录相一致,所以就可以使用相对目录而不需要很繁琐地输入绝对目录了。

AbeFile > ls [c/s]

0011 0 0 00

ls命令同样有上面那两种选择,我同样选择了用系统命令完成。但是这回是确定不能在windows上运行了,因为windwos上相应的命令是’dir’,而且返还的结果和linux完全不是同一个格式。因此AbelFile只能在linux上运行。

图中的示意有一个错误的命令,它是参数错误,所以有此显示。所有的错误都由./client/errordict.py进行管理。

这里有一个着色的困难点。我们知道,一般linux GUI的ls命令是会通过颜色区分文件与目录的,对于client本地的文件与目录通过python的os模块就可以很容易的判断了,但是对于socket发过来的server的文件与目录,没有办法直接判断。为此,只能现在server先判断一遍,附加目录/文件标识再通过socket发送给client:

tmp = []
for item in itemls:
    item = item.rstrip('\n')
    if(os.path.isdir(item)):
        tmp.append('d %s' % item)
    else:
        tmp.append('f %s' % item)

这个附加的信息可以在client通过解析UI的正则表达函数解析:

_type_, item = argsplit(itemstr)
if _type_ == 'd':
    print('%s%s%s%s%s'%(clr.S,clr.B,item[0],clr.E,clr.E), '  ', end = '')
else:
    print(item[0], '  ', end = '')

这样就可以正确地着色了。

AbeFile > pull [file.txt/dir]

在push, pull命令中,目录一致性的重要性就显现出来了。在这两个命令中,都没有做其他目录下文件的扩展,也就是说用户只能将self.SERVER_DIR是文件pull到self.CLIENT_DIR。如果希望pull其他目录下的文件,他将只能cd切换目录。

当用户做出pull请求以后,client会先检查文件覆盖问题。如果当前目录下已经有了用户希望pull的文件或目录,就不得不决定是要继续pull或是放弃。随后,server会检查用户请求pull的对象是否存在或者它的类型,如果不存在server会发送:

0100 0 0 00

最后两位清零以示不存在。否则如果检查到pull请求的是文件,则发送:

0100 0 0 01

以示文件类型,如果检查到是目录,则为:

0100 0 0 10

做这个检测是因为文件和目录的传输方式不太相同。对于文件而言,就是普通的传输。对于目录而言,我想到有两种传输方式:


  • 用os遍历目录下所有的文件,逐一做文件传输

  • 将目录压缩为.zip文件,只传输.zip文件,再解压

.zip的传输方式是:server先将self.SERVER_DIR下的目录压缩为AbelFile/server下的.zip文件,将.zip文件作为文件传输到AbelFile/client,然后解压到self.CLIENT_DIR,server和client删除AbelFile/server和AbelFile/client下的.zip文件。

这样的传输方式虽然别扭,但是实现起来简单。需要注意的是工作目录的问题,如果将目录压缩到当前目录下的.zip文件,则会存在同名覆盖的风险,那么计算机上我们唯一知道不会存在这种.zip同名文件的目录就是AbelFile/server和AbelFile/client,因此压缩只能压缩到这两个目录下。

需要说明的是,这种方式比较适合文件数多,但总大小比较小的目录。如果采用目录遍历的方式挨个做文件传输,那么对于文件数量很多的情况显然传输起来很不方便,但是通过zip方式的话则只需要传输一个.zip文件就够了。可是如果压缩之后的大小很大,那么就又不如挨个传输来得好,因为一旦链接断开,用整个.zip文件传输的话将一无所有,而挨个传输还能得到已经传输好的文件。对此需要权衡,因为这是一个简单的project,所以我就用压缩的方式做了。

此外,传输过程采用AES加密。而且有一个百分比进度条、传输时间的小功能:

AbeFile > push [file.txt/dir]

0101 0 0 00

和push基本一样,只是过程相反,不赘述。




AbeFile > del [c/s] delete.txt



0110 0 0 00


del操作会判断一下是文件还是目录,据此调用不同的函数:

# delete file
if os.path.isfile(target):
    os.remove(target)
# delete sub-folder
elif os.path.isdir(target):
    shutil.rmtree(target)
# null target
else:
    error['del:no file']()

注意它也只能删除当前目录下的文件和目录。对于删除对象不存在的情况,也有报错处理。

AbeFile > bash /yourdir/yourbash.txt

之所以要将UI命令分成CLIENT, BASIC两类,主要就是为了方便地实现bash命令。

def bash(self, argvar):
    if os.path.isfile(argvar[0]) is False:
        error['bash:no file']()    # bash file exists
        return
    bashlist = open(argvar[0], 'r').readlines()
    bashlist = [line.strip('\n') for line in bashlist]
    # execute bash cmds
    for bashline in bashlist:
        hint = '%s%sAbelFile%s%s server%s:%s%s%s\n         %sclient%s:%s%s%s > '%(clr.B,clr.Y,clr.E,clr.G,clr.E,clr.S,self.clt.SERVER_DIR,clr.E,clr.G,clr.E,clr.S,self.clt.CLIENT_DIR,clr.E)
        argkey, argvar = argsplit(bashline)
        if argkey == 'bash':
            error['bash:abort']()
            return
        elif argkey == '#':
            pass
        elif argkey in self.ops.keys():
            self.ops[argkey](argvar)
        elif argkey in self.clt.ops.keys():
            print(hint, bashline)
            self.clt.ops[argkey](argvar)
        else:
            print('%sCMD ERROR! Type \'help\' to check!%s'%(clr.R, clr.E))
        time.sleep(0.1)

从上到下执行CLIENT中的函数是方便的,但是如果CLIENT类有bash成员函数来调用其它成员函数就没那么方便。这个bash命令实际上就是将基础UI照抄了一遍,但是对我们进行测试却很方便。

注意在这样的bash命令中,bash脚本是可以通过’#’符号添加注释的。并且,禁止bash bash,也就是bash脚本中不允许再执行一个bash,以免陷入无限循环。

AbeFile > server

server命令将控制server的系统命令,这实际上是一个非常危险的行为,但是为了好玩我还是做了一个简单的。它的功能是server执行用户输入的命令,并且将终端上显示的信息返还给client:

def server(self, arg_ptc):
    os.chdir(self.SERVER_DIR)
    while True:
        cmd = deAES(self.ctrl_skt.recv(1024))
        print('server > %s' % cmd)
        # time.sleep(1)
        if not cmd:
            self.data_skt.send(enAES('quit()'))
            break
        if cmd == 'quit()':
            break
        data = str(os.popen(cmd).readlines())
        self.data_skt.sendall(enAES(data))

上面的代码是server端的。下面执行server的ls命令,并且运行了一个python脚本作为示范。注意这只能运行当前self.SERVER_DIR下的命令,想要运行其他目录也需要quit()退出然后cd切换目录。

AbeFile > exit

exit退出用户程序。