2017年11月25日 星期六

從外網以 SSH 存取樹莓派的方法

這個問題在我心中盤旋很久了, 但卻一直沒時間研究. 樹莓派的低功耗使其成為家中不關機的伺服器首選. 我曾將樹莓派連續開機好幾個月都不會當機, 不需要接螢幕或鍵盤滑鼠, 只要插上電源開機, 它會自動連線到家中的無線基地台, 並固定取得 192.168.2.192 這個 IP, 然後利用筆電以 Putty 用 SSH 連線樹莓派的固定 IP 192.168.2.192 即可完全操控它, 稱為無頭存取方式, 參考 :

樹莓派的 "無頭存取" (headless access)
設定樹莓派 WiFi 無線網卡固定 IP 的方法
樹莓派 Raspberry Pi 文章列表

在區域網路存取沒問題, 但 192.168.xxx.xxx 只限於家裡的區網能用, 如果在公司想要連線回來該怎麼做? 這就需要 WiFi 無線基地台 (路由器) 上設置虛擬伺服器, 將外網 IP 對應到區網的 192.168.xxx.xxx 了.

我參考了下列文章在我的 EDIMAX 無線基地台的管理網頁中為樹莓派的 外網 SSH 存取設定虛擬伺服器 :

樹莓派通過郵件上報實時IP,隨時隨地遠程登錄樹莓派

EDIMAX 管理網頁位置是在 "位址轉換/Virtual server" 頁籤 :




先將左上角的 "Enable virtual server" 打勾, 然後輸入 Private IP, 我的樹莓派因為有在 /etc/network/interfaces 檔案中設定固定 IP=192.168.2.192, 因此 Private IP 就輸入 192.168.2.192. 然後 Private port 與 Public port 都輸入 SSH 的埠號 22, 並在 Comment 欄輸入備註, 再按 "Add" 鈕, 上述的設定就會出現在下面的 "Current virtual server table" 中了, 最後要按 "Apply" 才會真正生效.

接下來要測試看看是否真能從外網與區網內的樹莓派建立 SSH 連線. 首先必須知道家裡無線基地台的外網 IP, 有許多網站提供 IP 查詢服務, 例如 :

http://cmp.nkuht.edu.tw/info/ip.asp
http://dir.twseo.org/ip-check.php
http://www.ip138.com/

我筆電原先是透過家裡的無線基地台上網, 利用上列網站查出無線基地台外網 IP 後, 這時將筆電改透過手機行動網路分享的 WiFi 上網, 這樣就可以模擬從 Internet 外網進行存取了. 這時打開 Putty, 輸入樹莓派所連之無線基地台外網 IP, 果然順利連線到區網內的樹莓派了.

https://github.com/laixintao/Report-IP-hourly

如果要在命令列查詢外網 IP, 可輸入下列 curl 指令 :

pi@raspberrypi:~ $ curl http://members.3322.org/dyndns/getip >>/home/pi/ip.log    % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    14    0    14    0     0     46      0 --:--:-- --:--:-- --:--:--    46
pi@raspberrypi:~ $ cat ip.log
37.237.225.60

查詢內網則是用 ifconfig, 若只要擷取 IP 可用下列指令 :

pi@raspberrypi:~ $ ifconfig | grep 'Bcast' | cut -d B  -f 1 >> /home/pi/ip.log
pi@raspberrypi:~ $ cat ip.log
37.237.225.60
          inet addr:192.168.2.192

由於一般 ADSL 用戶都是浮動的公網 IP 並非固定, ISP 業者會一段時間會更換此 IP, 樹莓派必須在外網 IP 異動時自動以 E-mail 通知我們, 否則將無法以 SSH 從外網連線到樹莓派. 這篇文章的作者提供了 Python 程式碼來完成此項通報工作, 此程式已放在 GitHub 專案中 :

https://github.com/laixintao/Report-IP-hourly

主角是專案中的 Python 程式 reportip.py :

https://github.com/laixintao/Report-IP-hourly/blob/master/reportip.py

將此程式複製下來加以編輯, 主要是修改 e-mail config 部分 :

# the e-mail config
# this is just a simple format,this e-mail doesn't exist.
smtpserver = "smtp.sina.com"
username = "reaspberrypi@sina.com"
password = "123456"
sender = "reaspberrypi@sina.com"
receiver = ["receiver@sina.com","master@sina.com"]
subject = "[RPI]IP CHANGED"

改成如下 (注意, Hinet SMTP 主機是 msr) :

smtpserver = "msr.hinet.net"
username = "blabla@ms5.hinet.net"
password = "1234567890"
sender = "blabla@ms5.hinet.net"
receiver = ["jyp@yahoo.com","jyp@google.com"]
subject = "[RPI]IP CHANGED"

另外 Getmyip 類別之 getip() 方法裡面的查詢 IP 網址也要改成台灣的網址, 不要用原作中的大陸網站, 我查詢 Google 發現下面三個網站還不錯, 都可以正常運作 :
  1. http://myip.com.tw
  2. http://cmp.nkuht.edu.tw/info/ip.asp
  3. http://dir.twseo.org/ip-check.php
修改後的完整程式如下 :

#!/usr/bin/python
#-*-coding:utf-8*-

__author__ = 'laixintao'

import socket
import time
import struct
import smtplib
import urllib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
import re
import urllib2

# the e-mail config
# this is just a simple format,this e-mail doesn't exist.
smtpserver = "msr.hinet.net"
username = "blabla@ms5.hinet.net"
password = "1234567890"
sender = "blabla@ms5.hinet.net"
receiver = ["jyp@yahoo.com","jyp@google.com"]
subject = "[RPI]IP CHANGED"

# file_path config
file_path = "lastip.txt"

def sendEmail(msghtml):
    msgRoot = MIMEMultipart('related')
    msgRoot["To"] = ','.join(receiver)
    msgRoot["From"] = sender
    msgRoot['Subject'] =  subject
    msgText = MIMEText(msghtml,'html','utf-8')
    msgRoot.attach(msgText)
    smtp = smtplib.SMTP()
    smtp.connect(smtpserver)
    smtp.login(username, password)
    smtp.sendmail(sender, receiver, msgRoot.as_string())
    smtp.quit()


def check_network():
    while True:
        try:
            print "Network is Ready!"
            break
        except Exception , e:
           print e
           print "Network is not ready,Sleep 5s...."
           time.sleep(10)
    return True

def get_lan_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(("1.1.1.1",80))
    ipaddr=s.getsockname()[0]
    s.close()
    return ipaddr

class Getmyip:
    def getip(self):
        try:
            myip = self.visit("http://myip.com.tw")
        except:
            try:
                myip = self.visit("http://cmp.nkuht.edu.tw/info/ip.asp")
            except:
                try:
                    myip = self.visit("http://dir.twseo.org/ip-check.php")
                    # if you want to add more,use the format "except try"
                    # make sure the most useful link be the first
                except:
                    print "Fail to get the Network ip."
                    print "Get the LAN ip."
                    myip = get_lan_ip()
        return myip
    def visit(self,url):
        opener = urllib2.urlopen(url,timeout=20)
        if url == opener.geturl():
            str = opener.read()
            print "IP information from",url
        return re.search('\d+\.\d+\.\d+\.\d+',str).group(0)

def get_network_ip():
    getmyip = Getmyip()
    localip = getmyip.getip()
    return localip


if __name__ == '__main__':
    check_network()
    ipaddr=get_network_ip()
    lanip=get_lan_ip()
    emailip=str(ipaddr)+" "+str(lanip)
    ip_file = open(file_path)
    last_ip = ip_file.read()
    ip_file.close()
    if last_ip == emailip:
        print "IP not change."
    else:
        print "IP changed. New ip: {}".format(emailip)
        ip_file = open(file_path,"w")
        ip_file.write(str(emailip))
        ip_file.close()

        sendEmail(ipaddr)
        print "Successfully send the e-mail."


注意, Hinet 的 SMTP 發信主機要用 msr.hinet.net, 我的郵件主機是 ms5.hinet.net, 我原先以為要用這個, 結果信傳不出去, 改成 msr 就可以了, 參考 :

http://blog.xuite.net/yatpingchen/blog/199891664-國外收發Hinet郵件SMTP設定

其次, 上面這個程式原作是用 Python 2 寫的, 所以修改好後我改用 reportip2.py 存檔在 /home/pi 下, 手動執行時要用 python2 指令 :

pi@raspberrypi:~ $ python2 reportip2.py
Network is Ready!
IP information from http://cmp.nkuht.edu.tw/info/ip.asp
IP changed. New ip: 223.139.131.189 192.168.43.26
Successfully send the e-mail.
You have new mail in /var/mail/pi
pi@raspberrypi:~ $ python2 reportip2.py
Network is Ready!
IP information from http://myip.com.tw
IP not change.
You have new mail in /var/mail/pi


手動執行沒問題後, 接下來是編輯 contab 讓 reportip2.py 能夠定時自動執行, 這樣不論我在天涯海角都能透過 E-mail 得知目前的 IP 是多少而連線回去. 另外一個好處是可以知道家裡 ADSL 網路是否正常, 如果沒定時收到 E-mail 就表示網路異常了.

$ crontab -e 

參考原作的 crontabs 設定 :

https://github.com/laixintao/Report-IP-hourly/blob/master/rootcron

0 */1 * * * /usr/bin/python2 /root/rootcrons/reportip2.py

將其改為每 10 分鐘檢查一次 :

*/10 * * * * /usr/bin/python2 /home/pi/reportip2.py

在 crontab 加入這一筆後存檔, 再用 chmod 指令將 reportip2.py 改為可執行 :

pi@raspberrypi:~ $ sudo chmod +x /home/pi/reportip2.py   


2017-12-03 補充 :

下午 16:00 要回高雄, 因姊姊要搭高鐵回台北, 所以下午抽點時間測試上面的程式, 原作使用的 IP 查詢網站 www.138ip.com 每次 cron 執行回報的 IP 都不同, 事實上即使是浮動 IP 也不可能每 10 分鐘就變動一次, 我猜可能是該網站位於中國境內的關係, 改用台灣的 http://myip.com.tw 等位址就正常了.

Cron 執行紀錄放在 /var/log/cron.log  裡面, 不過紀錄功能預設是關閉的, 必須修改 /etc/rsyslog.conf 檔之設定將其打開才會記錄, 參考 :


以管理員身分開啟 /etc/rsyslog.conf, 在 "RULES" 項下可找到預設被 mark 掉的 #cron 設定, 拿掉 # 後存檔 :

pi@raspberrypi:~ $ sudo nano /etc/rsyslog.conf


###############
#### RULES ####
###############

#
# First some standard log files.  Log by facility.
#
auth,authpriv.*                 /var/log/auth.log
*.*;auth,authpriv.none          -/var/log/syslog
#cron.*                         /var/log/cron.log
daemon.*                        -/var/log/daemon.log
kern.*                          -/var/log/kern.log
lpr.*                           -/var/log/lpr.log
mail.*                          -/var/log/mail.log
user.*                          -/var/log/user.log

然後重啟系統記錄檔即可 : 

$ sudo /etc/init.d/rsyslog restart  

檢視 cron 執行紀錄 :

pi@raspberrypi:~ $ sudo cat /var/log/cron.log                                                   
Dec  3 23:25:01 raspberrypi CRON[20720]: (pi) CMD (/usr/bin/python2 /home/pi/reportip2.py)                                                      ortip2.py)
Dec  3 23:30:01 raspberrypi CRON[20785]: (pi) CMD (/usr/bin/python2 /home/pi/reportip2.py)                                                      ortip2.py)
Dec  3 23:35:01 raspberrypi CRON[20806]: (pi) CMD (/usr/bin/python2 /home/pi/reportip2.py)                                                      nclean ] && /usr/lib/php5/sessionclean)
Dec  3 23:40:01 raspberrypi CRON[20879]: (pi) CMD (/usr/bin/python2 /home/pi/reportip2.py)                                                      ortip2.py)
Dec  3 23:45:01 raspberrypi CRON[20947]: (pi) CMD (/usr/bin/python2 /home/pi/reportip2.py)


參考 :

Where do Cron error message go?

2017-12-09 補充 :

我把上面 Python 2 的程式改為 Python 3 版, 結果卻敗在 visit() 方法無法取得外網 IP, 原因可能出在 urllib 模組上 :

import socket
import time
import struct
import smtplib
from urllib.request import urlopen
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
import re

# the e-mail config
# this is just a simple format,this e-mail doesn't exist.
smtpserver = "msr.hinet.net"
username = "blabla@ms5.hinet.net"
password = "1234567890"
sender = "blabla@ms5.hinet.net"
receiver = ["jyp@yahoo.com","jyp@google.com"]
subject = "[RPI]IP CHANGED"

# file_path config
file_path = "lastip.txt"

def sendEmail(msghtml):
    msgRoot = MIMEMultipart('related')
    msgRoot["To"] = ','.join(receiver)
    msgRoot["From"] = sender
    msgRoot['Subject'] =  subject
    msgText = MIMEText(msghtml,'html','utf-8')
    msgRoot.attach(msgText)
    smtp = smtplib.SMTP()
    smtp.connect(smtpserver)
    smtp.login(username, password)
    smtp.sendmail(sender, receiver, msgRoot.as_string())
    smtp.quit()


def check_network():
    while True:
        try:
            print("Network is Ready!")
            break
        except Exception :
           print(e)
           print("Network is not ready,Sleep 5s....")
           time.sleep(10)
    return True

def get_lan_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(("1.1.1.1",80))
    ipaddr=s.getsockname()[0]
    s.close()
    return ipaddr

class Getmyip:
    def getip(self):
        try:
            myip = self.visit("http://myip.com.tw")
        except:
            try:
                myip = self.visit("http://cmp.nkuht.edu.tw/info/ip.asp")
            except:
                try:
                    myip = self.visit("http://dir.twseo.org/ip-check.php")
                    # if you want to add more,use the format "except try"
                    # make sure the most useful link be the first
                except:
                    print("Fail to get the Network ip.")
                    print("Get the LAN ip.")
                    myip = get_lan_ip()
        return myip
    def visit(self,url):
        opener = urlopen(url,timeout=20)
        if url == opener.geturl():
            str = opener.read()
            print("IP information from",url)
        return re.search('\d+\.\d+\.\d+\.\d+',str).group(0)

def get_network_ip():
    getmyip = Getmyip()
    localip = getmyip.getip()
    return localip


if __name__ == '__main__':
    check_network()
    ipaddr=get_network_ip()
    lanip=get_lan_ip()
    emailip=str(ipaddr)+" "+str(lanip)
    ip_file = open(file_path)
    last_ip = ip_file.read()
    ip_file.close()
    if last_ip == emailip:
        print("IP not change.")
    else:
        print("IP changed. New ip: {}".format(emailip))
        ip_file = open(file_path,"w")
        ip_file.write(str(emailip))
        ip_file.close()

        sendEmail(ipaddr)
        print("Successfully send the e-mail.")

主要是把輸出改成函數式的 print(), 以及匯入 urllib.request.urlopen 而已, 執行結果如下 :

E:\test>python reportip3.py
Network is Ready!
IP information from http://myip.com.tw
IP information from http://cmp.nkuht.edu.tw/info/ip.asp
IP information from http://dir.twseo.org/ip-check.php
Fail to get the Network ip. 
Get the LAN ip.
IP changed. New ip: 192.168.43.72 192.168.43.72
Successfully send the e-mail.

收到的 E-mail 是內網 IP, 到底哪裡出錯? 有空再研究.

How to: Connecting to VNC Server (5.x and before) over the Internet


2017-12-11 補充 :

前天週六晚上將改好的 report2.py 傳到 Pi 執行 crontab, 只要公網 IP 有變動就會發出郵件通知, 證實上面的做法確實可行. 由於傳檔用的 WinSCP 也是使用 22 埠 (sFTP), 也是可以透過 Internet 傳送檔案 :





Bingo! 以後就可以隨時從遠端存取鄉下樹莓派裡的檔案了.

參考 :

如何让树莓派可以被外网访问?

7 則留言 :

Kid 提到...

狐前輩 您好
最近剛好在弄從外網以ssh連線,搜尋到本文
做法也是用虛擬伺服器(Port forwarding)
在IP變動方面,因為家中Router為RT-AC66U B1
ASUS有提供自訂免費的DDNS
例:把DDNS設定為demo123.asuscomm.com
從外部連線時,輸入demo123.asuscomm.com:22即可ssh
不管浮動IP怎麼變,DDNS都是固定的

如果Router沒有提供DDNS,網路上不少服務都有提供free ddns
例:
Raspberry Pi | Free Dynamic DNS Service | Dynu
https://www.dynu.com/DynamicDNS/IPUpdateClient/RaspberryPi-Dynamic-DNS

以上分享~

小狐狸事務所 提到...

感謝您的分享, 我找時間來試試看, 這應該比較方便, 不需要在路由器上鑽洞.

Unknown 提到...

我有一個想法
只是要做很多前置作業
樹莓派裡面有要一支程式
把外網IP更改在一個TXT網頁
TXT網頁就有外網IP了(把TXT網址當做變數)
此時固定網址,只要手機APP或者直接讀TXT固定網址的IP


TXT網頁網站https://pastebin.com/

舉例像這樣https://pastebin.com/raw/xH0XaYxe
只是樹莓派要寫一支程式
要成登入該網頁做編輯的動作

ihost 提到...

如果使用gamil的話,在reportip.py裡面,在sendEmail()裡的
smtp.connect(smtpserver)
smtp.login(username, password)
這兩行之間新增另外兩行指令變成:
smtp.connect(smtpserver)
smtp.ehlo()
smtp.starttls()
smtp.login(username, password)

然後再到google帳戶裡面,在'登入和安全性'裡面,[允許安全性較低的應用程式] 設定處於啟用狀態,把這選項打開,然後送信就會正常了,以上是我用gmail送信遇到的問題~希望能幫助那些跟我遇到一樣問題的人

小狐狸事務所 提到...

我還沒時間試試 Gmail, 原來用 Gmail 還有安全性設定問題, 實在非常感謝您的分享, 我也來試試看.

Storge 提到...
作者已經移除這則留言。
carry0987 提到...

大大,如果我有一台中華電信的數據機,然後上面接了樹莓派,也接了一台TPLink,那我的筆電是連線至TPLink的,這樣要如何才能讓使用TPLink的筆電連線到它的父網路(中華電信數據機)上的樹莓派呢?