Spring Security でCSRFトークンを使ってみる

Spring Security を使ってCSRFトークンを使ってみたという話です。

CSRFとは

Cross Site Request Forgery (CSRF) とは、ログインした状態のユーザが悪意のあるリンク等をクリックすることで、意図しない操作をアプリケーションに送信する攻撃のことです。受け取ったアプリケーションはリクエストを正当なものか、不当なものか判断できないので意図しない操作を実行してしまうことになります。

CSRFについての解説は以下のサイトがわかりやすいと思います。

クロスサイトリクエストフォージェリ - Wikipedia

Spring Security リファレンス - ドキュメント

Spring Security でCSRF攻撃を防ぐ

Spring Security にはCSRF攻撃を防ぐ機能が実装されています。CSRF攻撃を防ぐには一般的に次の2つの方法があります。

Spring Security ではシンクロナイザートークンパターンを使った実装が提供されているのでこれを使ってみましょう。以下の公式リファレンスにはシンクロナイザートークンパターンの解説が載っています。

https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#csrf-protection-stp

シンクロナイザートークンパターンを使ったCSRF攻撃の防ぎ方を簡単に説明するとこんな感じになります。

f:id:bau1537:20201123141120p:plain

  1. クライアントがGETリクエストをWebアプリケーションに送信する。
  2. WebアプリケーションはCSRFトークンを生成して、HTTPセッションに保存する。
  3. WebアプリケーションはCSRFトークンをクライアントに返す。
  4. クライアントはPOSTリクエスト(他、状態を変更するリクエストでも同じ)に受け取ってあるCSRFを付けて送信する。
  5. Webアプリケーションは受け取ったCSRFトークンとHTTPセッションに保存しておいたCSRFトークンが同一か検証する。同一でなければ不正なリクエストとして403ステータスをクライアントに返却する。

CSRFトークンとはランダムな文字列のことです。Spring Security ではデフォルトでUUIDによるCSRFトークンを生成するようになっています。

一連の流れで重要なのは、クライアントがPOSTリクエストを送信する前に、GETリクエストをWebアプリケーションに対して送信してCSRFトークンを受け取っているところです。CSRFトークンは悪意のあるリクエストと正当なリクエストを区別するために発行される一時的な証明書のようなものなので、一度GETリクエストを行ってCSRFトークンを取得しなくてはいけません。GETリクエストを事前に行ってCSRFトークンを取得することで、悪意のあるリンクをユーザーがクリックし、ユーザーになりすましたリクエストがWebアプリケーションに送信されたとしても、CSRFトークンが含まれていないリクエストは拒否される事になり、CSRF攻撃を防ぐことができるのです。

プロジェクトの設定

環境は以下の通り。

bash-3.2$ java --version
openjdk 14.0.2 2020-07-14
OpenJDK Runtime Environment (build 14.0.2+12-46)
OpenJDK 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)

プロジェクトの pom.xml 以下の通り。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

CSRFトークンを使用してログイン

Spring Security は特に設定せずともCSRFトークンを用いてログインするように設定されています。

まずはコンフィグレーションでユーザの作成、エンドポイントの保護を行います。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        UserDetails user = User.withUsername("mary")
                .password("12345")
                .authorities("READ")
                .build();
        inMemoryUserDetailsManager.createUser(user);
        return inMemoryUserDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }


    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated();
        
        http.formLogin()
                .defaultSuccessUrl("/product", true);
    }

}

アプリケーションを起動すると以下のようなログイン画面が返却されると思います。このフォームの中で hidden が設定されている _csrf パラメータがCSRFトークンになります。

<form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
        <input name="_csrf" type="hidden" value="f8c41886-abc9-4a34-aeb6-ae0b969367e2" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>

デフォルトのログインでは上記のHTMLで分かる通りGETリクエストによって取得した画面の中にhiddenパラメータとしてCSRFトークンが埋め込まれ、フォームを送信するときにWebアプリケーションに対してCSRFトークンを送信する動作になります。埋め込まれている値はWebアプリケーションがセッションごとに生成したUUIDの値です。

この hidden パラメータを使ったやり取りは独自にPOSTリクエストを送信するときも同じように使用できます。

CSRFトークンを使って独自のPOSTリクエストを処理する

独自に実装するPOSTリクエストのエンドポイントでCSRFトークンを使ってみようと思います。

Controllerクラスを実装してGETとPOSTリクエストを受け付けます。GETリクエストでは画面を単に返します。POSTリクエストでは name リクエストパラメータを受け取りログに出力します。POSTリクエストでもGETリクエストと同じ画面を返却します。

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.logging.Logger;

@Controller
@RequestMapping("/product")
public class ProductController {

    private final Logger logger = Logger.getLogger(ProductController.class.getName());

    @GetMapping
    public String product() {
        return "main";
    }

    @PostMapping("/add")
    public String add(@RequestParam String name) {
        logger.info("add product: " + name);
        return "main";
    }

}

main.html はこちら。Thymeleafを使っているので th タグを使いCSRFトークンを hidden パラメータとして埋め込んでいます。 _csrf という名前でCSRFトークンを保持するインスタンスにアクセスすることができるのですが、これはSpringSecurityが自動的に行なってくれています。開発者が独自に実装するのはどのような名前で、なんの値を送信するかを決めることなので、 _csrf.parameterName で送信する名前を、 _csrf.tokenトークン値を送信するようにします。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Main</title>
  </head>
  <body>
    <form action="/product/add" method="post">
      <span>Name:</span>
      <span><input type="text" name="name"/></span>
      <span><button type="submit">Add</button></span>

      <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    </form>
  </body>
</html>

Spring Security がCSRFトークンを処理する仕組み

Spring Security はCSRFトークンを独自のサーブレットフィルター CsrfFilter を用いて処理を行っています。CSRFフィルターはGETリクエストでCSRFトークンを生成しセッションに保存したり、POSTリクエストでCSRFトークンの検証をおこなったりするフィルターです。CSRFフィルターと関わりを持つ重要なクラスは CsrfTokenRepositoryCsrfToken です。

CsrfFilter - Javadoc

CsrfTokenRepository - Javadoc

CsrfToken - Javadoc

CsrfTokenRepositoryCSRFトークンの生成処理や保存などを担当します。これはインターフェイスなので独自のクラスに切り替えることでUUID以外の採番を行えたり、セッション以外にCSRFトークンを保存したりすることができます。 CsrfTokenCSRFトークン自体を表現するクラスです。

前述のサンプルで _csrf としてビューからCSRFトークンを参照できるようにしてくれているのは CsrfFilter のおかげというわけです。 CsrfFilter_csrf という名前で CsrfToken クラスをサーブレットリクエストの属性に追加します。

CsrfFilter の動きを簡単にまとめるとこんなかんじでしょうか。

GETリクエストのときはこう。

f:id:bau1537:20201123205003p:plain

  1. CsrfFilterCsrfTokenRepository を呼び出す。
  2. CsrfTokenRepositoryCsrfToken を生成してセッションに保存する。
  3. CsrfFilter は後続のフィルターチェーンを呼び出す。

POSTリクエストのときはこう。

f:id:bau1537:20201123205258p:plain

  1. CsrfFilterCsrfTokenRepository から CsrfToken を取り出し、送信されてきた値と比較する。
  2. 同じであれば後続のフィルターチェーンを呼び出す。同じでないのであれば 403 リクエストを返す。

Spring Security を理解する

Spring Security を一度しっかり理解しておこうかと思って。

やってみたこと

Springは色々と便利な機能がある反面、その設定だとか動き方だとかが良くも悪くもブラックボックス化されています。数あるSpring関連のプロジェクトの中でも特に複雑&理解が難しいのがSecurityかな、と思いましてどうやって理解すればいいものかと考えていたところMannningからSpring Security in Actionが発売されたので読んでみました。

Manning Spring Security in Action

というわけで、とりあえず基本的なところだけをおさえてみたので、基本的な認証認可の流れをまとめておきます。

基本的な認証認可の流れ

Spring Securityの基本的な認証認可の流れはこんなかんじ。

  1. 認証を行うサーブレットフィルターがリクエストを受け取り、 AuthenticationManager に処理を委譲する。

  2. AuthenticationManagerAuthenticationProvider を使用し認証処理を行う。

  3. AuthenticationProviderUserDetailsServicePasswordEncoder を使って認証処理を行う。

  4. 認証に成功するとユーザ情報がSecurity Contextに保存される。

  5. 認可を行うサーブレットフィルターがリクエストの許可・拒否の判断を行う。

Authentication
Filter
Authentication...
<interface>
Authentication
Manager
<interface>...
<interface>
UserDetails
Service
<interface>...
<interface>
Password
Encoder
<interface>...
<interface>
Authentication
Provider
<interface>...
Filter
Filter
Filter
Filter
Request
Request
Security Context
Security Context
<interface>
UserDetail
<interface>...
Viewer does not support full SVG 1.1

ほとんどインターフェイスになっていますがこれはSpringの思想で、フレームワーク利用者が動作をカスタマイズできるようにするためですね。図ではインターフェイス間に依存があるように書きましたが、これは正確に言うと実装クラスによります。でも一般的には実装クラスにおいてこのような依存が発生することを想定してSpring Securityが作られているので、図の通りに理解しても問題ないと思います。

それぞれの役割はこんな感じ。

インターフェイス 役割

AuthenticationManager

認証情報をAuthenticationProviderに委譲する。

AuthenticationProvider

認証を行う。

UserDetailsService

UserDetailの管理を行う。(取得や保存など)

PasswordEncoder

パスワードのエンコード、突合を行う。

UserDetails

ユーザを表現する。

AuthenticationManagerは複数のAuthenticationProviderに問い合わせ、認証の種類に対応しているかを確認し、処理を委譲するという役割があります。図で示したようにAuthenticationProviderは複数存在することができます。これは、認証方法に応じて(ユーザ名とパスワードで認証するのか?トークンにより認証するのか?)AuthenticationProviderが存在するためです。AuthenticationManagerはそれらを束ねて管理し適切なAuthenticationProviderを呼び出す、という責任を持っています。

一般的にアプリケーションごとに独自に実装しなければならないのは、ユーザー情報を表すクラスと、それを取得する方法かなと思います。その他の処理についてはSpringSecurityがデフォルトで実装してくれているのでそれを使えば良さそう。PasswordEncoderについてもいくつも便利な実装が提供されているので特別なことをしなければならない場合を除いて提供されている実装を選択するだけになるかなと思いました。

PythonのテンプレートエンジンJinja2を使ってみた

Jinja2はPythonDjangoウェブフレームワークに強く影響を受けて開発された、テンプレートエンジンらしいです。テキストベースのファイルであればなんでも出力することができるのでHTMLに加え、CSVXMLレンダリングすることができますね。やろうと思えばできますが、JSONYAMLももちろんレンダリングできます。

環境

以下の環境で試しに使ってみます。

bash-3.2$ python --version
Python 3.8.6

bash-3.2$ pip list
Package    Version
---------- -------
Jinja2     2.11.2
MarkupSafe 1.1.1
pip        20.2.3
setuptools 50.3.0

サンプル

Jinja2の基本的な使い方は Loader クラスでテンプレートファイルの読み込み方を決めて、その他の設定とともに Environment クラスをインスタンス化します。んで、 Template クラスを生成してもらって必要な引数を render メソッドに渡してレンダリング結果を文字列として受け取る、という流れです。

from jinja2 import Environment, FileSystemLoader

# 1. テンプレートファイルの場所と、レンダリング時のtrim設定を行う。
#    Environmentクラスはjinja2の中心的なクラスで、以下のように
#    設定を元にTemplateインスタンスを生成する役割を担う。
env = Environment(
    loader=FileSystemLoader('src/first_example/templates'),
    trim_blocks=True
)

# 2. テンプレートファイルを取得しレンダリングする。
#    レンダリング結果はファイルに出力する。
template = env.get_template('message1.jinja')
result = template.render(name='BookStore')
with open('src/first_example/output/message1.txt', 'w') as f:
    f.write(result)

このときに使われたテンプレートファイルは以下になります。かなり単純な内容ですが。

src/first_example/output/message1.txt.

Hello, {{ name }} !

テンプレートでは {{ name }} の箇所が render メソッドの引数に応じて書き換わるという動きになります。 render メソッドは名前付き引数を任意の数受け取るのでテンプレートの {{ variable name }} に合わせて呼び出すことになるかと。

テンプレートファイル

jinja2 template designer

テンプレートファイルでは主に以下の3つの要素を使うことができます。StatementsとExpressionsだけ覚えておけば基本もの足りる印象でした。

  • {% …​ %} …​ Statements

  • {{ …​ }} …​ Expressions

  • {# …​ #} …​ Comments

上記の要素を使ってテンプレートを書くとこうなります。これはHTMLファイルのレンダリングですね。ステートメントではfor文を定義し、リストで渡されるであろう値をレンダリングしてます。

<!DOCTYPE html>
<!--suppress HtmlUnknownTarget -->
<html lang="en">
<head>
    <title>My Webpage</title>
</head>
<body>
<ul id="navigation">
    <!-- ステートメント -->
    {% for item in navigation %}
    <!-- 式 -->
    <li><a href="{{ item.href }}">{{ item.caption }}</a></li>
    {% endfor %}
</ul>

<h1>My Webpage</h1>
<!-- 式 -->
{{ a_variable }}

<!-- コメント -->
{# a comment #}
</body>

以下のようにif文もテンプレート内に記述できます。 username が定義されているか、されていないかを判断してレンダリングする内容を切り替えます。

{% if username is defined %}
    username is : {{ username }}
{% else %}
    username not defined
{% endif %}

パイプ処理を行うように、フィルタリングも行うことができます。フィルタリングは以下のように | を使って関数をつなげていきます。処理の順番は一番左に書かれた変数を左から順に関数に適用して、最終的な結果の文字列をレンダリングします。以下はHTMLの文字列をエスケープ処理し、各単語の戦闘を大文字に変換するフィルタリングになります。

{{ name|striptags|title }}

フィルタリングで使用できるビルトインの関数はここに載ってます。

jinja2 builtin filters

他にもいろんな機能があるみたいなんですが、今のところ触ってみたのはここまでですね。

Loader

Loader はテンプレートファイルをロードする役割を持つもので Environment の引数の一つです。いくつか種類があり、以下のものを試しに使ってみました。サンプルコードは unittest モジュールを使って単体テスト形式で書いてます。

  • FileSystemLoader

  • PackageLoader

  • DictLoader

  • FunctionLoader

  • PrefixLoader

# Loaderはテンプレートを読み込む役割を持ちます。
# jinja2.BaseLoaderを継承したクラスがLoaderとして扱われ、標準で用意されているものもあれば独自に作ることもできます。

import unittest

import jinja2


class ExamplesJinja2Loaders(unittest.TestCase):

    def test_FileSystemLoader(self):
        # ファイルシステムからテンプレートを読み込みます。
        loader = jinja2.FileSystemLoader('mypackage/views')
        template = jinja2.Environment(loader=loader).get_template(name='template1.jinja')
        self.assertEqual(template.render(message='Hello'), 'Hello')

    def test_PackageLoader(self):
        # パッケージからテンプレートを読み込みます。
        loader = jinja2.PackageLoader(package_name='mypackage', package_path='views')
        template = jinja2.Environment(loader=loader).get_template(name='template1.jinja')
        self.assertEqual(template.render(message='Hello'), 'Hello')

    def test_DictLoader(self):
        # dictによりテンプレートファイルのソースを読み込みます。
        # testなどで使用する想定らしいです。
        loader = jinja2.DictLoader({'message_template': '{{ message }}'})
        template = jinja2.Environment(loader=loader).get_template('message_template')
        self.assertEqual(template.render(message='Hello'), 'Hello')

    def test_FunctionLoader(self):
        # 関数によりテンプレートを読み込みます。
        # 関数にはテンプレート名が渡されるので、その名前をもとにテンプレート文字列を返します。
        # その他にも(source, filename, uptodatefunc)のタプル値を返すこともできます。
        def load_template(name):
            if name == 'template1.jinja':
                with open('mypackage/views/template1.jinja', 'r') as f:
                    return f.read()
            else:
                return None

        loader = jinja2.FunctionLoader(load_template)
        template = jinja2.Environment(loader=loader).get_template(name='template1.jinja')
        self.assertEqual(template.render(message='Hello'), 'Hello')

    def test_PrefixLoader(self):
        # テンプレート名を指定する際のプレフィックスで、Loaderを変更できます。
        # 例えば複数のFileSystemLoaderを組み合わせれば異なるディレクトリからテンプレートを読み込めます。
        loader = jinja2.PrefixLoader({
            'app1': jinja2.FileSystemLoader('mypackage/views'),
            'app2': jinja2.FileSystemLoader('templates')
        })
        template = jinja2.Environment(loader=loader).get_template(name='app2/template1.jinja')
        self.assertEqual(template.render(message='Hello'), 'Hello')


if __name__ == '__main__':
    unittest.main()

ちょっとしたツールを作るのであればFileLoaderで十分かなと。テンプレートファイルが複数のディレクトリに分散するのであればPrefixLoaderを使うと便利そうですね。

おわりに

試しに使ってみましたが、なにかテキスト形式のものを生成するには使いやすそうだなと思いました。Ansibleでも使われているみたいですし、Python界隈では割とポピュラーなモジュールなのかな?

Pythonのfilterとsort

Pythonでちょっとしたツールを作る事が増えたのですが、やはりコレクション操作は重要だなと実感しました。コレクションから特定の値を抜き出したりとか、特定の値になるまでループしたりとか、やはり何するにもこの辺りはつきまとってくるんですね。

filter

と言うわけで最初はPythonでフィルター操作どうやるんだっけと言う話です。

devdocs でさっと検索してみると filter の説明が。

https://devdocs.io/python~3.8/library/functions#filter

バッテリー同梱なんて言われている言語ですから当然基本ライブラリに含まれていますね。

第一引数にフィルター関数を、第二引数には対象となるリストをセットする感じ。

>>> def filter_function(parameter):
...     parameter == 'a'
...
>>> filter(filter_function, ['a', 'b', 'c'])

第一引数の関数はラムダでも行けます。

>>> list(filter(lambda p: p == 'a', ['a', 'b', 'c']))

戻り値が iterator なので list 関数でリスト化できます。つなげて書くとこんな感じ。

>>> list(filter(lambda p: p in filter_strings, ['a', 'b', 'c']))

リスト内包表記でも同じことを実現できます。

>>> [item for item in ['a', 'b', 'c'] if item == 'a']
['a']
>>> [item for item in range(0, 30) if item <= 15]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]

sort

ソートについては以下のドキュメントに詳しく載ってました。

https://docs.python.org/ja/3/howto/sorting.html

Pythonでのソートは sorted 関数でできます。

>>> sorted([1, 3, 2, 0])
[0, 1, 2, 3]

引数にソートに使用する値を取り出す関数を与えれば複雑なリストでもソートできます。

>>> student_tuples = [
...     ('john', 'A', 15),
...     ('jane', 'B', 12),
...     ('dave', 'B', 10)
... ]
>>> sorted(student_tuples, key=lambda student: student[2])
[('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]

operator モジュールにはこれらを簡略化する関数が定義されているので一般的にはこれを使うことになるのかなと。 itemgetterイテレーション可能な値から特定のインデックス値を指定する関数です。 attrgetter は名前付けされた属性(Classのインスタンスなど)に対して属性を指定する関数です。

>>> from operator import itemgetter, attrgetter
>>> sorted(student_tuples, key=itemgetter(2))
[('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]

>>> class Student:
...     def __init__(self, name, grade, age):
...         self.name = name
...         self.grade = grade
...         self.age = age
...     def __repr__(self):
...         return repr((self.name, self.grade, self.age))
>>> student_objects = [
...     Student('john', 'A', 15),
...     Student('jane', 'B', 12),
...     Student('dave', 'B', 10),
... ]
>>> sorted(student_objects, key=attrgetter('age'))
[('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]

itemgetterattrgetter は複数段階でのソートを可能にしてくれます。

>>> sorted(student_tuples, key=itemgetter(1, 2))
[('john', 'A', 15), ('dave', 'B', 10), ('jane', 'B', 12)]
>>> sorted(student_objects, key=attrgetter('age', 'grade'))
[('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]

reverse パラメータを使うと昇順と降順を指定できます。 True の場合が降順になるようです。

>>> student_tuples = [
...     ('john', 'A', 15),
...     ('jane', 'B', 12),
...     ('dave', 'B', 10)
... ]
>>> sorted(student_tuples, key=itemgetter(2), reverse=True)
[('john', 'A', 15), ('jane', 'B', 12), ('dave', 'B', 10)]

Javaを使っていると compareTo メソッドみたいな役割がないのか気になりますが、Pythonでは lt メソッドがそれに該当します。

from operator import itemgetter, attrgetter


class Student:

    def __init__(self, name, grade, age):
        self.name = name
        self.grade = grade
        self.age = age

    def __repr__(self):
        return repr((self.name, self.grade, self.age))

    def __lt__(self, other):
        return self.age < other.age


student_objects = [
    Student('john', 'A', 15),
    Student('jane', 'B', 12),
    Student('dave', 'B', 10),
]

sorted(student_objects)
# [('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]

Pythonはメソッドをクラス定義後に追加できるみたいなので、外部のライブラリに定義されている場合は以下のようにすることでソート可能なクラスにできますね。

Student.__lt__ = lambda self, other: self.age < other.age

Pythonでforkとexecvを試す

Linux のしくみ」と言う本を読んでいて、Pythonで試してみようかなと思い。

https://gihyo.jp/book/2018/978-4-7741-9607-7

fork とか大学の授業でやったな、と懐かしい感じがします。大学生の時にC言語で作ってfork処理が無限ループするとかやらかしてました。ここではPythonforkexecv を試します。

Pythonのバージョンはこちら。

bash-3.2$ python3 --version
Python 3.8.5

スクリプト

作ってみたPythonスクリプトはこちら。

import os
import sys

pid = os.fork()

if pid == 0:
    print('Child process: {}'.format(os.getpid()))
else:
    print('Parent process: {}, Child process: {}'.format(os.getpid(), pid))
    sys.stdout.flush()
    os.execv('/bin/echo', ['/bin/echo', 'Hello'])

fork と execv とは

forkexecv はどちらもプロセスに関する処理をカーネルに対して依頼する関数になります。 fork は現在のプロセスを複製し親プロセスと子プロセスに分けます。 execv は別のプロセスを起動します。

上記のスクリプトではこの二つの処理を使ってプロセスを複製して、親プロセスが別のプロセスを呼び出すようにしてみました。子プロセスか、親プロセスかは戻り値を見て判断します。0なら子プロセス。0以外の値であれば親プロセスでその値は子プロセスのPIDになってます。

親プロセスは execv で別のプロセスを起動します。別のプロセスは元となるプロセスのメモリ上に展開されるので下のプロセスはそこで終了します。

上記のサンプルでは echo コマンドを呼び出しています。 execv の第一引数は実行するプロセスのファイル、第二引数は実行するプロセスに与える引数になります。Pythonの場合、プロセスへの引数はリストを使って渡しますが、その際に一つ目のインデックスにはプロセスを表す名前を渡す慣習らしいです。ここがなぜそんな慣習になっているのかは調べてみてもよくわかりませんでしたが動作に影響を与えていないようです。

NFSv4でFirewall越しに接続する

この前NFSUbuntuMacをつなげたんですけど、Firwall越しに接続できていなかったので改善したいと思います。

やることはかんたん。クライアント側で接続するときにバージョンを4指定で接続するだけです。

NFSv4はポートが固定されるのでfirewalldでの設定が楽です。既存のサービスの中にnfsがあるのでそれを割り当てておけばFirewall越しに通信できるかと。

bookstore@bookstoreUbuntu:~$ sudo cat /usr/lib/firewalld/services/nfs.xml
<?xml version="1.0" encoding="utf-8"?>
<service>
  <short>NFS4</short>
  <description>The NFS4 protocol is used to share files via TCP networking. You will need to have the NFS tools installed and properly configure your NFS server for this option to be useful.</description>
  <port protocol="tcp" port="2049"/>
</service>

zoneにnfsを割り当てます。個人的な事情で2つのIPからの通信を許可しています。

<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Home with http</short>
  <description>custom home zone</description>
  <interface name="enp5s0"/>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="samba-client"/>
  <service name="dhcpv6-client"/>
  <rule family="ipv4">
    <source address="192.168.50.126"/>
    <service name="http"/>
    <accept/>
  </rule>
  <rule family="ipv4">
    <source address="192.168.50.126"/>
    <service name="nfs"/>
    <accept/>
  </rule>
  <rule family="ipv4">
    <source address="192.168.50.225"/>
    <service name="nfs"/>
    <accept/>
  </rule>
</zone>

あとはクライアントからv4で接続するだけですね。

sudo mount_nfs -o vers=4 192.168.50.35:/ nfs/

UbuntuにNFSを設定してみる

UbuntuNFSを設定してみようかなと。

参考にしたサイトはこちら。

まずはサーバー側の設定から。 nfs-kernel-server パッケージが必要みたいなのでインストールします。

bookstore@bookstoreUbuntu:~$ dpkg -l | grep nfs-kernel-server
bookstore@bookstoreUbuntu:~$ apt search nfs-kernel-server
ソート中... 完了
全文検索... 完了
nfs-kernel-server/focal-updates,focal-security 1:1.3.4-2.5ubuntu3.3 amd64
  NFS カーネルサーバ用サポート
bookstore@bookstoreUbuntu:~$ sudo apt install nfs-kernel-server

NFSに割り当てるディレクトリを作成します。ディレクトリのパーミッションは777にします。

bookstore@bookstoreUbuntu:~$ mkdir -p /export/share
bookstore@bookstoreUbuntu:~$ chmod 777 /export/share
bookstore@bookstoreUbuntu:~$ ls -l /export
total 4
drwxrwxrwx 2 bookstore bookstore 4096  9月 23 11:32 share

/etc/exports ファイルに下記のエントリを追加します。

bookstore@bookstoreUbuntu:~$ cat /etc/exports
# /etc/exports: the access control list for filesystems which may be exported
#       to NFS clients.  See exports(5).
#
# Example for NFSv2 and NFSv3:
# /srv/homes       hostname1(rw,sync,no_subtree_check) hostname2(ro,sync,no_subtree_check)
#
# Example for NFSv4:
# /srv/nfs4        gss/krb5i(rw,sync,fsid=0,crossmnt,no_subtree_check)
# /srv/nfs4/homes  gss/krb5i(rw,sync,no_subtree_check)
#
/export            192.168.50.0/24(rw,fsid=0,insecure,no_subtree_check,async)
/export/share      192.168.50.0/24(rw,nohide,insecure,no_subtree_check,async)

サービスを再起動します。

bookstore@bookstoreUbuntu:~$ systemctl restart nfs-kernel-server
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentication is required to restart 'nfs-server.service'.
Authenticating as: bookstore,,, (bookstore)
Password:
==== AUTHENTICATION COMPLETE ===
bookstore@bookstoreUbuntu:~$ systemctl status nfs-kernel-server
● nfs-server.service - NFS server and services
     Loaded: loaded (/lib/systemd/system/nfs-server.service; enabled; vendor preset: enabled)
    Drop-In: /run/systemd/generator/nfs-server.service.d
             └─order-with-mounts.conf
     Active: active (exited) since Wed 2020-09-23 11:37:42 JST; 17s ago
    Process: 2176 ExecStartPre=/usr/sbin/exportfs -r (code=exited, status=0/SUCCESS)
    Process: 2177 ExecStart=/usr/sbin/rpc.nfsd $RPCNFSDARGS (code=exited, status=0/SUCCESS)
   Main PID: 2177 (code=exited, status=0/SUCCESS)

 9月 23 11:37:41 bookstoreUbuntu systemd[1]: Starting NFS server and services...
 9月 23 11:37:42 bookstoreUbuntu systemd[1]: Finished NFS server and services.

次はクライアントを設定していきます。クライアントはMacです。

 bash-3.2$ sw_vers -productVersion
 10.15.6

nfs ディレクトリをNFSサーバーの /export/share ディレクトリにマウントします。このマウントはNFSサーバーのファイアーウォールが機能していたら接続が拒否されました。必要なポートが空いていないみたいなのでそれは後で調べるとして一旦ファイアーウォールはストップさせた状態でやってます。

bash-3.2$ sudo mount_nfs 192.168.50.35:/export/share ~/nfs

これでNFSの設定ができたのでファイルを共有してみます。クライアント側で一時ファイルを作成。

bash-3.2$ touch ~/nfs/hello-from-mac

NFSサーバーでファイルを確認。うまくいってますね。

bookstore@bookstoreUbuntu:~$ ls /share/
hello-from-mac