建立安全的VPN连接,不仅需要输入用户名和密码,还需要输入动态口令(token)。作为一个懒人,我更喜欢什么手工输入都不需要,既不需要输入password,也不需要输入token。也就是说,只需一个命令就能径直连接上VPN,那自然是极好滴。那么,懒人的愿望能实现吗?答案是肯定的!本文将基于FreeOTP 支持的TOTP(Time-based One-Time Password)算法,介绍如何利用Python代码自动获取动态口令(token),进而利用Expect实现一个自动连接VPN的Bash脚本。
PyOTP是一套开源的函数库,可用来计算基于TOTP算法的Token。有关TOTP算法的细节,本文不做介绍,如有兴趣请参考这里。
1. 下载PyOTP
huanli@ThinkPadT460:tmp$ git clone https://github.com/pyotp/pyotp.git Cloning into 'pyotp'... remote: Counting objects: 601, done. remote: Total 601 (delta 0), reused 0 (delta 0), pack-reused 601 Receiving objects: 100% (601/601), 165.02 KiB | 207.00 KiB/s, done. Resolving deltas: 100% (297/297), done. huanli@ThinkPadT460:tmp$ huanli@ThinkPadT460:tmp$ tree /tmp/pyotp/src /tmp/pyotp/src └── pyotp ├── compat.py ├── hotp.py ├── __init__.py ├── otp.py ├── totp.py └── utils.py 1 directory, 6 files huanli@ThinkPadT460:tmp$
2. 使用PyOTP
huanli@ThinkPadT460:tmp$ export PYTHONPATH=/tmp/pyotp/src:$PYTHONPATH huanli@ThinkPadT460:tmp$ python ...<snip>... >>> import base64 >>> import pyotp >>> s = 'Hello World' >>> secret = base64.b32encode(s) >>> totp = pyotp.TOTP(secret) >>> token = totp.now() >>> print token 338462 >>>
由此可见,通过pyotp.TOTP()获取token非常容易。我们将调用到的核心代码实现如下:
# https://github.com/pyotp/pyotp/blob/master/src/pyotp/otp.py .. 10 class TOTP(OTP): 11 """ 12 Handler for time-based OTP counters. 13 """ 14 def __init__(self, *args, **kwargs): 15 """ 16 :param interval: the time interval in seconds 17 for OTP. This defaults to 30. 18 :type interval: int 19 """ 20 self.interval = kwargs.pop('interval', 30) 21 super(TOTP, self).__init__(*args, **kwargs) .. 37 def now(self): 38 """ 39 Generate the current time OTP 40 41 :returns: OTP value 42 :rtype: str 43 """ 44 return self.generate_otp(self.timecode(datetime.datetime.now())) .. # https://github.com/pyotp/pyotp/blob/master/src/pyotp/otp.py .. 8 class OTP(object): 9 """ 10 Base class for OTP handlers. 11 """ 12 def __init__(self, s, digits=6, digest=hashlib.sha1): 13 """ 14 :param s: secret in base32 format 15 :type s: str 16 :param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more. 17 :type digits: int 18 :param digest: digest function to use in the HMAC (expected to be sha1) 19 :type digest: callable 20 """ 21 self.digits = digits 22 self.digest = digest 23 self.secret = s 24 25 def generate_otp(self, input): 26 """ 27 :param input: the HMAC counter value to use as the OTP input. 28 Usually either the counter, or the computed integer based on the Unix timestamp 29 :type input: int 30 """ 31 if input < 0: 32 raise ValueError('input must be positive integer') 33 hasher = hmac.new(self.byte_secret(), self.int_to_bytestring(input), self.digest) 34 hmac_hash = bytearray(hasher.digest()) 35 offset = hmac_hash[-1] & 0xf 36 code = ((hmac_hash[offset] & 0x7f) << 24 | 37 (hmac_hash[offset + 1] & 0xff) << 16 | 38 (hmac_hash[offset + 2] & 0xff) << 8 | 39 (hmac_hash[offset + 3] & 0xff)) 40 str_code = str(code % 10 ** self.digits) 41 while len(str_code) < self.digits: 42 str_code = '0' + str_code 43 44 return str_code ..
下面给出完整的Python脚本:
- vpn_token.py
1 #!/usr/bin/python 2 import sys 3 import datetime 4 import time 5 6 def main(argc, argv): 7 if argc != 3: 8 sys.stderr.write("Usage: %s <token secret> <pyotp path>\n" % argv[0]) 9 return 1 10 11 token_secret = argv[1] 12 pyotp_path = argv[2] 13 14 sys.path.append(pyotp_path) 15 import pyotp 16 totp = pyotp.TOTP(token_secret) 17 18 # 19 # The token is expected to be valid in 5 seconds, 20 # else sleep 5s and retry 21 # 22 while True: 23 tw = datetime.datetime.now() + datetime.timedelta(seconds=5) 24 token = totp.now() 25 if totp.verify(token, tw): 26 print "%s" % token 27 return 0 28 time.sleep(5) 29 30 return 1 31 32 if __name__ == '__main__': 33 sys.exit(main(len(sys.argv), sys.argv))
- 来自Terminal的Token: 707907
- 来自手机的Token: 707907
由此可见,跟PyOTP计算出的Token码完全一致。于是,我们就可以利用Expect实现完全自动的VPN连接。例如: (注: 这里使用sexpect)
- autovpn.sh (完整的代码请戳这里)
1 #!/bin/bash 2 3 function get_vpn_user 4 { 5 typeset user=${VPN_USER:-"$(id -un)"} 6 echo "$user" 7 return 0 8 } 9 10 function get_vpn_password 11 { 12 typeset password=${VPN_PASSWORD:-"$(eval $($VPN_PASSWORD_HOOK))"} 13 echo "$password" 14 return 0 15 } 16 17 function get_vpn_token 18 { 19 typeset f_py_cb=/tmp/.vpn_token.py 20 cat > $f_py_cb << EOF 21 #!/usr/bin/python 22 import sys 23 import datetime 24 import time 25 26 def main(argc, argv): 27 if argc != 3: 28 sys.stderr.write("Usage: %s <token secret> <pyotp path>\\n" % argv[0]) 29 return 1 30 31 token_secret = argv[1] 32 pyotp_path = argv[2] 33 34 sys.path.append(pyotp_path) 35 import pyotp 36 totp = pyotp.TOTP(token_secret) 37 38 # 39 # The token is expected to be valid in 5 seconds, 40 # else sleep 5s and retry 41 # 42 while True: 43 tw = datetime.datetime.now() + datetime.timedelta(seconds=5) 44 token = totp.now() 45 if totp.verify(token, tw): 46 print "%s" % token 47 return 0 48 time.sleep(5) 49 50 return 1 51 52 if __name__ == '__main__': 53 argv = sys.argv 54 argc = len(argv) 55 sys.exit(main(argc, argv)) 56 EOF 57 58 typeset pyotp_path=$VPN_PYOTP_PATH 59 typeset token_secret=$VPN_TOKEN_SECRET 60 if [[ -z $token_secret ]]; then 61 token_secret=$(eval $($VPN_TOKEN_SECRET_HOOK)) 62 fi 63 64 python $f_py_cb $token_secret $pyotp_path 65 typeset ret=$? 66 rm -f $f_py_cb 67 return $ret 68 } 69 70 function get_vpn_conf 71 { 72 typeset conf=$VPN_CONF 73 echo "$conf" 74 return 0 75 } 76 77 function check_sexpect 78 { 79 type sexpect 2>&1 | egrep 'not found' > /dev/null 2>&1 80 (( $? != 0 )) && return 0 81 return 1 82 } 83 84 vpn_user=$(get_vpn_user) 85 (( $? != 0 )) && exit 1 86 vpn_password=$(get_vpn_password) 87 (( $? != 0 )) && exit 1 88 vpn_token=$(get_vpn_token) 89 (( $? != 0 )) && exit 1 90 vpn_conf=$(get_vpn_conf) 91 (( $? != 0 )) && exit 1 92 93 check_sexpect || exit 1 94 95 export SEXPECT_SOCKFILE=/tmp/sexpect-ssh-$$.sock 96 trap '{ sexpect close && sexpect wait; } > /dev/null 2>&1' EXIT 97 98 sexpect spawn sudo openvpn --config $vpn_conf 99 sexpect set -timeout 60 # XXX: 'set' should be invoked after server is running 100 101 while :; do 102 sexpect expect -nocase -re "Username:|Password:" 103 ret=$? 104 if (( $ret == 0 )); then 105 out=$(sexpect expect_out) 106 if [[ $out == *"Username:"* ]]; then 107 sexpect send -enter "$vpn_user" 108 elif [[ $out == *"Password:"* ]]; then 109 sexpect send -enter "$vpn_password$vpn_token" 110 break 111 else 112 echo "*** unknown catch: $out" >&2 113 exit 1 114 fi 115 elif sexpect chkerr -errno $ret -is eof; then 116 sexpect wait 117 exit 0 118 elif sexpect chkerr -errno $ret -is timeout; then 119 sexpect close 120 sexpect wait 121 echo "*** timeout waiting for username/password prompt" >&2 122 exit 1 123 else 124 echo "*** unknown error: $ret" >&2 125 exit 1 126 fi 127 done 128 129 sexpect interact
- 运行autovpn.sh
huanli@ThinkPadT460:~$ ./autovpn.sh Sat Aug 11 22:32:17 2018 OpenVPN 2.4.6 x86_64-redhat-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Apr 26 2018 Sat Aug 11 22:32:17 2018 library versions: OpenSSL 1.1.0h-fips 27 Mar 2018, LZO 2.08 Enter Auth Username: huanli Enter Auth Password: **************** Sat Aug 11 22:32:17 2018 NOTE: the current --script-security setting may allow this configuration to call user-defined scripts ...<snip>... Sat Aug 11 22:32:20 2018 GID set to openvpn Sat Aug 11 22:32:20 2018 UID set to openvpn Sat Aug 11 22:32:20 2018 Initialization Sequence Completed