[Microsoft][Cloud] ローカルブレイクアウトを自宅でやってみたい

なお、ここで掲載している手法ですが、あくまで私の目指すゴールは「ログイン時のアクセスを通常のIPoEから固定IP経路へシフトさせ、二要素認証をバイパスする」というものであり、Enterprise方向に対するローカルブレイクアウトを実現するには行う処理が足りません。
O365から受信する経路にはIPアドレス以外に、URLもあり、そこに対するケアが出来ないと本当の意味でクラウド経路をインターネットへ寄り軽い経路に回すローカルブレイクアウトは実現できません。
このあたりはSquidで手作りでもよいでしょうし、FortigateやA10Thunder CFWなどのようなプロキシアプライアンス機能が必要になります。あらかじめご了承ください。

変動IPだと不都合なことが多い

この前、Untangle Firewallを使ってこんなことをしました。

要は、Interlink-PPPoE回線やHomenocの回線は、サーバが主に使用しているということもあり、クライアントで扱う際に結構帯域が狭くなってるのです。特にMisskeyの受信を受け持つInterlink-PPPoEは壊滅的で、下り速度がマジに遅いです。

そこでInterlink-IPoEを恒常的に使うよう、Untangle Firewallを使用しまして、回線を固定しつつセキュリティを保つことが出来たのであります。・・・が面倒なことが起きました。

Office365のSingle SignOnが効かんのです。
Azure Portalへのアクセスで常に二要素認証を求められるのです。

原因はそう、IPoE回線が変動IPアドレスだから。故に、自宅のクライアントセグメントからアクセスしようとすると大体IPが都度都度変わります。これは正直困った。事あるごとに認証をせんとイカン。

そんなあなたにローカルブレイクアウト

ローカルブレイクアウトというのは、クラウドを使用する時のインターネット負荷を軽減する手法の一つです。インターネットブレイクアウトともいいます。

用語解説は上記サイトを見てもらえれば分かるかなと思うのですが、要はOffice365やMicrosoft Azureへの経路を固定IP発信、今回の場合はHomenoc回線へ移し、それ以外をIPoE回線に移せば良いのです。

つまりこういうことがしたい。

本来このブレイクアウト処理はプロキシ製品で頑張るケースが多いです。が、エンタープライズで動かすプロキシなんて、しかもExplicit Proxyが得意な代物って意外と難しいので、おつむの弱い私にも扱えるものはないか・・・と考えたところ、何も完璧にやる必要はなくて、とりあえずIPv4経路だけでも渡したらだいぶいい感じで行けるんじゃないか?ということに気づきました。

そうだ、これしかない!・・・・そう思って1週間が経過しちゃいました。週末にやっと手が出せた・・・

まずやること:どうやって経路を取るか?

さて、Microsoftサービス系の経路ってどういうふうに取れば良いのでしょう?BGPとかでとるのか?というとそうじゃないです。実はMicrosoftにはこんなサービスがあります。

https://docs.microsoft.com/ja-jp/office365/enterprise/office-365-ip-web-service

これは何かというと、RESTAPIを使ってMicrosoftがOffice365向けに公開しているネットワークセグメントやURLの情報です。応答はJSON形式で返ってきます。昔はXML形式でこうした情報を配信していたのですが、昨年の秋頃にこれが新しくなり、より情報が取りやすくなりました。

この中のChangesメソッドというのが優秀で、現時点で保有する情報と最新の情報をバージョン番号ベースで突合して、その差分を追加経路・削除経路と言う形で配信してくれるのです。

今回作成したスクリプトは、このサイトの下の方にあるサンプルスクリプト処理を一部流用・応用しています。

早速ドツボに

で、まずはUntangle FirewallのAPI周りを調査するのですが、なんとまぁびっくりするぐらいAPIの情報がつかめない。基本的に英語情報ばっかりなんですが、このFirewall、意外とAPIの作りがバラバラで、全体を包括して使えそうなのはJava APIだけというなかなかの難易度・・・・

ここ最近体調崩しがちな私には少し無理ゲーと判断して、こちらは断念しています。
さてどうしようかなぁ・・・と頭抱えていたら、「あ、これならできるわ」というのが見つかると。

C3750GからOSPFで配ればええじゃないか

つまりこうだ。

何も直接対象機器を操作しようとしなくとも、OSPFで伝われば処理できるのでは。

IOSであれば、Python3+napalmで行けるじゃないかと考え、この構成で挑むことにしました。

MicrosoftサイトからのJSONデータ受信

処理構成としては以下のとおりです。こちらはほぼMicrosoftのサンプルスクリプトを使用しました。

# メソッドの実行とJSONデータの取得・加工
def webApiGet(methodName, instanceName, clientRequestId, nowVersion):
    ws = "https://endpoints.office.com"
    if methodName == 'changes':
        requestPath = ws + '/' + methodName + '/' + instanceName + '/' + nowVersion + '?clientRequestId=' + clientRequestId
    else:
        requestPath = ws + '/' + methodName + '/' + instanceName + '/?clientRequestId=' + clientRequestId

    request = urllib.request.Request(requestPath)
    with urllib.request.urlopen(request) as response:
        return json.loads(response.read().decode())

この辺りは関数として作ります。サンプルスクリプトではendpointsメソッドとversionsメソッドの利用しか想定しておらず、changesメソッドを使うとURLの構造が少し変わるので、今回は処理を追加しています。引数nowVersionの追加と、methodName変数をみてchangesメソッドを使う場合は処理を分けるという形にしています。

最終的には「return json.loads(response.read().decode())」によって、レスポンスとして返ってきたJSONデータをDictionary型の値として呼び出し元関数へ返します。

endpointSets = webApiGet('changes', 'Worldwide', clientRequestId, latestVersion)

実際の処理としては上記のような使い方をします。これによって、JSONデータはデシリアライズされて、DictionaryデータとしてendpointSets変数へ格納されます。

データの分解

で、これを希望する形にデータを取り出し、追加ルートと削除ルートのDictionaryデータとして返しています。

    # flatIPs = IP情報を格納するディクショナリ
    addFlatIps = []
    removeFlatIps = []

    #エンドポイントWebサービスから追加ルートと削除ルートをDictionary型に整理
    for endpointSet in endpointSets:
        if 'add' in endpointSet:
            ips = endpointSet['add']['ips'] if 'add' in endpointSet and 'ips' in endpointSet['add'] else []
            action = 'add'
            # IPv4 strings have dots while IPv6 strings have colons
            ip4s = [ip for ip in ips if '.' in ip]
            addFlatIps.extend([(action, ip) for ip in ip4s])

        if 'remove' in endpointSet:
            ips = endpointSet['remove']['ips'] if 'remove' in endpointSet and 'ips' in endpointSet['remove'] else []
            action = 'remove'
            # IPv4 strings have dots while IPv6 strings have colons
            ip4s = [ip for ip in ips if '.' in ip]
            removeFlatIps.extend([(action, ip) for ip in ip4s])

今回、JSONデータはaddキーの部分が階層化されていて、単純にIPアドレスが取り出せないようになっています。そこで、ips変数を取り出す際のデータ表記がaddとips2階層ぶん書いた書き方になっています。その後、取り出す条件式がどうもPython3だとこういう書き方になるそうで、少し見づらいなぁ・・・と感じたりはします。

実はendpointsメソッドだとこうはならなくて、1階層で済むので簡単ですが、changesはこうして二階層構造になってる箇所があるので注意が必要です。

最終的に、addFlatIps変数、removeFlatIps変数と言う2つのディクショナリが構成され、その内容は追加/削除区分とCIDR表記のIPアドレスと言うふうに単純化されています。

追加コンフィグファイルの作成

さて、削除するルート、追加するルートが変数内で固まってきたので、これを今度はCisco向けコマンドの並びに変更します。

    #Cisco3750Gに対する追加ルートファイルを発行する
    cswitch_writer = open(cmdfile_changes_tmp,'w')

    removeCount = 0
    addCount = 0

    #削除ルートをCIDR表記をネットワークアドレスとサブネットマスクに分割してコマンドに書き換える
    for (action, ip) in removeFlatIps:
        addr_v4 = ipaddress.IPv4Network(ip)
        netaddr_v4 = addr_v4.network_address
        netmsk_v4 = addr_v4.netmask
        cswitch_writer.write("no ip route "+ route_vrf + str(netaddr_v4) + " " + str(netmsk_v4) + " " + nexthop_v4addr + " " + a_distant + "\n")
        removeCount = removeCount + 1

    #追加ルートをCIDR表記をネットワークアドレスとサブネットマスクに分割してコマンドに書き換える
    for (action, ip) in addFlatIps:
        addr_v4 = ipaddress.IPv4Network(ip)
        netaddr_v4 = addr_v4.network_address
        netmsk_v4 = addr_v4.netmask
        cswitch_writer.write("ip route "+ route_vrf + str(netaddr_v4) + " " + str(netmsk_v4) + " " + nexthop_v4addr + " " + a_distant + "\n")
        addCount = addCount + 1

    cswitch_writer.close()

もともとのデータですが、CIDR表記と言って、xxx.xxx.xxx.xxx/yyと言う表記。これに対してCisco Catalystが持つコマンドはIPアドレスとサブネットマスクなので、xxx.xxx.xxx.xxx 255.255.255.255(32bitの場合) と言う風に分けなければなりません。それをやってるのが以下の箇所です。

        addr_v4 = ipaddress.IPv4Network(ip)
        netaddr_v4 = addr_v4.network_address
        netmsk_v4 = addr_v4.netmask

まず、CIDR表記のアドレスをip.address.IPv4Network()関数に放り込みます。この時点でipaddress型の変数としてaddr_v4が爆誕するそうで。あとはこの派生した関数であるipaddress.IPv4Network.network_address, ipaddress.IPv4Network.netmask各関数を使用することでCIDR表記からアドレス・サブネットマスク方式に値が変わります。

後はこれをIOSのip routeコマンドに放りこむ形で定義させるようにしています。こんなふう。

no ip route vrf vrf-internal 70.37.154.128 255.255.255.128 192.168.100.4 10
no ip route vrf vrf-internal 134.170.116.0 255.255.255.128 192.168.100.4 10
no ip route vrf vrf-internal 134.170.165.0 255.255.255.128 192.168.100.4 10
ip route vrf vrf-internal 23.103.144.0 255.255.240.0 192.168.100.4 10
ip route vrf vrf-internal 23.103.144.0 255.255.240.0 192.168.100.4 10
ip route vrf vrf-internal 13.64.196.27 255.255.255.255 192.168.100.4 10
ip route vrf vrf-internal 13.64.198.19 255.255.255.255 192.168.100.4 10
ip route vrf vrf-internal 13.64.198.97 255.255.255.255 192.168.100.4 10
ip route vrf vrf-internal 13.64.199.41 255.255.255.255 192.168.100.4 10

実機に設定を適用する。

もはやあまり記憶が残ってないので、昔のTipsを流用したのですが、実際にスイッチへ設定を組み込むのはNAPALMを使います。

    #IOSデバイスへSSH接続を試みる
    driver = napalm.get_network_driver('ios')
    device = driver(
        hostname=cswitch_hostip,
        username=cswitch_username,
        password=cswitch_password,
        optional_args={'secret':cswitch_secret})

    #IOSデバイスへ接続
    device.open()

    #ファイルに格納したコンフィグを既存コンフィグとマージする
    device.load_merge_candidate(filename=cmdfile_changes_tmp)
    pprint(device.compare_config())

    #設定を確定する
    device.commit_config()

    #接続解除
    device.close()

ドライバはiosを使うんですが、これはSSH利用を前提としている点が注意点となります。
pprintと組み合わせて、デバイスに対してコンフィグのマージを行います。投入するファイルは先に述べたJSONデータをもとに作ったアドレス情報をコマンドに変換したものです。

実行した後のスイッチの状態

スクリプトの最終形態は最後に書こうと思いますが、実際スクリプトを作って実行した結果、無事Catalyst3750Gには経路設定が入ったようです。

     51.0.0.0/32 is subnetted, 3 subnets
S       51.141.51.76 [1/0] via 192.168.100.4
S       51.140.203.190 [1/0] via 192.168.100.4
S       51.140.155.234 [1/0] via 192.168.100.4
     103.0.0.0/29 is subnetted, 1 subnets
O E2    103.247.181.136 [110/10] via 192.168.100.4, 1d08h, Vlan10
     70.0.0.0/32 is subnetted, 1 subnets
S       70.37.97.234 [10/0] via 192.168.100.4
     168.63.0.0/32 is subnetted, 3 subnets
S       168.63.29.74 [10/0] via 192.168.100.4
S       168.63.18.79 [10/0] via 192.168.100.4
 --More--         S       168.63.100.61 [10/0] via 192.168.100.4
     168.62.0.0/32 is subnetted, 1 subnets
S       168.62.43.8 [10/0] via 192.168.100.4
     168.61.0.0/32 is subnetted, 4 subnets
S       168.61.149.17 [10/0] via 192.168.100.4
S       168.61.146.25 [10/0] via 192.168.100.4
S       168.61.170.80 [10/0] via 192.168.100.4
S       168.61.172.71 [10/0] via 192.168.100.4

そして、Untangle Firewallの状態を見てみるとこんなふうになってました。

52.96.0.0/14 via 192.168.100.4 dev eth1 metric 20 
52.100.0.0/14 via 192.168.100.4 dev eth1 metric 20 
52.104.0.0/14 via 192.168.100.4 dev eth1 metric 20 
52.108.0.0/14 via 192.168.100.4 dev eth1 metric 20 
52.112.0.0/14 via 192.168.100.4 dev eth1 metric 20 
52.163.126.215 via 192.168.100.4 dev eth1 metric 20 
52.170.21.67 via 192.168.100.4 dev eth1 metric 20 
52.172.185.18 via 192.168.100.4 dev eth1 metric 20 

無事、OSPF経路を通じて配信されたようです。もちろん、Catalyst側でのredisribute設定は忘れずに。staticルートに対する再配信設定が必要です。

結果として、無事二要素認証の回避とOffice365へのSSOログインが再び出来るようになりました。JSONの扱いやRESTAPIの使い方とかは、今後ドンドン発展していく上、ものすごく抽象化した概念でデータを取り扱えるのでかなり便利だと思います。

ただまぁ、急いで作ると色んな穴は出来るけど・・・エラーハンドリングとか例外処理は別途追加しながら上手いこと動かし続けたいなーと思ってます。

スクリプトはこちら

#
#Office365/公開経路組み込みスクリプト:BLUECORE.NET:20190825
#

import json
import os
import urllib.request
import uuid
import napalm
import sys
from pprint import pprint
import ipaddress
import datetime

# メソッドの実行とJSONデータの取得・加工
def webApiGet(methodName, instanceName, clientRequestId, nowVersion):
    ws = "https://endpoints.office.com"
    if methodName == 'changes':
        requestPath = ws + '/' + methodName + '/' + instanceName + '/' + nowVersion + '?clientRequestId=' + clientRequestId
    else:
        requestPath = ws + '/' + methodName + '/' + instanceName + '/?clientRequestId=' + clientRequestId

    request = urllib.request.Request(requestPath)
    with urllib.request.urlopen(request) as response:
        return json.loads(response.read().decode())

#ログ書き出し処理
def printLogFile(severity, prtLogfile, messageText):
    with open(prtLogfile,'a') as f:
      date_now = str(datetime.datetime.now())
      lineMessage = date_now + ' ' + severity + ' '  +messageText + '\n'
      f.write(lineMessage)

# UUIDおよびVersion保存ファイルの指定
datapath = '/var/lib/endpoints/endpoints_clientid_latestversion.txt'
logFileEndpoints = '/var/log/get_endpoint_info.log'

# ルーター追加するコマンド群を格納するファイル名
cmdfile_changes_tmp = '/var/lib/endpoints/tmp-addcmds.txt'
route_vrf = 'vrf <vrf-name> '

# ルーターアクセスパラメータ
cswitch_hostip='<main l3 switch ip address>'
cswitch_username='username'
cswitch_password='********'
cswitch_secret='*******'

# NexthopアドレスとAD値
nexthop_v4addr='<nexthop ipv4 addr>'
a_distant='10'

printLogFile('INFO', logFileEndpoints, 'Start endpoint get function')

#最新バージョンの取り出し・UUID生成
if os.path.exists(datapath):
    with open(datapath, 'r') as fin:
        clientRequestId = fin.readline().strip()
        latestVersion = fin.readline().strip()
else:
    clientRequestId = str(uuid.uuid4())
    latestVersion = '0000000000'
    with open(datapath, 'w') as fout:
        fout.write(clientRequestId + '\n' + latestVersion)

# versionメソッドの呼び出しとバージョン比較
version = webApiGet('version', 'Worldwide', clientRequestId, '')

# 変更を検出したら、後続処理を実行する。
if version['latest'] > latestVersion:
    printLogFile('INFO', logFileEndpoints, 'New version of Office 365 worldwide commercial service instance endpoints detected')
    # write the new version number to the data file
    with open(datapath, 'w') as fout:
        fout.write(clientRequestId + '\n' + version['latest'])

    if latestVersion == '':
      latestVersion = '0000000000'

    # endointsメソッドの呼び出しとJSONデータ取り出し
    endpointSets = webApiGet('changes', 'Worldwide', clientRequestId, latestVersion)

    versionResult = 'Version check completed. Latest version is ' + latestVersion + '.'
    printLogFile('INFO', logFileEndpoints, versionResult)

    # flatIPs = IP情報を格納するディクショナリ
    addFlatIps = []
    removeFlatIps = []

    #エンドポイントWebサービスから追加ルートと削除ルートをDictionary型に整理
    for endpointSet in endpointSets:
        if 'add' in endpointSet:
            print("find add route\n")
            ips = endpointSet['add']['ips'] if 'add' in endpointSet and 'ips' in endpointSet['add'] else []
            action = 'add'
            # IPv4 strings have dots while IPv6 strings have colons
            ip4s = [ip for ip in ips if '.' in ip]
            addFlatIps.extend([(action, ip) for ip in ip4s])

        if 'remove' in endpointSet:
            print("find remove route\n")
            ips = endpointSet['remove']['ips'] if 'remove' in endpointSet and 'ips' in endpointSet['remove'] else []
            action = 'remove'
            # IPv4 strings have dots while IPv6 strings have colons
            ip4s = [ip for ip in ips if '.' in ip]
            removeFlatIps.extend([(action, ip) for ip in ip4s])

    #Cisco3750Gに対する追加ルートファイルを発行する
    cswitch_writer = open(cmdfile_changes_tmp,'w')

    removeCount = 0
    addCount = 0

    #削除ルートをCIDR表記をネットワークアドレスとサブネットマスクに分割してコマンドに書き換える
    for (action, ip) in removeFlatIps:
        addr_v4 = ipaddress.IPv4Network(ip)
        netaddr_v4 = addr_v4.network_address
        netmsk_v4 = addr_v4.netmask
        cswitch_writer.write("no ip route "+ route_vrf + str(netaddr_v4) + " " + str(netmsk_v4) + " " + nexthop_v4addr + " " + a_distant + "\n")
        removeCount = removeCount + 1

    #追加ルートをCIDR表記をネットワークアドレスとサブネットマスクに分割してコマンドに書き換える
    for (action, ip) in addFlatIps:
        addr_v4 = ipaddress.IPv4Network(ip)
        netaddr_v4 = addr_v4.network_address
        netmsk_v4 = addr_v4.netmask
        cswitch_writer.write("ip route "+ route_vrf + str(netaddr_v4) + " " + str(netmsk_v4) + " " + nexthop_v4addr + " " + a_distant + "\n")
        addCount = addCount + 1

    cswitch_writer.close()
    resultRoutes = 'Routing data construction completed. Add ' + str(addCount) + ', Remove ' + str(removeCount) + ' routes.'
    printLogFile('INFO', logFileEndpoints, resultRoutes)
    printLogFile('INFO', logFileEndpoints, 'L3SW Configuration data output completed.')

    printLogFile('INFO', logFileEndpoints, 'L3SW SSH connection start.')

    #IOSデバイスへSSH接続を試みる
    driver = napalm.get_network_driver('ios')
    device = driver(
        hostname=cswitch_hostip,
        username=cswitch_username,
        password=cswitch_password,
        optional_args={'secret':cswitch_secret})

    #IOSデバイスへ接続
    device.open()

    #ファイルに格納したコンフィグを既存コンフィグとマージする
    device.load_merge_candidate(filename=cmdfile_changes_tmp)
    pprint(device.compare_config())
    printLogFile('INFO', logFileEndpoints, 'L3SW configure changes completed.')

    #設定を確定する
    device.commit_config()

    #接続解除
    device.close()
    printLogFile('INFO', logFileEndpoints, 'L3SW write config completed.')

    # latest変数と一致している場合はメッセージのみ出力して終了
else:
    printLogFile('INFO', logFileEndpoints, 'Office 365 worldwide commercial service instance endpoints are up-to-date')

1ファイルで書き上げたのですが、なんだかなぁ・・・な感じではあります。行きあたりばったりの作成をしたのでそりゃぁまぁそうなるよなーと言う感じはします。

呼び出しはどーしても「python3 /usr/local/bin/******.py」ってやっちゃいますねー。変な癖がついたなぁ。

タイトルとURLをコピーしました