blog

アクセスログを地図にプロットする

Published:

By nob

Category: Posts

Tags: OpenBSD Python MaxMind GeoIP SQLite3

Webサイトのアクセスログを解析して、どこからアクセスされたのか地図に表示できたら面白そうだと考えた。

前提

software version
OpenBSD 7.5 -game*
Python 3.10.14

手順

GeoIPサービス(IPアドレスと緯度・経度を変換する)

IPアドレスから緯度・経度を探すというのは昔 MaxMind のデータベースを使って試したことがあった。最近どうかなと思って調べる。

  • ipstack

    Webサービス。月100件のリクエストまで可。 Bulk Lookup という機能があり、一度に50IPを指定して検索ができるらしい。要登録。

  • MaxMind

    Webサービス・ローカル。GeoIPデータベースをダウンロードできる。データベースは随時更新されている模様。各種言語で Official Client API がある。要登録。

検索回数の上限があるとちょっと大変だなと思ってデータベースをダウンロードできるMaxMindにする。

ちょっと見てみたいという程度の動機なので緯度・経度の精度については気にしない。(※後でMaxMindのデータベースを確認したところ、5kmの精度( accuracy radius )のデータがあった。)

登録手順は 公式ホームページ 記載の通り。

Client APIのインストール

# pip install geoip2

データベースのダウンロード

作業ディレクトリを作成して、MaxMindのアカウント画面からデータベースファイルをダウンロードする。今回はCityデータベース。

# mkdir /var/maxmind

ログ格納ディレクトリを作成する。

# mkdir /var/maxmind/log

Clientの作成

/var/maxmind にスクリプトを配置する。

import re
import sqlite3
from datetime import datetime

import geoip2.database
import geoip2.errors

# 表示したくないアドレスをリストする。自宅など。
inet_addr_ignore = ["0.0.0.0", "127.0.0.1"]

with sqlite3.connect("/var/maxmind/log/access.db") as con:
    cur = con.cursor()
    cur.execute(
        "create table if not exists access (time datetime, ip varchar(15), lat real, lng real, name text, primary key (time, ip))"
    )
    cur.execute("create index if not exists idx_access_1 on access (time)")
    cur.execute("create index if not exists idx_access_2 on access (ip)")
    with geoip2.database.Reader("/var/maxmind/GeoLite2-City.mmdb") as geoip:
        with open("/var/log/relayd", encoding="utf-8") as f:
            # TODO User-Agent
            matcher = re.compile(
                "(?P<time>^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([1-2][0-9]|3[0-1]| [1-9]) ([0-5][0-9]:){2}([0-5][0-9]){1})"
                ".*?"
                r"(?P<ip>((1[0-9][0-9]|2[0-4][0-9]|25[0-5]|[1-9][0-9]|[0-9])\.){3}((1[0-9][0-9]|2[0-4][0-9]|25[0-5]|[1-9][0-9]|[0-9]){1}))"
                ".*?"
            )
            for line in f:
                result = matcher.search(line)
                if not result:
                    continue
                # TODO 年末年始
                time = datetime.strptime(
                    result.group("time"), "%b %d %H:%M:%S"
                ).replace(year=datetime.today().year)
                access_time = time.strftime("%Y-%m-%d %H:%M:%S")
                inet4_addr = result.group("ip")
                if inet4_addr in inet_addr_ignore:
                    continue
                try:
                    response = geoip.city(inet4_addr)
                    params = (
                        access_time,
                        inet4_addr,
                        response.location.latitude,
                        response.location.longitude,
                        response.city.name,
                    )
                    cur.execute(
                        "insert into access(time, ip, lat, lng, name) values (?, ?, ?, ?, ?) on conflict (time, ip) do nothing",
                        params,
                    )
                except geoip2.errors.AddressNotFoundError:
                    print(access_time, inet4_addr)
    con.commit()

地図を表示するHTMLファイルの作成

<!DOCTYPE HTML>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>visitor</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
        <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
        <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
        <script type="text/javascript">
            window.addEventListener("load", function() {
                const map = L.map('map', { zoom: 3, worldCopyJump: true, });
                const tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                    attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a>, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>',
                });
                tileLayer.addTo(map);

                const features = [];
                const bounds = L.latLngBounds([35.689503, 139.691727]);
                const places = 
// #IP#
                ;
                for (const place of places) {
                    L.marker([place.lat, place.lng])
                        .addTo(map)
                        .bindPopup(place.name);
                    bounds.extend([place.lat, place.lng]);
                }
                map.fitBounds(bounds);
            });
        </script>
        <style>
            body
            {
                padding: 0px;
                margin: 0px;
                height: 100vh;
                width: 100vw;
            }
            #map
            {
                height: 100vh;
                width: 100vw;
            }
        </style>
    </head>
    <body>
        <div id="map"></div>
    </body>
</html>

crontabに登録するシェルスクリプトの作成

#!/bin/ksh
/usr/local/bin/python3 /var/maxmind/extract_access.py
/usr/local/bin/sqlite3 /var/maxmind/log/access.db  ".mode json" ".once /var/maxmind/log/access.json" "select distinct ip, lat, lng, coalesce(name, 'n/a') as name from access order by ip"
sed '/#IP#/r /var/maxmind/log/access.json' /var/maxmind/visitor.tmpl > /tmp/visitor.html
uuencode /tmp/visitor.html visitor.html | mail -s "visitor report" blog@nobituk.net
# chmod +x /var/maxmind/report_access_log.sh

crontabの設定

日次で起動するように設定。

0       0       *       *       *       /var/maxmind/report_access_log.sh

完成

pic-0