UPnPで取得したIPアドレスでDDNS更新

# DDNS(DynamicDNS)のIPアドレスを定期更新
DDNSサービスのIPアドレス情報を更新するスクリプト。
UPnPと組み合わせるパターンを見かけなかった(知らないだけ？)ので、メモとして残しておきます。
2021.05追記：引っ越しをしまして自宅回線がVDSLからひかり収容に変わりました(≒終端装置が変わった)。プロバイダ(asahiネット)のDS-Liteが使えるようになったのでを有効にしたところ、No-IPで引いたアドレスから自宅VPNにアクセスできなくなってしまったので、その対策を記載。

* WAN側IPアドレスの取得はUPnP（トラフィックは無駄に増やさない）、デフォゲからの応答のみ選別。
* IPアドレスに変更がない時はリクエストを送信しない
* 24時間に1回はリクエストを送信
* ログはsyslogに送信
* No-IPには、正引きのIPアドレスをパラメータで明示

# DDNSサービス（No-IP）
無料のDDNSサービスはいくつかありますが、[No-IP](https://www.noip.com/)を対象に説明。
他のサービスでも似たような感じのはずなのでスクリプトは適宜修正のこと。

# UPnPクライアント
ルータのWAN側IPアドレス取得のために[MiniUPnP](http://miniupnp.free.fr/)を使用。
フレッツのGWはじめ家庭用のルータなら大抵UPnP使えるはず。

## apt-getでインストール
Raspbian(Debian系)なら一発。

```bash
# apt-get install miniupnpc
```

## 自分でビルド
[MiniUPnP](http://miniupnp.free.fr/)からminiupnpcのソースを取得。
./configureで始めるタイプではないので少し癖があるものの、まだ読める範囲なので指定できる引数はMakefile参照。
下記は基本的な例。

```bash
$ tar xfz miniupnpc-2.1.20191224.tar.gz
$ cd niupnpc-2.1.20191224
$ make           # クロスコンパイル時はmake引数にCC=*****-gcc のようにコンパイラを指定
$ sudo su
# make install   # インストール先を指定したい場合はDESTDIR, INSTALLPREFIXをmake引数に指定(指定なければ/usr/bin/upnpc)
```

# デフォゲのアドレス取得
UPnPクライアントは初めにブロードキャストを投げてサーバーからの応答を待つ。
miniupnpcは、UPnPサーバーのdescription URLを指定しないと、初めに応答があったサーバーの情報しか表示しない。ルータと(ルータを転用した)無線LANのAPなど複数のUPnP対応機器がネットワーク内にあると期待した情報を取得できなくなるので、ターゲット(=デフォゲ)にするIPアドレスを事前に確認しておく必要あり。

```bash
# default GW IP addr
declare -r DEFAULT_GW_ADDR=`ip route | grep default | awk '{ print $3 }'`
```

# UPnPでWAN側IPアドレス取得
デフォゲから返ってきたdiscoveryに対する応答(description URL)のみ拾って、外部IPアドレスを取得。
2021.05追記:我が家のホームゲートウェイ(RX-600MI、以後HGW)だと、UPnPで拾ってくる外部IPアドレスはPPPoEで取得したアドレスです(DS-LiteのIPv4アドレスではない)。これがちょっと問題になります(後述)

```bash
# retrieving External IP address using miniupnpc
declare -r IGD_URL=`upnpc -P | grep -P "^ desc: http://${DEFAULT_GW_ADDR}:.+$" | grep -o -P "http://.+$"`
if ! [[ ${IGD_URL} =~ http:\/\/.+$ ]]; then
    echo "No UPnP server found."
    exit 1
fi
declare -r EXTERNAL_IP_NEW=`upnpc -u ${IGD_URL} -s | grep ExternalIPAddress | grep -o -P "\d+\.\d+\.\d+\.\d+"`
if ! [[ ${EXTERNAL_IP_NEW} =~ [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
    echo "No valid External IP address found."
    exit 1
fi
```

# No-IPのIPアドレス更新
とりあえず把握しているレスポンス一覧
* good : 更新成功
* nochg : アドレス変更なし
* nohost : 該当ホスト名なし
* badauth : 認証エラー

2021.05追記：今までの記事ではGET引数に自分のIPアドレスをパラメータとして明示していませんでした。No-IPではIPアドレスの指定がない場合、httpアクセス元のアドレスをドメイン名に対応するアドレスとして登録します。
DS-Liteが有効の場合このアクセスがDS-Lite経由で出て行ってしまうため、No-IPにはDS-LiteのIPv4アドレスが登録されてしまいます。ですが、DS-Liteで付与されるIPアドレスはサーバー用途には使えないため、HGWにL2TPが通らず、No-IPで引いたアドレスから自宅へのVPN接続ができなくなってしまいました。
よって、明示的にPPPoE側のアドレスをNo-IPに設定する必要があります。myipパラメータを設定するだけなんですが。
※プロバイダによって、DS-LiteとPPPoEの共存が(許容セッション数の問題で)できたりできなかったりするそうです。AsahiネットはOKでした。

```bash
declare -r NOIP_ACCOUNT='YOURACCOUNT'
declare -r NOIP_PASSWD='YOURPASSWORD'
declare -r NOIP_HOSTNAME='YOURFQDN'

declare -r NOIP_URL="https://${NOIP_ACCOUNT}:${NOIP_PASSWD}@dynupdate.no-ip.com/nic/update?hostname=${NOIP_HOSTNAME}&myip=${EXTERNAL_IP_NEW}"

declare -r NOIP_STATUS=`curl --silent ${NOIP_URL}`
if [[ ${NOIP_STATUS} =~ ^good.+ ]]; then
    echo "Updated ${EXTERNAL_IP} to ${EXTERNAL_IP_NEW}"
elif [[ ${NOIP_STATUS} =~ ^nochg.+ ]]; then
    echo "Keep current IP addr ${EXTERNAL_IP_NEW}"
else
    declare -r RESP=`echo ${NOIP_STATUS} | sed -e 's/[\r\n]*$//'`
    ${LOG_WARN} "Unknown response (${RESP})"
fi
```

# スクリプト全体
<details><summary>ddns_noip.sh</summary><div>

```bash:ddns_noip.sh
#!/bin/bash -u

# No-IP configuration
declare -r NOIP_ACCOUNT='YOURACCOUNT'
declare -r NOIP_PASSWD='YOURPASSWORD'
declare -r NOIP_HOSTNAME='YOURFQDN'

declare -r NOIP_URL='https://${NOIP_ACCOUNT}:${NOIP_PASSWD}@dynupdate.no-ip.com/nic/update?hostname=${NOIP_HOSTNAME}\&myip=${EXTERNAL_IP_NEW}'

#############################################################################################

# working dir(should be in tmpfs area)
declare -r RUN_DIR='/run/ddns_noip'
declare -r CURRENT_IP_FILE="${RUN_DIR}/current_ip"
declare -r LAST_UPDATED_FILE="${RUN_DIR}/last_updated"

# syslog
declare -r LOG_TAG='DDNS_NOIP'  # No space permitted
declare -r LOG_WARN="logger -p warning -t ${LOG_TAG} "
declare -r LOG_INFO="logger -p info    -t ${LOG_TAG} "
#############################################################################################
# make working directory
mkdir -p ${RUN_DIR}

#  Previous External IP address
if [ -f ${CURRENT_IP_FILE} ]; then
    declare -r EXTERNAL_IP=`cat ${CURRENT_IP_FILE}`
else
    declare -r EXTERNAL_IP='0.0.0.0'
fi

# is miniupnpc installed?
which upnpc  > /dev/null
if [ $? -ne 0 ]; then
    ${LOG_WARN} "No miniupnpc found."
    exit 1
fi

# default GW IP addr
declare -r DEFAULT_GW_ADDR=`ip route | grep default | awk '{ print $3 }'`

# retrieving External IP address using miniupnpc
declare -r IGD_URL=`upnpc -P | grep -P "^ desc: http://${DEFAULT_GW_ADDR}:.+$" | grep -o -P "http://.+$"`
if ! [[ ${IGD_URL} =~ http:\/\/.+$ ]]; then
    ${LOG_WARN} "No UPnP server found."
    exit 1
fi
declare -r EXTERNAL_IP_NEW=`upnpc -u ${IGD_URL} -s | grep ExternalIPAddress | grep -o -P "\d+\.\d+\.\d+\.\d+"`
if ! [[ ${EXTERNAL_IP_NEW} =~ [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
    ${LOG_WARN} "No valid External IP address found."
    exit 1
fi

# get time of last updated
if [ -f ${LAST_UPDATED_FILE} ]; then
    declare -r LAST_EPOCH=`cat ${LAST_UPDATED_FILE}`
else
    declare -r LAST_EPOCH=0
fi

# get current epoch time
declare -r TIME=`date`
declare -r TIME_EPOCH=`date -u +%s`
declare -r TIME_DIFF=`expr ${TIME_EPOCH} - ${LAST_EPOCH}`

# update IP address to No-IP if
#  * elapsed 1 day since last update OR
#  * IP address is changed
if [ ${TIME_DIFF} -gt 86400 ] || [ ${EXTERNAL_IP} != ${EXTERNAL_IP_NEW} ]; then
    declare -r NEW_URL=$(eval echo ${NOIP_URL})    # lazy evaluation for ${EXTERNAL_IP_NEW}
    declare -r NOIP_STATUS=`curl --silent ${NEW_URL}`

    if [[ ${NOIP_STATUS} =~ ^good.+ ]]; then
        echo ${EXTERNAL_IP_NEW} > ${CURRENT_IP_FILE}
        echo ${TIME_EPOCH} > ${LAST_UPDATED_FILE}
        ${LOG_INFO} "Updated ${EXTERNAL_IP} to ${EXTERNAL_IP_NEW}"

    elif [[ ${NOIP_STATUS} =~ ^nochg.+ ]]; then
        echo ${EXTERNAL_IP_NEW} > ${CURRENT_IP_FILE}
        echo ${TIME_EPOCH} > ${LAST_UPDATED_FILE}
        ${LOG_INFO} "Keep current IP ${EXTERNAL_IP_NEW}"

    else
        declare -r RESP=`echo ${NOIP_STATUS} | sed -e 's/[\r\n]*$//'`
        ${LOG_WARN} "Unknown response (${RESP})"
    fi
else
    ${LOG_INFO} "Nothing to do (IP:${EXTERNAL_IP_NEW}, TIMEDIFF:${TIME_DIFF})"

fi

exit 0
```

</div></details>

# おまけ。crontabに登録
/etc/cron.d/ddns_noip.sh (パーミッションは0755)に上記ファイルがあるとして、10分おきに実行するように設定。
変更時||1日1回 しか外部にアクセスしない、相手に迷惑をかけない優しいスクリプト。
※ラズパイでは/etc/crontabを直接編集したら怒られたので、下記のように専用のコマンドで追加。

```bash:
# crontab -e   # 下記行追加
*/10 * *  * * /etc/cron.d/ddns_noip.sh
```