2012/06/30

Google App EngineでWANアドレス管理サーバー(開発編)

Google App Engineサイトを活用して家のWANアドレス管理サーバーを構築する

開発環境は前回整えたので、サーバーアプリ作ってみた。と言ってもやることは
ごく簡単なことなのでAppEngineの知識はチュートリアルレベルで十分。

アプリケーションを定義するapp.yamlは以下の様な感じ。
[app.yaml]
application: アプリ名
version: 1
runtime: python27
api_version: 1
threadsafe: yes

handlers:
- url: /favicon\.ico
  static_files: favicon.ico
  upload: favicon\.ico

- url: .*
  script: main.app
#  login: required

libraries:
- name: webapp2
  version: "2.5.1"
サンプルをコピペしてapplication: のところを変更しただけかな。

アプリにアクセスした時に起動するメインプログラムがこちら。
[main.py]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import webapp2
from google.appengine.api import users
from google.appengine.ext import db
from datetime import datetime,timedelta


class AppUser(db.Model):
    account = db.UserProperty()
    lanaddr = db.StringProperty()
    address = db.StringProperty()
    apppath = db.StringProperty()
    updated = db.DateTimeProperty()


class RootHandler(webapp2.RequestHandler):
    def get(self):
        self.response.headers['Content-Type'] = 'text/plain'
        self.response.out.write( self.request.remote_addr )


class MyHandler(webapp2.RequestHandler):
    def get(self):
        self.response.headers['Content-Type'] = 'text/html'

        user = users.get_current_user()
        if user:
            self.response.out.write(''+user.email()+'')

            app_users = AppUser.gql("where account = :1",user)
            for au in app_users:
                if self.request.remote_addr == au.address:
                    url = "http://"+au.lanaddr+au.apppath
                    non = "http://"+au.address+au.apppath
                else:
                    url = "http://"+au.address+au.apppath
                    non = "http://"+au.lanaddr+au.apppath
                self.response.out.write(""+url+"
")
                self.response.out.write(non+"
")
        else:
            self.redirect(users.create_login_url(self.request.uri))


class CommitHandler(webapp2.RequestHandler):
    def get(self):
        self.response.headers['Content-Type'] = 'text/plain'
        user = users.get_current_user()
        if user:
            app_users = AppUser.gql("where account = :1",user)

            if app_users.count() == 0:
                ip = AppUser()
            else:
                ip = app_users[0]

            ip.account = user
            ip.updated = datetime.now()
            ip.address = self.request.remote_addr
            ip.apppath = self.request.get('path')
            ip.lanaddr = self.request.get('lan')
            if ip.lanaddr == "":
                ip.lanaddr = "localhost" 
            ip.put()
            self.response.out.write(ip.address+" "+ip.lanaddr+" "+ip.apppath+" COMMITED")
        else:
            self.redirect(users.create_login_url(self.request.uri))


class AdminHandler(webapp2.RequestHandler):
    def get(self):
        self.response.headers['Content-Type'] = 'text/plain'
        user = users.get_current_user()
        if user:
            if users.is_current_user_admin():
                self.response.out.write('AppUsers')
                for u in AppUser().all():
                    self.response.out.write("\n"+u.account.email())
                    self.response.out.write(" "+u.lanaddr)
                    self.response.out.write(" "+u.address)
                    self.response.out.write(" "+u.apppath)
                    self.response.out.write(" "+str(u.updated+timedelta(hours=9)))
        else:
            self.redirect(users.create_login_url(self.request.uri))

            
app = webapp2.WSGIApplication([('/', RootHandler),
                               ('/my', MyHandler),
                               ('/commit', CommitHandler),
                               ('/admin', AdminHandler)
                               ],
                              debug=True)
サンプルをコピーして、必要なコードを書き加えていったところ、こんななりました。
Apacheライセンスである事が記述されてますが、サンプルがそうなっていたのでそのまま残してます。自分はそんなつもりはないんだけど。

順を追って説明しておくと

import webapp2
'webapp2'フレームワークを使いますってこと。サンプルがそうなってた。

from google.appengine.api import users
ユーザサービスというアカウント認証関係のAPIを使用する。WANアドレスをこのアカウントと紐付けるため。

from google.appengine.ext import db
データストアというデータベースを使用する。これ使ってデータ管理する。

from datetime import datetime,timedelta
これがちょっと悩んだんですが、サーバー側の時間が全部UTCなんですね。そこからJSTへ変換するための最小モジュールをインポート。pythonモジュールで時間関係の便利なモジュールは沢山あるけれど、やりたいことはたった一つ
self.response.out.write(" "+str(u.updated+timedelta(hours=9)))
の部分なので、timedelta使うことに。
この辺は、'google app engine utc jst'でググれば沢山親切ページが現れる。

以下の様な定義を入れておくことで、データストアにAppUserという自分専用のデータテーブルが作成される

class AppUser(db.Model):
    account = ユーザアカウント(Googleアカウントオブジェクトインスタンス)
    lanaddr = LANアドレス(コミット時に自分でパラメータ指定する)
    address = WANアドレス(self.request.remote_addrで得られる。目的の物!)
    apppath = 自分ちのサーバーアプリへのURL(コミット時に自分でパラメータ指定する)
    updated = コミット時の更新日時
プログラム上では、この'AppUser'クラスを使ってDBへアクセスする。

最初に実行されるフレームワークアプリケーションオブジェクト生成部分でハンドラの定義をする。

app = webapp2.WSGIApplication([('/', RootHandler),
                               ('/my', MyHandler),
                               ('/commit', CommitHandler),
                               ('/admin', AdminHandler)

http://[app-name].appspot.com だったら、RootHandler
http://[app-name].appspot.com/my だったら、MyHandler
http://[app-name].appspot.com/commit だったら、CommitHandler
http://[app-name].appspot.com/admin だったら、AdminHandler
で、それぞれの'get'メソッドが呼ばれる。Railsでいうところのroutesかな。

class RootHandler(webapp2.RequestHandler):
ここはとにかく単純にWANアドレスを返す。そういうサイトよくあるよね。
バッチ処理とかで使いやすいようにプレーンテキストでアドレスのみを返すようにした。結構便利。

class MyHandler(webapp2.RequestHandler):
自分のアドレスを返すところ。
ログインユーザのアカウントに紐付いたデータが登録されていれば、それをリンクで表示。これがやりたかっただけなんだけど、道のり長いっす。
ログインしていないなら、ログインページへ飛ばす。ログインしたら戻ってくる。
self.redirect(users.create_login_url(self.request.uri))
がフレームワークとユーザサービスの便利な組み合わせ。
これもチュートリアルの範囲内。

class CommitHandler(webapp2.RequestHandler):
"http://[app-name].appspot.com/commit?lan=XXXX&path=YYYY" ってアクセスする。
アカウント認証されたユーザならば、DB検索してマッチしたデータエンティティに、なければ新たなエンティティにデータを記録するところ。ログインしてなければ同じようにログインページへ飛ぶ。

class AdminHandler(webapp2.RequestHandler):
ここはデータテーブルの一覧を表示するところ。管理者(自分)のみが観覧できる。

チュートリアルに従って作って、ブラウザ上からアクセスして機能テスト。問題なし!

実際にはここまで作るだけでも試行錯誤したけど・・・本題とは遠いところで詰まった。
例えば、UTC=>JSTのシンプル解決に悩んだりとか。
適当なモジュールをインポートしてもサーバーになかったりとか。
途中からDBモデルにlanaddrを追加したんだけれど、なぜか更新されないとか。
'https://appengine.google.com'のアプリ管理からデータテーブルを削除したり、ログ見たり・・・

だけど、一番苦労したのは、ログイン認証だ。
サーバーは出来たが誰でもアクセスできちゃう。管理ユーザ(自分)だけしかアクセスできないパーミッション設定が可能だけど、それはそれで面倒だ。
個人のデータを保持するにはやはり個人を特定する認証が必要なのだ。
(もし利用希望者がおられれば、URLお教えしますが・・・)

ブラウザからならログインページが出て、ログインしたら後はブラウザがよろしくやってくれるけど、やりたいのは自分ちのUbuntuServerから、cronタスクで処理したいので、ブラウザがやってることを自分でやらんとならん。

googleアカウント認証は、こちらを参考にしましたよ
http://johannilsson.com/2011/04/13/authenticated-requests-on-app-engine.html
http://maimon-it.blogspot.jp/2010/04/authenticating-google-app-engine-apps.html

スクリプトでのログイン認証と、認証付きアクセスを記述したものがこちら
[gauth.py]
#!/usr/bin/python
# -*- coding: utf-8 -*-

from subprocess import *
from os import path

mail = 'Googleメールアドレス'
password = 'パスワード'
app = 'アプリ名'

app_url = 'http://'+app+'.appspot.com/'
cookie_file = path.expanduser("~")+'/.google_myapp_cookie'

def login():
    try:
        # API認証キーを取得
        print 'Get Auth-Key...\n'
        auth = Popen(['curl','-f','-s',
                      'https://www.google.com/accounts/ClientLogin',
                      '-d','accountType=HOSTED_OR_GOOGLE',
                      '-d','Email='  + mail,
                      '-d','Passwd=' + password,
                      '-d','source=' + app,
                      '-d','service=ah'],
                     stdout=PIPE).communicate()[0]
        # ログインしてクッキー取得
        print 'Login...\n'
        login = Popen(['curl','-c','-',
                       app_url+'_ah/login?auth='+auth[ auth.rindex("Auth=")+5:-1]],
                      stdout=PIPE).communicate()[0]
        cookie = login[login.rindex("ACSID")+6:-1]

        # クッキーの保存
        f = open(cookie_file,'w')
        f.write(cookie)
        f.close()
        return cookie
    except:
        return ""

def get(controller):
    try:
        f = open(cookie_file)
        cookie = f.read()
        f.close()
    except:
        cookie = login()

    try:
        return Popen(['curl',app_url+controller,'-b','ACSID='+cookie],
                     stdout=PIPE).communicate()[0]
    except:
        return ''

pythonのsubprocessや、外部コマンド'curl'については、親切解説しているところが沢山あるんで。そちらで、よろしくです。

処理内容を簡単に説明する

loginメソッド:(ログインしてクッキーを得るまで)

'https://www.google.com/accounts/ClientLogin' へ所定のパラメータ付きでアクセスしてアカウント認証データを取得。
'http://[app-name].appspot.com/_ah/login?auth=認証データのAuth=以降のデータ列' ってやるとログイン出来る。
ログインした時に得られるデータ内に"ACSID"というキーワードのデータ列があるんで、その部分を切り出して保存。
これがこのサイトへのログインクッキーデータ。
ドキュメントによると、Googleアカウントだけじゃなく、OpenIDでの認証も出来るらしいけど、その辺は無視。

getメソッド:(クッキー使ってアクセス)

クッキーデータをファイルから読んで、認証付きで指定URLへGETアクセスする。
手順がやっつけっぽいが、一応機能する。
Cookieが無効になる期限がどれくらいなのかイマイチ不明なので、その辺調べてはっきりしてから、ちゃんとした手順に直そうかな。
・・・
ということで、修正した getメソッドがこちら

def get(controller):
    for i in range(2):
        try:
            f = open(cookie_file)
            cookie = f.read()
            f.close()
            ret = Popen(['curl',app_url+controller,'-b','ACSID='+cookie],
                        stdout=PIPE).communicate()[0]
            if ret.find('COMMITED')>0:
                return 'ok'
            else:
                raise
        except:
            cookie = login()
    return 'err'

Cookieが無効になるのは、https://appengine.google.com から、アプリ設定ページでCookie Expirationというところで設定するのだった。1日、1週間、2週間が設定できる。デフォルトは1日だった。最長の2週間に設定したがそれでも短いので、コミットに失敗したらログインからやり直しするように変更した。

cronで実行するスクリプト
[gcommith.py]
#!/usr/bin/python
# -*- coding: utf-8 -*-
import gauth
# コミット
#gauth.get('commit?lan=192.168.X.Y&path=/foo')
gauth.get('commit?lan=192.168.X.Y')

cronタスク
crontabでユーザレベルタスクで回してます。
$ crontab -e
ってやって、自分の好きなエディタで開いたら

0 3 * * * python ~/Grive/gcommit.py

って書いておく。毎日3時にコミット実行です。crontabで回すと自分にメールでタスク結果が飛んでくる。開発中は飛んでくるのが便利だけど、落ち着くとうざいね。止めちゃおっかな。

この記事をよく読んだ奇特な方はおや?と思ったかも。
gcommit.pyの冒頭に、#!/usr/bin/pythonって書いてあるのに、なぜに「python ~/Grive/gcommit.py」なのかと。その謎は、パス名[/Grive]に隠されております。
どうでも良い話ですが・・・次回はその話にちょっと脱線します。

0 件のコメント:

コメントを投稿