本来题目想说通过邮件控制远程主机,不过实际实现的情况还达不到完全的交互的控制,所以就有了上面的标题。使用的场景主要是远程主机不能通过ssh端口暴露在互联网中,或者网络连接并不是十分稳定的情况下,缺点就是响应时间慢,可能上午发出的脚本,下午才能执行,不具备时效性,不具备交互性。动工之前未查阅是否已经有相关的实现方式,所以基本上的是闭门造车,方法比较笨。
实现的工具链:fecthmail(收取指定gmail帐号中的邮件) --> 自定义的MDA脚本(将邮件中的附件提取出来,放到指定目录) --> 检查邮件中的附件并做相应处理的后台脚本 --> mutt(将脚本的运行输出邮寄给指定账号) --> exim4(主机上的邮件服务)
邮件发送人的可信任,是通过附件的GPG加密来确认的。发送人发送的邮件附件都必须GPG加密,远程主机在收到邮件后会检查是否能正确解密。
下面是自定义的MDA 的python脚本,fetchmail的配置文件中需要将其做为MDA,邮件头是按gmail的格式来分析的,其它服务商的可能不适用。
1.将此脚本存为MDA.py,保存在/usr/bin/目录,所有人都可执行。
2. 创建 /var/spool/MailControlSpool/attachment 目录,MailControlSpool 及其子目录的ower: fetchmail, group 可以是mail 或者 root,权限:0755。因为此脚本是fetchmail运行的,所以如果fetchmail无法读写相关目录的话,就会造成投递失败。
#!/usr/bin/python
import os
import fileinput
import time
import base64
import glob
import sys
maildir="/var/spool/MailControlSpool/"
attachmentdir=maildir+"attachment/"
masterid="gmailAccount@gmail.com"
def SaveMail():
'''
used to save mail which is pass from fetchmail
return saved file name
'''
dateinfo=time.strftime("%y%m%d-%H%M%S")
filename=maildir+"Mail"+dateinfo
f=open(filename,'w')
if f==None:
return ""
longline=""
# TODO:here maybe need to consider the pretty long line,set a counter?
for line in fileinput.input():
#print(line,end="")
longline+=line
f.write(longline)
f.close()
return filename
def mailprocess(mailfile):
'''
Read mail, if it from master,then save the attachments
get attachment,and a list of attachment file name
'''
print("read file: " + mailfile)
mail=open(mailfile,'r')
lastkey=""
mailheaderEnd=0
hasboundary=0
findboundary=0
usemailheaddict=1
isheader=1
isboundary=0
iscontent=0
tempDict={'Delivered-To':"",
'Received':"",
'Return-Path':"",
'Received-SPF':"",
'Authentication-Results':"",
'DKIM-Signature':"",
'MIME-Version':"",
'Date':"",
'Message-ID':"",
'Subject':"",
'From':"",
'To':"",
'Content-Type':"",
'boundary':"",
'Content':""
}
attachmentList=[]
boundarycount=0
# start to read the content
# try to fetch the content and fill them to dictionary
# TODO: which way will be better:
# 1. readlines at once then process
# 2. use if/elif clause to process the keyword
for line in mail.readlines():
# if the header is finished,then
# if this mail is not from master,do nothing
# lastkey!="": avoid the several header lines are blank and '\n' in content
#print("line: " + line)
if line[0]=='\n' and lastkey!="":
#print("******header finish")
if mailheaderEnd==0:
mailheaderEnd=1
fromwho=tempDict['From']
pos1=fromwho.find('<')
pos2=fromwho.find('>')
fromwho=fromwho[pos1+1:pos2]
if fromwho != masterid:
print("masterid doesn't match: " + fromwho)
return 1
if tempDict['Received-SPF'][0:4] != "pass":
print("SPF is not pass: "+ tempDict['Received-SPF'][0:4])
return 2
position=tempDict['Content-Type'].find("boundary")
if position > 0:
tempDict['boundary']=tempDict['Content-Type'][position+9:]
tempDict['boundary']="--"+tempDict['boundary']
hasboundary=1
# :-1 remove the last '\n'
boundary=tempDict['boundary']
boundaryLength=len(boundary)
#print("boundary is: "+tempDict['boundary'])
lastkey=""
isheader=0
continue
if isheader==1:
position=line.find(':')
if position>0:
keyword=line[:position]
if keyword in tempDict:
# position+2: ignore the space after colon(:)
# len(line)-1: ignore the '\n'
tempDict[keyword]+=line[position+2:len(line)-1]
#print("Get key: " + keyword)
lastkey=keyword
else:
if lastkey != "":
tempDict[lastkey]+=line
else:
print("unknow key: "+ keyword)
continue
elif lastkey != "":
tempDict[lastkey]+=line
else:
print("Mail last line ")
continue
# in the mainbody or attachment mainbody
if hasboundary==1:
#print("3line: " +line[:boundaryLength])
if line[:boundaryLength] == boundary:
boundarycount+=1
findboundary=1
# the next line after boundary will be header
isheader=1
if boundarycount==1:
# save the mail header content
mainbodyDict=tempDict
if boundarycount==2:
# copy the main body content to the mail header dictionay variable
mainbodyDict['Content']=tempDict['Content']
# save to list,if there one attachment,the count will be to 3
if boundarycount>2:
attachmentList.append(tempDict)
# mail header is end, change to attachment Dict
tempDict={'Content-Type':"",
'Content-Disposition':"",
'Content-Transfer-Encoding':"",
'X-Attachment-Id':"",
'Content':""
}
continue
else:
tempDict['Content']+=line
continue
else:
tempDict['Content']+=line
continue
if hasboundary==0:
mainbodyDict=tempDict
mail.close()
#print("boundarycount: " + str(boundarycount))
'''
for k,v in mainbodyDict.items():
print(k+": "+v)
for l in attachmentList:
print("-----------------------------")
for k,v in l.items():
print(k+": "+v)
'''
count=0
filetype=""
charset=""
filename=""
# use base64 to decode attachments
for l in attachmentList:
contentType=l['Content-Type']
position=contentType.find(';')
filetype=contentType[:position]
contentType=contentType[position+1:]
pos1= contentType.find('charset=')
if pos1>0:
pos2= contentType.find(';')
charset=contentType[pos1+8:pos2]
#print("charset is: " + charset)
else:
#print("no char set found: " + contentType+ ": " + str(pos1))
pass
pos1= contentType.find('name=')
filename=contentType[pos1+5:].strip('"')
if l['Content-Transfer-Encoding']=="base64": #and filetype=="text/plain":
#:-1 remove the last '\n' char
attachmentContent=l['Content'][:-1]
try:
# bytes type returned
attachmentContent=base64.b64decode(attachmentContent.encode('utf-8'))
filename=attachmentdir+filename
attachmentFile=open(filename,'wb')
if attachmentFile!=None:
attachmentFile.write(attachmentContent)
attachmentFile.close()
print("Saved: "+ filename)
else:
print("open file: " + filename + " failed")
#print(str(attachmentContent)[2:-1].encode(charset))
except TypeError:
print("Failed to decrypt attachment use base64")
else:
print("This attachment use unkown encoding: "+ l['Content-Transfer-Encoding'])
if __name__ == '__main__':
# if there are files in the $attachmentdir,then quit with 1
filecount=0
for file in glob.glob(attachmentdir+'*'):
filecount+=1
if filecount>0:
print("There are " + str(filecount) + " files in attachment dir")
sys.exit(9)
mailfile= SaveMail()
print("save mail as "+mailfile)
mailprocess(mailfile)
下面这个是runmailscript.sh,会每隔1分钟检查/var/spool/MailControlSpool/attachment目录,如果有文件的话,就会验证是否可以正常解密,如果可以并且是shell脚本,则将其移到/root/mailscript/目录(需要创建)运行,并将其输出寄回以master账号。这种方法对于所要执行的脚本是些限制的,比如不能是有要求输入的,否则就会停在那了,如果可以预见输入提示的话,可以用expect来操作。脚本执行完后会被删除。这里需要有gpg的key事先生成,gpghomedir是存放key的目录,gpgid是对应key的id.
如果把runmailscript.sh 加在/etc/rc.local中,一开机就运行,那么Mutt的配置文件需要放在/etc中,文件名为Muttrc。 因为在不登录的情况下将会使用系统的配置文件,而非个人的,包括fetchmail的配置文件fetchmailrc也要放在/etc中。
#!/bin/bash
mailattachmentdir="/var/spool/MailControlSpool/attachment/"
scriptdir="/root/mailscript/"
scriptoutput="/root/mailscript/scriptoutput.txt"
pidfile="/var/run/runmailscript.pid"
master="gmailAccount@gmail.com"
gpgid="mygpgid"
gpghomedir="/root/.gnupg/"
mailmsg=""
echo $$ > $pidfile
cd $scriptdir
mutt -s "service start" $master <<EOF
`date`
EOF
while :
do
while :
do
filecount=`ls $mailattachmentdir | wc -l`
if [ $filecount -gt 0 ];then
mv $mailattachmentdir/* $scriptdir
break
else
echo "no new file found"
sleep 60
fi
done
gpgfilecount=0
gpgerror=0
haserror=0
mailmsg=""
for file in `ls`
do
length=${#file}
position=$(($length-3))
postfix=${file:$position}
echo $postfix
# check the postfix of attachment,if gpg,then decrypt
if [ "$postfix" == "asc" ] || [ "$postfix" == "gpg" ];then
((gpgfilecount++))
gpg --homedir=${gpghomedir} $file 2> $scriptoutput
result=$?
if [ $result -eq 0 ];then
rm $file
continue
else
# if gpg failed,then do nothing
mailmsg="Error: gpg failed to decrypt file: "$file"!\n"
while read line;do
mailmsg=$mailmsg$line
done < $scriptoutput
gpgerror=1
haserror=1
break
fi
else
mailmsg="No asc/gpg file found: "$file"!"
haserror=1
break
fi
done
scriptfilecount=`ls *.sh 2>/dev/null | wc -l`
if [ $scriptfilecount -ne 1 ];then
mailmsg=$mailmsg" no or more than one .sh files found"
haserror=1
fi
if [ $haserror -gt 0 ];then
echo $mailmsg > $scriptoutput
echo "runs mutt: has error found"
if [ $gpgerror -eq 1 ];then
mutt -s "Gpg failed" -a $file -- $master < $scriptoutput
else
mutt -s "Run error" $master < $scriptoutput
fi
else
scriptname=`ls *.sh 2>/dev/null`
bash $scriptname 1> $scriptoutput 2>&1
result=$?
echo "Here run script: "$scriptname
# use trust-mode,or gpg will ask yes/no to use this public key
gpg --homedir=${gpghomedir} --trust-model direct -ea -r $gpgid $scriptoutput
if [ -f $scriptoutput.asc ];then
echo $mailmsg > $scriptoutput
echo "runs mutt: script is executed"
mutt -s "script return: "$result $master -a $scriptoutput.asc -- $master < $scriptoutput
else
echo "gpg failed"
fi
fi
rm $scriptdir/* 2>/dev/null
done
下面是fetchmailrc的参考:
set daemon 300
set no bouncemail
defaults:
antispam -1
batchlimit 100
poll imap.gmail.com with protocol imap
user gmailAccount@gmail.com
pass passwd
mda "/usr/bin/MDA.py"
options
keep
idle
ssl
下面是Muttrc的参考:
set realname = "realname"
set from = "gmailAccount@gmail.com"
set use_from = yes
set envelope_from ="yes"
set sendmail="/usr/sbin/exim4"
set spoolfile = /var/spool/mail/root
set folder="$HOME/Mail/mbox" # Local mailboxes stored here
set record="/root/sent" # Where to store sent messages
set postponed="+postponed" # Where to store draft messages
set mbox_type=mbox # Mailbox type
set move=no # Don't move mail from spool
mailboxes ! +slrn +fetchmail +mutt
set sort_browser=alpha # Sort mailboxes by alpha(bet)
ignore *
unignore Date: From: User-Agent: X-Mailer X-Operating-System To: \
Cc: Reply-To: Subject: Mail-Followup-To:
hdr_order Date: From: User-Agent: X-Mailer X-Operating-System To: \
Cc: Reply-To: Subject: Mail-Followup-To:
set editor="vim -c 'set tw=70 et' '+/^$' "
set edit_headers=yes # See the headers when editing
下面是exim4的配置,在Debian下是这两个文件,其它系统的配置方法可能不一样:
/etc/exim4/passwd.client:
*.google.com:gmailAccount@gmail.com:passwd
/etc/exim4/update-exim4.conf.conf
dc_eximconfig_configtype='smarthost'
dc_other_hostnames='localhost.localdomain'
dc_local_interfaces='127.0.0.1 ; ::1'
dc_readhost=''
dc_relay_domains=''
dc_minimaldns='false'
dc_relay_nets='192.168.0.101/24'
dc_smarthost='smtp.gmail.com'
CFILEMODE='640'
dc_use_split_config='false'
dc_hide_mailname='true'
dc_mailname_in_oh='true'
dc_localdelivery='mail_spool'