【悪用厳禁】AWS Lambda で Tor を使う

公開:2020/10/08
更新:2020/10/09
8 min読了の目安(約7900字TECH技術記事
Likes36

AWS の Lambda 経由でクローリング/スクレイピングを行いたい場合、Headless Chrome (chromium) を用いることで実現できますが、更にその際、Tor を用いることで IP アドレスを秘匿化する方法について紹介したいと思います。

※ 実装は TypeScript で行っていきます。

Tor のレイヤー化

Tor ブラウザ」があるので勘違いされやすいですが、Tor は別に Tor ブラウザに限定されているものではなく、自分でtorコマンドを実行することで、例えば普段使っている Chrome 等の一般のブラウザも「Tor化」することができます。

torコマンドは こちら の Tor プロジェクトでバージョン管理されており、ダウンロード&コンパイルすることで誰でも簡単に使用することができます。また、自分でコンパイルせずとも、例えば MacOS だと Homebrew 経由で brew install tor で簡単にインストールすることもできます。

Lambda上で使うには、Linux環境でコンパイルしたファイルを用いなければエラーが起きてしまうので、Dockerコンテナ内でダウンロード&コンパイルするのが良いでしょう。今回はその手順は省略しますが、事前にコンパイルして zip 化したものをこちらのリポジトリに用意したので、zip ファイルを Lambda レイヤーとしてアップロードするだけで簡単に参照することができます。実行ファイルのパスは /opt/bin/tor となります。

また、リポジトリ内に serverless.yml も合わせて書いてあるので、sls deploy を実行するだけでアップロードが完了するようにもなっています。torファイル自体は15MB弱なので、充分 Lambda で扱うことができるサイズです。

Headless Chrome

※ 最終成果物はGitHubリポジトリにアップロードしてあります。こちらも serverless に対応しています。

Tor のレイヤー化は終わったので、あとは Headless Chrome から呼び出すだけです。ひと昔前は Headless化するのに一苦労した記憶がありますが、今は簡単にインストールして使うことができますね。

$ yarn add chrome-aws-lambda

でモジュールをインストールしたら、下記のように chromium をインポートするだけで準備は完了です。

import chromium from 'chrome-aws-lambda'

あとは、

await chromium.puppeteer.launch({ options })

とすることで Headless Chrome を立ち上げることができます。Lambda で Tor を使うには、

  • Headless Chrome 起動前に tor を実行(起動)
  • 上記の options に Tor を使う旨を記述

という流れをとることになります。

Tor のモジュール化(クラス化)

tor の起動を簡単に行えるように、クラス化しておきましょう。tor は設定ファイルとして .torrc というファイルを見ますが、その中には下記の2つが書かれている必要があります。

  • SOCKSPort: SOCKSプロキシ通信用ポート
  • DataDirectory: データ送受信用ディレクトリパス

Lambda では一時ディスク容量が用意されており、tmpファイル・ディレクトリを作成することができるので、ここに .torrc および DataDirectory に対応するディレクトリを作成します。

結局、主に実装すべき点は以下の3つになります。

  • 一時ファイル・ディレクトリの作成
  • tor の実行
  • 終了時のクリーンアップ

以上を踏まえて、Torクラス化した実装を見てみましょう。コメント付きで全体を書いてみました。

/* tor.ts */
import { spawn, execSync, ChildProcessWithoutNullStreams } from 'child_process'
import tempfile from 'tempfile'
import { IS_LOCAL } from './env' // ローカル環境かどうかの判別。ファイルの中身は後述

export default class Tor {
  port: number       // SOCKSPort に対応
  dataDir: string    // DataDirectory に対応
  torrcPath: string  // 一時ファイルとしての .torrc のパス
  torPath: string = `/opt/bin/tor`
  proc: ChildProcessWithoutNullStreams

  constructor(port: number) {
    this.port = port
  }

  async launch() {
    if (IS_LOCAL) { // ローカルでは tor は起動済という前提
      return
    }

    this.createTempfiles() // 一時ファイル・ディレクトリを作成

    // tor の起動には時間がかかるため Promise を返しておく
    return new Promise((resolve, reject) => {
      // tor の起動
      this.proc = spawn(this.torPath, ['-f', this.torrcPath], {
        cwd: this.dataDir,
      })
      
      // 起動完了まで待機
      this.proc.stdout.on('data', (data: Buffer) => {
        if (data.toString().match(/100%/)) { // 起動完了
          resolve()
        }
      })

      this.proc.stderr.on('data', (data) => {
        reject(`Failed tor initialization: ${data}`)
      })

      this.proc.on('close', (code) => {
        if (code) {
          reject()
        } else {
          resolve()
        }
      })
    })
  }

  createTempfiles() {
    // 一時ファイル・ディレクトリのパス生成
    this.dataDir = tempfile('.data')
    this.torrcPath = tempfile('.torrc')

    // .data および .torrc の作成
    const cmds = [
      `mkdir ${this.dataDir}`,
      `touch ${this.torrcPath}`,
      `echo 'SOCKSPort ${this.port}\n' > ${this.torrcPath} && echo 'DataDirectory ${this.dataDir}\n' >> ${this.torrcPath}`,
    ]

    for (let cmd of cmds) {
      execSync(cmd)
    }
  }

  close() {
    if (IS_LOCAL) {
      return
    }

    // tor の終了
    this.proc.kill('SIGINT')

    const cmds = [`rm -rf ${this.dataDir}`, `rm -rf ${this.torrcPath}`]
    for (let cmd of cmds) {
      execSync(cmd)
    }
  }
}

上記コード内の env ファイルは、下記となります。

/* env.ts */
export const IS_LOCAL = process.env.IS_LOCAL === 'true' ? true : false

Lambdaをローカルで試すことができる serverless-offline を利用すると、

$ sls invoke local -f <function_name>

とすることで関数を実行できますが、その際に IS_LOCAL=true という環境変数がセットされます。これをきちんと boolean で評価できるように記述したものになります。

Headless Chrome w/ Tor のモジュール化(クラス化)

さて、ここまでで Headless Chrome および Tor を利用する準備は整ったので、両者を組み合わせて使えるように実装をしていきましょう。

先述したとおり、chromium.puppeteer.launch({ options }) の引数に、Torを使用するための記述をするだけです。

/* browser.ts */
import 'source-map-support/register'

import chromium from 'chrome-aws-lambda'
import { Browser } from 'puppeteer-core'
import { IS_LOCAL } from './env'
import os from './os' // ローカルで Win/Mac の判別用。ファイルの中身は後述
import Tor from './tor'

// Tor のポート
const PORT = 9050

// Chromium のデフォルトのユーザーエージェントは `webdriver`
export const UA =
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)' +
  'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'

// ローカルの場合は、インストールしてある Chrome を起動
let path: string = ''
switch (os) {
  case 'win':
    path = 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
    break
  case 'mac':
    path = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    break
}

export interface LaunchOptions {
  headless?: boolean
  useTor?: boolean
}

export class Chrome {
  browser?: Browser // Headless Chrome 本体
  tor: Tor | undefined

  async launch({ headless, useTor }: LaunchOptions) {
    headless = headless === false ? false : true
    let { args } = chromium
    if (useTor) {
      // 事前に tor を実行
      this.tor = new Tor(PORT)
      await this.tor.launch()
      
      // Tor を利用するために、引数にSOCKSプロキシの設定を追加
      args.push(`--proxy-server=socks5://127.0.0.1:${PORT}`)
    }

    this.browser = await chromium.puppeteer.launch({
      args,
      defaultViewport: chromium.defaultViewport,
      executablePath: IS_LOCAL ? path : await chromium.executablePath,
      headless,
      ignoreHTTPSErrors: true,
    })
  }

  async close() {
    if (this.tor) {
      this.tor.close()
    }
    if (this.browser != null) {
      await this.browser.close()
    }
  }
}

コード内のOS判別は下記の実装で行っています。

/* os.ts */
export const platform = process.platform

export let os: 'win' | 'mac' | 'linux' | 'unknown' = 'unknown'

switch (platform) {
  case 'win32':
    os = 'win'
    break
  case 'darwin':
    os = 'mac'
    break
  case 'linux':
    os = 'linux'
    break
}

export default os

あとは

const chrome = new Chrome({ headless: true, useTor: true })

とすれば、Torブラウザ化の完了です。

Let's Try!

実際に試してみましょう!

/* main.ts */
import 'source-map-support/register'

import { Handler } from 'aws-lambda'
import { Chrome } from './browser'

export const handler: Handler = async (_event, _context, callback) => {
  const chrome = new Chrome()
  const url = 'https://ipinfo.io/'

  let content: string[]
  try {
    await chrome.launch({ headless: true, useTor: true })
    const page = await chrome.browser.newPage()
    await page.goto(url, { waitUntil: 'networkidle2' })
    await page.waitForSelector('.json-widget-entry')
    content = await page.$$eval('.json-widget-entry', (elems: any) => {
      return elems.map((el: any) =>
        (el.textContent as string).replace(/\n|\s/g, '')
      )
    })
  } catch (e) {
    console.error(e, e.stack)
    return callback(e, null)
  } finally {
    await chrome.close()
  }

  return callback(null, content)
}

以上をデプロイ&実行すると、

[
  ...
  "city:\"Wiesbaden\"",
  "region:\"Hesse\"",
  "country:\"DE\"",
  ...
  "tor:true",
]

という結果となり、海外からのアクセスになっていることが確認できます。 tor:true という表示もあるので、「Tor化」できていることもわかりました。

実装は以上です!Lambda上でTor化するのは意外と簡単だったことがわかったかと思います。これで自分の身元は隠匿することはできますが、決して悪用はしないようにしてください。
(また、先述した tor:true とあるように、Torかどうかは判別できるので、そもそもサービスによってはアクセスを弾いているものもあります。)

リポジトリ内では、Chromeクラスで preventBotDetection というコードも記述しています。これは Chromium 経由のアクセスであることを隠すための実装ですが、この内容に関しては別の機会に紹介したいと思います。