Status Code 303 - See Other

サーバサイド、iOS・アンドロイドアプリ、インフラレベルの話まで幅広くやってます。情報の誤りや指摘・意見などは自由にどうぞ。

Zabbix API との通信を暗号化する

概要

前回記事
Javaアプリから Zabbix API を使う - Status Code 303 - See Other

java アプリケ−ションから通信はできたが、HTTP 通信のため平文で通信している。
外部にこれらを公開することを考慮して、今回は通信を暗号化する。

また、Zabbix サーバとの SSL 通信に用いる証明書はいわゆる「オレオレ証明書」を用いる。
これらは信頼できない証明にあたるため、Java はデフォルトで通信を許可しないが、これらを無理矢理設定して通信する。

Raspberrypi 設定

Raspberrypi の SSL 対応

作業手順は、下記記事を参考に行った。
Qaplaの覚書・メモ・備忘録・独言 Raspberry Piを公開サーバ化(HTTPS対応)

Linux 系 OS の SSL 導入記事は、Apache2 の conf ファイル名が違ったり、
apt-get から取得したアプリケーションのフォルダ階層が違ったりしていたため、
手順だけ知りたい人は、Raspberrypi での導入記事を選択した方が良さそう。

なお、この証明書作成の過程で何をやっているかについて興味があるなら、この記事を参考に。
オレオレ証明書をopensslで作る(詳細版) - ろば電子が詰まっている

Java アプリケーション

前回と同じく Jersey を用いて実装を行う。まず、何もしないでアクセス先を http → https に変更すると、
下記のようなエラーログが出る。

Exception in thread "main" javax.ws.rs.ProcessingException: Already connected
	at org.glassfish.jersey.client.ClientRuntime.invoke(ClientRuntime.java:264)
	at org.glassfish.jersey.client.JerseyInvocation$1.call(JerseyInvocation.java:684)
	at org.glassfish.jersey.client.JerseyInvocation$1.call(JerseyInvocation.java:681)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:315)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:297)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:228)
	at org.glassfish.jersey.process.internal.RequestScope.runInScope(RequestScope.java:444)
	at org.glassfish.jersey.client.JerseyInvocation.invoke(JerseyInvocation.java:681)
	at org.glassfish.jersey.client.JerseyInvocation$Builder.method(JerseyInvocation.java:437)
	at org.glassfish.jersey.client.JerseyInvocation$Builder.post(JerseyInvocation.java:343)
	at api.zabbix.ZabbixAPIAccess.getResponse(ZabbixAPIAccess.java:76)
	at api.zabbix.ZabbixAPIAccess.getToken(ZabbixAPIAccess.java:82)
	at api.zabbix.ZabbixAPIAccess.request(ZabbixAPIAccess.java:41)
	at api.zabbix.ZabbixAPIAccess.request(ZabbixAPIAccess.java:51)
	at api.zabbix.Main.main(Main.java:24)
Caused by: java.lang.IllegalStateException: Already connected
	at sun.net.www.protocol.http.HttpURLConnection.setRequestProperty(HttpURLConnection.java:3014)
	at sun.net.www.protocol.https.HttpsURLConnectionImpl.setRequestProperty(HttpsURLConnectionImpl.java:316)
	at org.glassfish.jersey.client.internal.HttpUrlConnector.setOutboundHeaders(HttpUrlConnector.java:421)
	at org.glassfish.jersey.client.internal.HttpUrlConnector.access$100(HttpUrlConnector.java:96)
	at org.glassfish.jersey.client.internal.HttpUrlConnector$4.getOutputStream(HttpUrlConnector.java:384)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.commitStream(CommittingOutputStream.java:200)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.commitStream(CommittingOutputStream.java:194)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.commit(CommittingOutputStream.java:262)
	at org.glassfish.jersey.message.internal.OutboundMessageContext.commitStream(OutboundMessageContext.java:816)
	at org.glassfish.jersey.client.ClientRequest.writeEntity(ClientRequest.java:545)
	at org.glassfish.jersey.client.internal.HttpUrlConnector._apply(HttpUrlConnector.java:388)
	at org.glassfish.jersey.client.internal.HttpUrlConnector.apply(HttpUrlConnector.java:285)
	at org.glassfish.jersey.client.ClientRuntime.invoke(ClientRuntime.java:255)
	... 14 more

これは、どうやら https に変更すると、自動的に SSL で通信を行ってくれるのだが、
信頼できない証明書を利用した SSL 通信はデフォルトで、例外を発生させるみたいだ。

事実、信頼できる証明書を使用するサイトでは、前回実装でも SSL でアクセスできる(GoogleYahoo! Japan! で確認)。
では、信頼できない証明書を使っている場合はどうすれば良いか。

答えは、デフォルト設定を無理矢理変更して、アクセスする。
一般的に SSL 導入利点として挙げられるのは、

  1. 通信の暗号化(機密性)
  2. 通信先の相手が正しいことを保証(真正性)
  3. 改竄を検知できることを保証 (完全性)

信頼できない証明書を受け入れるということは、少なくとも「通信先の相手が正しいことを保証」はできなくなる。
もし、 名前解決の部分を悪意をもった誰かが操作して別のサイトに導いた場合、攻撃者はプログラムの通信に関与できてしまう。

このようなセキュリティ上のリスクがあることを認識すること。・・とはいえ、SSL 証明書は導入コストがかかるので、
開発中に「外部に公開しない・重要な処理や情報を扱わない」なら、例外を設けて通信をすることも必要になる。

前置きが長くなったが、実装内容。前回の ZabbixAPIAccess クラスに対して、次の static メソッドを追加。

	private static Client getSSLClient() throws NoSuchAlgorithmException, KeyManagementException {
		SSLContext sc = SSLContext.getInstance("TLSv1");
		System.setProperty("https.protocols", "TLSv1");
		// 全ての証明書を信用するトラストマネージャの作成。詳しくは、JSSE を検索すること。
		TrustManager[] trustAllCerts = { new X509TrustManager() {

			@Override
			public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
			}

			@Override
			public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
			}

			@Override
			public X509Certificate[] getAcceptedIssuers() {
				return null;
			}
		} };
		sc.init(null, trustAllCerts, new java.security.SecureRandom());

		// 全ホストを妥当だとする。詳しくは、JSSE を検索。
		HostnameVerifier allHostsValid = new HostnameVerifier() {

			@Override
			public boolean verify(String hostname, SSLSession session) {
				return true;
			}
		};
		// Client を Builder から作成するときに、上記設定を適用する。
		return ClientBuilder.newBuilder().hostnameVerifier(allHostsValid).sslContext(sc).build();
	}

そして、ZabbixAPIAccess クラスのgetResponse メソッドを次のように変更する。

	private Response getResponse(Map<String, Object> params) throws KeyManagementException, NoSuchAlgorithmException {
		return getSSLClient().target(property.getString("zabbix.api.url")).request()
				.post(Entity.entity(params, MediaType.APPLICATION_JSON));
	}

これに伴い、様々な所でキャッチ例外(KeyManagementException, NoSuchAlgorithmException)をどうにかしろと言われるので、throws 宣言を追加。
以上で完了。

ただし、さきほども記述した通り。セキュリティ上あまり良くはないので、
本番時には、ルート証明書を取得するか、例外設定処理を記述した方が良い。
そのときは、getSSLClient メソッドのクライアント取得手順を変更する。

その設定内容としては、以下が参考になる。
JavaのSSLSocketでSSLクライアントとSSLサーバーを実装する:CodeZine(コードジン)
Java SE 6 用 JSSE リファレンスガイド

そういえば、暗号化といえば。
2016年2月から正式リリースされた、Zabbix 3.0 の拡張機能で、 Zabbix-server と Zabbix-agent 間の暗号化をサポートするようになったとか。
Zabbix-Agentとの通信を暗号化してみる. (3.0の機能) - フロッピーディスクの残骸

これも時間があるときに試してみよう。

例外仕様設計

概要

プログラムが正しく実行できない状態に陥ったときに、それを処理側に通知するための機構として例外がある。
これによって、もしユーザからの入力が想定外のものだったりしても、安全にプログラムを終了させることができる。

しかし、仕様を考慮もせず適当に例外を発生させたりすれば、そのクラスはユーザによって使いづらいだろうし、
そうかといって、例外発生に対して適切な処理をしなければ、バグが発生する原因をユーザに調査させることになる。

このような可能性から、クラス設計において例外は、本質的な内容ではないことが多いが、
仕様の一部であるべきと考えている。今回は、この例外仕様について個人的な意見を記述する。

事前チェック VS 例外

コマンドライン引数から数値変換・計算・表示するプログラムを例として説明する。
前提として、これは「2つの整数を除算した結果(整数)を表示するプログラム」という仕様ということにする。

public class Main {
	public static void main(String[] args) {
		System.out.println(Integer.parseInt(args[0])/Integer.parseInt(args[1]));
	}
}

このプログラムは、単純な処理にも関わらず、以下の3種類の実行時例外が起こる可能性がある。

  • ArrayIndexOutOfBoundsException
  • NumberFormatException
  • ArithmeticException

それぞれの例外に対して、どのように対処すべきかの個人的な見解を述べる。

ArrayIndexOutOfBoundsException

これが発生するのは、コマンドライン引数が2個未満のときになる。
仕様が「2つの整数を除算した結果」なのだから、これは致命的な例外なので処理続行は不可能である。
つまり、例外を発生させるべきだと考える。

しかし、単純に異常終了させるべきではない。なぜなら、この問題の発生原因を考えれば想像がつく。
この例外が発生する原因は、入力項目が足りないことにある。
しかし、「ArrayIndexOutOfBoundsException」は配列外にアクセスしようとした例外である。

【ユーザ入力漏れ】→【配列外アクセス異常】というのは、プログラムの内部構造を意識して初めて理解できる。
プログラムを利用するユーザはこの例外を見て、ユーザが何の操作を間違っていたか分かるだろうか。
これを考慮すれば、違う例外を発生させるべきだろう。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要"); //コマンドライン引数が2個未満のときは引数異常で終了
		System.out.println(Integer.parseInt(args[0])/Integer.parseInt(args[1]));
	}
}

NumberFormatException

これが発生するのは、入力が整数値じゃなかったとき。この例外に対しては何も対処しない。

まず、数値に変換できない時点で除算ができない。つまり、処理続行が不可能なため例外を発生させる。
また、そのときに「値が整数形式ではない」という例外が発生し、これはプログラム仕様からマッチした内容なのでユーザも理解しやすい。

もし異常終了が問題になる場合は、try-catch で例外をキャッチして異常系処理を記述する。
正常なロジックとして記述しないのは、この動作は正常動作の範囲内ではないという判断からだ。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要");
		try {
			System.out.println(Integer.parseInt(args[0]) / Integer.parseInt(args[1]));
		} catch (NumberFormatException e) {
			System.out.println(e.getMessage());
		}
	}
}

ArithmeticException

これが発生するのは2番目の引数が 0 によって、0 除算が発生するとき。
0 は本仕様の整数値に含まれる入力なので、例外による異常終了はさせないで、正常系動作として実装すべき内容だ。
つまり、分岐によって事前チェックを行い対応する。

public class Main {
	public static void main(String[] args) {
		if (args.length < 2) throw new IllegalArgumentException("数値は2つ必要です");
		int arg0 = Integer.parseInt(args[0]);
		int arg1 = Integer.parseInt(args[1]);
		if (arg1 != 0) {
			System.out.println(arg0/arg1);
		}else {
			System.out.println("∞");
		}
	}
}

今回は、無限という結果(-∞は面倒臭いので考慮してない)にしているが、「計算できません」でも良いだろう。
大事なことは正常系の処理として記述することである。

異常終了 VS 例外キャッチ

例外をキャッチするか、そのまま異常終了させるかどうかの判断基準を記述する。

仕様上発生しない例外

さきほどの例でもあったが、仕様に対して明らかにおかしい入力値に対する処理は基本しなくて良い。
そのような例外にいちいち対処すると、本質ではないロジックのためにコードが読み辛くなり、保守性もおそらく落ちる。
また、そのような対処による品質向上効果は大抵薄い。

エラーアトミック性

エラーアトミック性とは、異常(例外)が起こってもシステム内部の状態を正常動作時に戻せる性質である。
この性質を持たせることができるならば、例外をキャッチして行うべきだろう。

しかし、このような仕組みはコストがかかりやすい。例えば、ある値を変更したものを元に戻す場合は、
そのための領域を確保しなければならないし、例外のタイミングで元に戻す処理内容が変わったりする。

また、例外の発生要因とは、多岐に渡るため全ての要因を塞ぐことは基本的にできないため、
影響度合や発生頻度が低いと判断できる場合は、例外によって処理を中断させることを検討した方が良い。

異常検知

発生する例外に全てロジックで対処する必要はないものの、それによってシステム内で整合性が取れない状態になってしまう可能性はある。
だから、どのような例外であっても、発生時には一度はキャッチしてその内容を調べるべき。

ただし、これを複数の箇所で記述すると複雑になりやすいので、大きな塊のエントリーポイントで行った方が良い。
この箇所では、実行ログ取得、運用チームへの通知など異常時に行うべき処理を記述する。
なお、例外によって中断させる意図を含むものもあるので、一般的にはその後異常終了させた方が良い。

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import org.slf4j.LoggerFactory;

public class Main {

	static void main(String[] args) {
		try(BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("~/data.txt")))) {
			bos.write(args[0].getBytes());
		} catch (IOException e) {
			LoggerFactory.getLogger(Main.class).debug(e.getMessage()); //エラーログ取得
			throw e; //異常なので結局スローする
		}
	}
}

例外をコントロールする

例外とは、そもそも異常状態になったことをプログラム実行側やユーザに伝える目的で行う。
ならば適切にその原因は伝えるべきだろう。

public class Main {

	static void main(String[] args) {
		try {
			System.out.println(args[0]);
		}catch(ArrayIndexOutOfBoundsException e) {
			throw new IllegalArgumentException("入力値不正"); //例外翻訳
		}
	}
}

なお、例外を無視するために使ってはならない。

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

public class Main {

	static void main(String[] args) {
		try(BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("~/data.txt")))) {
			bos.write(args[0].getBytes());
		} catch (IOException e) { // ダメ!ゼッタイ!
		}
	}
}

上記のコードは、 IOException が起こっても正常終了するため、ユーザは正しい操作が行われていないにも関わらず気づかない。
また、このような内部状態の不整合は、後々にバグを生み出す原因となり、いつ発生するか予測がつかない。

参考文献

EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)

Javaアプリから Zabbix API を使う

概要

前回 curl で疎通確認を行ったが、今回はこれらを Java で実装する。
前回記事:Zabbix API を使ってみる - Status Code 303 - See Other

Zabbix API は、JSON 形式で API と通信するため、JSON 形式で通信できるようセットアップする必要がある。
また、Zabbix API は、API の種類が多く、そのリクエストパラメータとそのレスポンスデータの種類も
オブジェクトリスト・オブジェクト・文字リスト・基本データ型など多種多様なので、これらをなるべく扱えるよう設計する。

また、最後には拡張方法の方針について触れる。

ライブラリ

今回は、Jersey (Jersey)を用いる。
JAX-RS (JAX-RS - Wikipedia)という、RESTFUL なアーキテクチャを実装するための API として有名。
しかし、jersey には、クライアントの機能を提供するライブラリもあり、それらは結構使い易いらしい。
参考:HTTPクライアントとして使うjersey-client – Akira Koyasu's WebLog

導入

まずは、ライブラリをダウンロードして、それらに対してビルドパスを設定する。
まずは、HTTP クライアント用ライブラリの導入。

Jersey-client 本体
  • jersey-client.jar
Jersey-client に必要なライブラリ
  • jersey-common.jar
  • javax.ws.rs-api-2.0.1.jar
  • javax.annotation-api-1.2.jar
  • javax.inject-2.4.0-b31.jar
  • hk2-api-2.4.0-b31.jar
  • hk2-locator-2.4.0-b31.jar
  • hk2-utils-2.4.0-b31.jar
  • jersey-guava-2.22.1.jar
  • jersey-entity-filtering-2.22.2.jar
  • jersey-media-json-jackson-2.22.2.jar
  • jackson-core-2.7.1.jar
  • jackson-databind-2.5.4.jar
  • jackson-jaxrs-base-2.5.4.jar
  • jackson-jaxrs-json-provider-2.5.4.jar
JSON を扱うためのライブラリ

上記ライブラリだけでも HTTP 通信は可能だが、JSON には対応できないらしく、専用ライブラリを導入する必要がある。
java - MessageBodyWriter not found for media type=application/json - Stack Overflow

  • jackson-annotations-2.5.4.jar
  • jackson-module-jaxb-annotations-2.5.4.jar
Lombok

ボイラープレートコード削減ライブラリ。
Lombok ライブラリ - Status Code 303 - See Other

実装内容

今回作成したのは、大きく分けて3つ

  • Zabbix API クラス (API 通信コントロール)
  • パラメータ構築クラス (リクエストデータ格納)
  • Zabbix プロパティ (設定ファイル)

この他、API に応じて以下を作成する。

  • API 結果型 (値格納)

Zabbix API クラス

今回は、実装量がある程度多いため、コメントは結構適当。

package api.zabbix;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;

import lombok.Data;
import resources.properties.Property;

public class ZabbixAPIAccess {

	// Zabbix の設定ファイル
	static Property property = new Property("zabbix");

	// トークン格納先
	private String token;

	// トークンの管理を担当、結果はレスポンス形式。
	public Response request(ZabbixParam params) throws JsonProcessingException, IOException {
		// トークン取得条件。本来はタイムアウトとか考慮だろうけど、かなり期間長いみたいなので適当
		if (token == null) {
			token = getToken();
		}
		Map<String, Object> p = getWrappingRequest(params);
		p.put("auth", token);
		return getResponse(p);
	}

	// レスポンスデータをオブジェクト形式にマッピング。第1引数にリクエストデータ、第2引数にマッピングクラス記述
	public <T> T request(ZabbixParam params, Class<T> klass) throws JsonProcessingException, IOException {
		ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
		JavaType type = mapper.getTypeFactory().constructParametrizedType(ZabbixResponse.class, ZabbixResponse.class, klass);
		ZabbixResponse<T> value = mapper.readValue((InputStream) request(params).getEntity(), type);
		return value.result;
	}

	// レスポンスデータをリスト形式にマッピング。第1引数にリクエストデータ、第2引数に各要素に適用するマッピングクラス記述
	public <E> List<E> requestWithList(ZabbixParam params, Class<E> componentKlass)
			throws JsonProcessingException, IOException {
		ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
		TypeFactory factory = mapper.getTypeFactory();
		ZabbixResponse<List<E>> value = mapper.readValue((InputStream) request(params).getEntity(), factory
				.constructParametrizedType(ZabbixResponse.class, ZabbixResponse.class, factory.constructCollectionType(ArrayList.class, componentKlass)));
		return value.result;
	}

	// パラメータをAPI 仕様の形式にラッピング
	private Map<String, Object> getWrappingRequest(ZabbixParam params) {
		Map<String, Object> result = new HashMap<>();
		result.put("jsonrpc", "2.0");
		result.put("method", params.getMethod());
		result.put("id", 1);
		result.put("params", params.getParameters());
		return result;
	}

	// 通信
	private Response getResponse(Map<String, Object> params) {
		return ClientBuilder.newClient().target(property.getString("zabbix.api.url")).request()
				.post(Entity.entity(params, MediaType.APPLICATION_JSON));
	}

	// トークン取得
	private String getToken() throws JsonProcessingException, IOException {
		ZabbixParam loginParams = new MapParam(new ZabbixParam("user.login"))
				.put("user", property.getString("zabbix.user")).put("password", property.getString("zabbix.password"));
		return new ObjectMapper().readTree((InputStream) getResponse(getWrappingRequest(loginParams)).getEntity())
				.get("result").asText();
	}

	// 一時的なレスポンスデータ格納先。使わないため private
	@Data
	private static class ZabbixResponse<T> {
		private String jsonrpc;
		private T result;
		private int id;
	}
}

パラメータ構築クラス

メソッドとパラメータを格納。
パラメータがリストやマップの場合は、下記 Decorator クラスを使った方が良い。

package api.zabbix;

import lombok.Data;

@Data
public class ZabbixParam {

	// (Zabbix APIの) メソッド名
	private final String method;
	// リクエストパラメータ
	private Object parameters;

}

ZabbixParameter がMapだったときに活躍しそうな、Decorator クラス。

package api.zabbix;

import java.util.LinkedHashMap;
import java.util.Map;

public class MapParam extends ZabbixParam {
	
	public MapParam(ZabbixParam param){
		super(param.getMethod());
		super.setParameters(new LinkedHashMap<String, Object>());
	}
	
	@SuppressWarnings("unchecked")
	public MapParam put(String key, Object value) {
		((Map<String, Object>)super.getParameters()).put(key, value);
		return this;
	}
}

ZabbixParameter が List だったときに活躍しそうな、Decorator クラス。

package api.zabbix;

import java.util.ArrayList;
import java.util.List;

public class ListParam<T> extends ZabbixParam {

	public ListParam(ZabbixParam param){
		super(param.getMethod());
		super.setParameters(new ArrayList<T>());
	}
	
	@SuppressWarnings("unchecked")
	public ListParam<T> add(T elements) {
		((List<T>)super.getParameters()).add(elements);
		return this;
	}
}

利用方法

今回は、例として次の流れでロジックを構成する。

  • ホストグループ作成
  • 作成したホストグループ情報取得
  • 作成したホストグループ削除

対応する Zabbix API リファレンスはこちら。(※対応 Zabbix バージョン 2.4)

なお、API 仕様はバージョンによって、変わることがあるので注意すること。

Zabbix 設定ファイル (zabbix.properties)

API 通信できるユーザ名・そのパスワード、そして API 問い合わせ先の設定。

zabbix.user = xxxx
zabbix.password = yyyy

zabbix.api.url = http://xxx.xxx.com/zabbix/api_jsonrpc.php
値格納クラス

API リファレンスから、どんなデータが返ってくるか分かるので、それら用のマッピングクラスを作成。

  • ホストグループクラス (ホストグループ取得に使う)
package api.zabbix.vo;

import com.fasterxml.jackson.annotation.JsonProperty;

public class HostGroup {

	// JSON では groupid 要素をココにマッピング
	@JsonProperty("groupid")
	public String id;
	
	// ホストグループ名
	public String name;

	public String internal;
}
  • ホストグループ ID リスト格納クラス。(ホストグループ作成時・削除時使用)
package api.zabbix.vo;

import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;

public class HostGroupIds {

	@JsonProperty("groupids")
	public List<String> groupIds;
}
ロジッククラス

上記クラスを利用して、以下のようにロジックに記述する。

package api.zabbix;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.core.JsonProcessingException;

import api.zabbix.vo.HostGroup;
import api.zabbix.vo.HostGroupIds;

public class Main {

	public static void main(String[] args) throws JsonProcessingException, IOException {

		ZabbixAPIAccess api = new ZabbixAPIAccess();

		// ホストグループ作成
		ZabbixParam createHostGroupParams = new MapParam(new ZabbixParam("hostgroup.create")).put("name", "test");
		HostGroupIds createValue = api.request(createHostGroupParams, HostGroupIds.class);

		// ホストグループ一覧取得 (フィルターで上記作成ホストグループだけ取得)
		Map<String, Object> filter = new HashMap<>();
		filter.put("name", "test");
		ZabbixParam getHostGroupParams = new MapParam(new ZabbixParam("hostgroup.get")).put("output", "extend").put("filter", filter);
		List<HostGroup> getValue = api.requestWithList(getHostGroupParams, HostGroup.class);
		System.out.println(getValue);
		
		// 上記のホストグループ削除
		ZabbixParam deleteHostGroupParams = new ListParam<String>(new ZabbixParam("hostgroup.delete")).add(createValue.groupIds.get(0));
		HostGroupIds deleteValue = api.request(deleteHostGroupParams, HostGroupIds.class);
		System.out.println(deleteValue.groupIds);
	}
}

出力結果

[{groupid=59, name=test, internal=0, flags=0}]
[59]

拡張手順

他の API 利用

次の手順で実装。

  1. Zabbix API 仕様を調べる
  2. 値格納用データクラス作成
  3. Main ロジック記述

例:ホストを作成する

  • Zabbix API 仕様

host.create [Zabbix Documentation 2.4]
host.get [Zabbix Documentation 2.4]
host.delete [Zabbix Documentation 2.4]

  • 値格納用クラスの追加
package api.zabbix.vo;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class Host {

	@JsonProperty("hostid")
	public String id;

	public int available;
	
	public String name;
	
	public String host;
	
	@JsonProperty("status")
	public String status;
}
package api.zabbix.vo;

import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;

public class HostIds {

	@JsonProperty("hostids")
	public List<String> ids;
}

ロジックの記述(追加分)

		// ホスト作成
		List<Map<String, Object>> interfaces = new ArrayList<>();
		Map<String, Object> if0 = new HashMap<>();
		if0.put("type", 1);
		if0.put("main", 1);
		if0.put("useip", 1);
		if0.put("ip", "127.0.0.1");
		if0.put("dns", "");
		if0.put("port", "10050");
		interfaces.add(if0);
		List<Map<String, Object>> groups = new ArrayList<>();
		Map<String, Object> group = new HashMap<>();
		group.put("groupid", createValue.groupIds.get(0)); // hostgroup.create から取得
		groups.add(group);
		ZabbixParam createHostParams = new MapParam(new ZabbixParam("host.create")).put("host", "test_server")
				.put("interfaces", interfaces).put("groups", groups);
		HostIds createHostValue = api.request(createHostParams, HostIds.class);
		System.out.println(createHostValue.ids);

		// ホスト取得
		Map<String, Object> filter = new HashMap<>();
		filter.put("host", "test_server");
		ZabbixParam getHostParams = new MapParam(new ZabbixParam("host.get")).put("output", "extend").put("filter", filter);
		List<Host> getValue = api.requestWithList(getHostParams, Host.class);
		System.out.println(getValue);

		// ホスト削除
		ZabbixParam deleteHostParams = new ListParam<String>(new ZabbixParam("host.delete")).add(getValue.get(0).id);
		Response deleteHostValue = api.request(deleteHostParams);
		System.out.println(deleteHostValue.readEntity(String.class));

これでホスト作成・取得・削除も実装できる。

便利なリクエストパラメータを構築するクラスが欲しい

ZabbixParam クラスに対する、Decorator 実装を行う。

  1. ZabbixParam かそれらを継承したクラスを継承したクラス作成
  2. コンストラクタ引数は ZabbixParam を取るもの
  3. 便利なメソッドを追加
  4. ロジッククラスで利用

Zabbix API の通信ログ取得

ZabbixAPIAccess の getResponse で全ての通信を一括しているため、そこでログ取得ライブラリ(Log4j, Logbackなど)で取得する。
実装詳細は、本題からそれるため割愛する。

トークンの取得条件を変更

本実装のトークン管理は、インスタンスに任せているためインスタンスが消滅すると、トークンを毎回取得する。
これをデータベースなどに格納して、頻繁にトークン取得させないようにしたいなら。

  1. トークン管理用データベース・テーブル作成
  2. トークン取得の際にデータベースにトークン/取得時間を格納
  3. データベースからトークン情報取得
  4. トークンが期限内なら使用。そうでなければ、破棄して取得するロジックを記述

ただし、この実装の際には、データベースに大量のトークンが残る可能性あるため
何かしら管理する機構を持たなければならない。

「プラス・マイナス・ゼロ」問題

概要

CodeIQ で私の解いてた問題で、ブログネタにできそうなものがあったので記述。

課題内容

codeiq.jp

自然数 n に対して、次の等式を考えます。
    □1□2□3□4…□n = 0
四角の部分には、プラス(+)またはマイナス(-)の記号が入ります。
等式を成立させるような記号の入れ方を考えましょう。

例えば、n = 7 のとき、次の 8 通りの入れ方が可能です。

+1+2-3+4-5-6+7 = 0
+1+2-3-4+5+6-7 = 0
+1-2+3+4-5+6-7 = 0
+1-2-3-4-5+6+7 = 0
-1+2+3+4+5-6-7 = 0
-1+2-3-4+5-6+7 = 0
-1-2+3+4-5-6+7 = 0
-1-2+3-4+5+6-7 = 0

自然数 n に対し、等式を成立させる記号の入れ方の数を F(n) と定義します。
例えば F(7) = 8 です。
同様に、F(3) = 2、F(8) = 14 となることが確かめられます。

標準入力から、自然数 n (1 ≦ n ≦ 50)が与えられます。
標準出力に F(n) の値を出力するプログラムを書いてください。

回答(C言語)

まずは、私の回答。

#include<stdio.h>

int main() {
	int i, j, n;
	scanf("%d", &n);

	int allSum = n*(n+1) >> 1;
	if(allSum & 1) {
		printf("0");
		return 0;
	}
	long long int count[allSum+1];
	for(i=0; i<=allSum; i++) count[i]=0L;

	count[0] = 1L;
	for(i=1; i<=n; i++) {
		int prevLength = ((i-1)*i >> 1)+1;
		long long int copy[prevLength];
		for(j=0; j<prevLength; j++) copy[j] = count[j];
		for(j=0; j<prevLength; j++) count[i+j] += copy[j];
	}
	printf("%lld", count[allSum >> 1]);
	return 0;
}

一部シフト演算子とか、ビット演算子とか気持ち悪いものを使っているが、次と同値。(読み辛くて申し訳ない)

  • [式] >> 1 の部分 → [式]/ 2
  • [式] & 1 → [式]が奇数なら1、偶数なら0

言い訳:当初書いたアルゴリズムがひどくて、計算が時間内に終わらず、できる限り計算時間の少なさそうな方法を選んていたから。

説明

単純にやると・・

回答の解説を見て頂ければ分かると思うが、単純に問題を解くと私の環境では n=23 くらいで処理が1秒以内に終了しなくなった。

なお、ここで単純な処理とは各□に+ や - を全組み合わせを試して確認するという方法。
この方法でも正しい結果は論理的に得られるが、アルゴリズムとしては n が 1増加することによって
計算時間が2倍増加してしまうため、アルゴリズムとしては実用的なものではない。

そういうわけで、確認方法を多少工夫する必要があり、その基本方針はまさに回答の「一つ手前の状態を考える」。
私はまず問題を変換してから考えているため、まずそれから説明する。

問題変換

全部のプラスマイナスパターンを確認するのは、面倒なので、まず問題を変換して解きやすくする。

まず、□1□2□3□4…□n = 0 を満たすプラスマイナスの組が合計 0 になる状況では、何が起こるか?

1〜n のプラス集合とマイナス集合の各々の合計値の絶対値は一致する
(例:+1+2-3+4-5-6+7 = 0 → |1+2+4+7| = |-3-5-6|=14
 ↓
各々の集合の絶対値は、1〜n の和{\displaystyle \frac{1}{2}n(n+1)}の半分に相当する
(例:1+2+3+4+5+6+7 = 7*8/2 = 28, 上記絶対値は14)
 ↓
1〜n のうち任意集合の合計値が {\displaystyle \frac{1}{4}n(n+1) } になる組み合わせ数が求めるべき数
 ↓
発生しうる任意集合の和とその組み合わせの数をリスト化したものを作成
 ↓
最後に{\displaystyle \frac{1}{4}n(n+1) }の組み合わせの数を表示

ただし、n=2 のように { S_2 =\displaystyle \frac{1}{2}*2*3 = 3 } が奇数になるものは解が存在しないため除外が必要。
これを下記で行っている。

	int allSum = n*(n+1) >> 1;
	if(allSum & 1) {
		printf("0");
		return 0;
	}

前回の処理結果から次の結果を求める方法

まず、選択した組み合わせの和を配列のインデックスに、その組み合わせ数を配列の値とする。
入力 n から、組み合わせ数を格納する配列長は、0(全て選択しない)〜{ \displaystyle \frac{1}{2}n(n+1) } (全て選択)まで確保すれば良い。

	long long int count[allSum+1];
	for(i=0; i<=allSum; i++) count[i]=0L;

まず、簡単な例を考える。□1 = 0 の場合。発生する部分和とそのカウント数は次の2通り。

[集合の合計値][組み合わせ数]
01
11
ここで、新しく2を追加する。つまり、 □1□2= 0 の場合。
[集合の合計値][これまでの組み合わせ数][2を選択した組み合わせ][組み合わせ数]
01-1
11-1
2-11
3-11

次に3を含めた場合。

[集合の合計値][これまでの組み合わせ数][3を選択した組み合わせ][組み合わせ数]
01-1
11-1
21-1
3112
4-11
5-11
6-11
同様に4を含めた場合。
[集合の合計値][これまでの組み合わせ数][4を選択した組み合わせ][組み合わせ数]
01-1
11-1
21-1
32-2
4112
5112
6112
7-22
8-11
9-11
10-11
これを以下のようにまとめる。
[集合の合計値][選択なし][1選択][2選択][3選択][4選択][組み合わせ数]
01----1
1-1---1
2--1--1
3--11-2
4---112
5---112
6---112
7----22
8----11
9----11
10----11
この表から、これまでの組み合わせ結果が分かれば、その結果と「それを下にn個ずらした組み合わせ数」を加算すれば組み合わせ数が求まる。
あとは、これを実装すれば良い。これを行ってるのが以下。

	count[0] = 1L;
	for(i=1; i<=n; i++) {
		int prevLength = ((i-1)*i >> 1)+1; // 前回には、どの範囲までを対象にすればいいか
		long long int copy[prevLength];
		for(j=0; j<prevLength; j++) copy[j] = count[j]; // 値のコピー
		for(j=0; j<prevLength; j++) count[i+j] += copy[j]; // 追加分足し込み
	}

そして、最終的に欲しい組み合わせ数は、1〜nまでの合計/2 だから。

	printf("%lld", count[allSum >> 1]);

で表示している。

改良点

コピーする必要なし

書いているとき気づいたが、値のコピーを作成するロジックは必要はない。(下記3, 4行目)

	for(i=1; i<=n; i++) {
		int prevLength = ((i-1)*i >> 1)+1; // 前回には、どの範囲までを対象にすればいいか
		long long int copy[prevLength];
		for(j=0; j<prevLength; j++) copy[j] = count[j]; // 値のコピー
		for(j=0; j<prevLength; j++) count[i+j] += copy[j]; // 追加分足し込み
	}

このコピーは、値が2重に足し込まれる問題に対して回避するため、元の値を保持する目的で作成していた。
しかし、この現象はインデックスが小さいものから処理すると発生するが、大きいものから処理すれば起こらない。

	for(i=1; i<=n; i++) {
		int prevLength = (i-1)*i >> 1; // 前回には、どの範囲までを対象にすればいいか
		long long int copy[prevLength];
		for(j=prevLength; j>=0; j--) count[i+j] += copy[j]; // 追加分足し込み
	}

この方がシンプルになる。

配列の初期化
	long long int count[allSum+1];
	for(i=0; i<=allSum; i++) count[i]=0L;
	count[0] = 1L;

配列の初期化は、こんなのでも可能らしく、値が指定されていないと0が自動で入るらしい。
そう思って、下記のように修正・・・。

	long long int count[allSum+1] = {1};

・・と思ったら、これ変数だめなようで、この書き方はサイズを定数で書いて確実にその長さが
存在することを保証しないといけないみたいだ。仕方ないので、n<=50 まで対応 → 50*51/2+1 = 1276 にすることに。

	long long int count[1276] = {1};
おまけ:短いコード

短いコードにするなら、ここまでは小さくできた。

#include<stdio.h>
int main() {
        int n,i=0,j;
        scanf("%d", &n);
        long long a=n*(n+1)/2,c[1276]={1};
        for(;++i<=n;)for(j=a;j>=0;j--)c[i+j]+=c[j];
        printf("%lld",a&1?0:c[a/2]);
}

不要な改行コードとかとって・・

#include<stdio.h>
int main(){int n,i=0,j;scanf("%d",&n);long long a=n*(n+1)/2,c[1276]={1};for(;++i<=n;)for(j=a;j>=0;j--)c[i+j]+=c[j];printf("%lld",a&1?0:c[a/2]);}

163byte。多分、やり方を変更すれば、もうちょっと縮まるかなあ。

Zabbix API を使ってみる

概要

前回の記事で(Raspberry PI に Zabbix Server 導入まで - Status Code 303 - See Other)
作ったZabbix Server に対して、API を発行してみる。今回は、curl で通信してみる。

Zabbix API テスト

BEFORE

Zabbix コンソールから現状のホストグループ構成を調べる。

まず、Zabbix コンソールにアクセスする。ルート管理者でログイン
http://[Raspberry pi プライベート IP]/zabbix/
f:id:kouki_hoshi:20160218001718p:plain

現状ではプライベート環境しか通信できないため、80番ポートでも大丈夫だと思うが、
今後、外部からも通信したいなら、セキュリティ上、SSL/TLS にしたりポート変更したりしなければならない。

そして、[設定]->[ホストグループ] で一覧確認。
f:id:kouki_hoshi:20160218002201p:plain

Zabbix API 実行

サンプルとして、 [設定]->[ホストグループ] に curl コマンドで新しくホストグループを作成する手順を記す。

認証情報を取得する。
curl -X POST -d '{"jsonrpc":"2.0","method":"user.login","id":1,"params":{"user":"userName","password":"xxxxx"}}' -H "Content-Type:application/json-rpc" http://[Raspberry pi プライベート IP]/zabbix/api_jsonrpc.php

やってることを説明すると


curl [options] http://[Raspberry pi プライベート IP]/zabbix/api_jsonrpc.php
Zabbix API を呼び出すコマンドで、 api_jsonrpc.php に対して問い合わせをする
-X POST
HTTP 問い合わせを POST で行う
-d '{"jsonrpc":"2.0","method":"user.login","id":1,"params":{"user":"userName","password":"xxxxx"}}'
問い合わせ時に引き渡すJSONデータ
-H "Content-Type:application/json-rpc"
送信データがJSON-RPC用データであることを送信先に明示する

なお、今回渡したデータは認証のためのデータ。詳細は本家 API 参照のこと。
user.login [Zabbix Documentation 2.2]

こんなデータが返ってくる。

{"jsonrpc":"2.0","result":"3df0b33dc345fe5a29f8b862a4e60bcc","id":1}

そして、「3df0b33dc345fe5a29f8b862a4e60bcc」の部分がトークンと呼ばれる、認証成功時に本人を識別するための情報。
このトークンには期限があり、これが切れると再度取得しなければならない。
それまでであれば、このトークンを API 呼び出しのデータに含めることで、様々な操作が実行できる。

API (hostgroup.create) 実行

test という名のホストグループを新しく作成してみる。API は下記を利用する。
hostgroup.create [Zabbix Documentation 2.2]

curl http://[Raspberry pi プライベート IP]/zabbix/api_jsonrpc.php -H "Content-type: application/json-rpc" -X POST -d '{"jsonrpc":"2.0","method":"hostgroup.create","auth":"3df0b33dc345fe5a29f8b862a4e60bcc","id":1,"params":{"name":"test"}}'

そして、成功すれば、以下のような結果を得る。
これは、ホストグループの作成に成功し、そのホストグループ ID を結果として送られている。

{"jsonrpc":"2.0","result":{"groupids":["11"]},"id":1}

実際に、さっきのページを更新してみると。

f:id:kouki_hoshi:20160218010006p:plain
test という名のホストグループが作られていることが分かる。
同様に、他の API を用いて行えば様々に Zabbix の設定を変更することができる。

ホストグループ以外に作成できるもの

ホストグループは、ホストと呼ばれる統合的な管理オブジェクトをまとめるものである。
だから、これだけでは特に何もできない。そこで、他には何を作成できるか、その一例を記す。


ホスト
様々なオブジェクト(下記)を統合的に管理するオブジェクト
テンプレート
設定情報を保持するオブジェクトで、ホストに設定するとその設定が適用されたホストになる
ユーザマクロ
ホストごとに特有なパラメータを管理するオブジェクト
アイテム
監視データを管理するオブジェクト
アプリケーション
論理的なグループでアイテムをグループ化するためのオブジェクト
トリガー
監視データから異常を検知するオブジェクト
アクション
異常を検知したときに、その動作定義を保持したオブジェクト

他にもあるので、もし興味があれば本家で調べてみるといいだろう。
Zabbix :: The Enterprise-Class Open Source Network Monitoring Solution

Raspberry PI に Zabbix Server 導入まで

概要

Raspberry pi に Zabbix Server を追加した経験談。
自身の失敗をメモしておくとともに、自身の失敗が誰かの救いになれば良い。

・・知識がないとこれほど大変だとは思わなかった。

事の発端

過去に購入したRaspberry Pi

Raspberry Pi 2 Model B (1)

Raspberry Pi 2 Model B (1)

監視カメラを作っている記事を見つけ、実践するつもりだったが、ただ動画キャプチャするだけだと
何か異常が起こっても分からないし、動画全部見るなんて結構面倒くさい。
そこで、状況に応じて、色んな制御を内部で組み込みたいと思っていた。

そして、Zabbix サーバというかなり高機能な運用監視サーバを導入しようと思いたち開始。

立ち上げまでの経緯

MySQL が動かない

MySQL のインストールがうまくいっていない。どうやら MySQL サーバが起動できないらしい。
すでにパッケージはインストールされているが、どうやら起動に毎回失敗している。
mysql のログは /var/log/syslog に出るらしいので、見てみると。

Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Can't start server: Bind on TCP/IP port: Cannot assign requested address
Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Do you already have another mysqld server running on port: 3306 ?
Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Aborting

とにかく接続できないらしい。netstat -t -a -n で調べるが、ポートは問題ない。
どうやら、curl http://127.0.0.1/ping 127.0.0.1 で失敗することから、127.0.0.1 が認識できていないようだ。
とりあえず /etc/mysql/my.cnf にある、bind-address を 127.0.0.1 → 192.168.0.xx に変えてみたら動いた。
確認してみると、過去に SSH の設定したときに、ネットワーク設定を間違ったようだ。
/etc/network/interfaces を確認すると、ループバックアドレス設定が間違っていた。

auto l0
iface lo inet loopback

なぜ今まで気づかなかったのか・・。bind-address も 127.0.0.1 に直して動くことを確認。

apt-get の Zabbix Server のバージョンが古い

無事 zabbix のインストールができたのだが、apt-get で導入した zabbix-server のバージョンが 1.8 。
あまりに古すぎるため、apt-get のミラーサイト変えてみたりして、色々試してみたもののどうしようもなかった。
調べてみると、ソースコードをビルドする方法で、最新バージョンを導入できそうなので (動作するか分からないけど)
今回はとりあえず、apt-get の推奨する動作環境で確認して、後で入れ替えて見ることに。

しかし、Zabbix の初期設定画面がうまくいかなかった。なんか mysql への接続に失敗しているみたい。
理由が分からなかっため、1週間以上放置してしまう。

Raspberry pi だからこそ起こる問題発覚

再開しようと思ってたおり、調査していると現状のままでは色々まずいことを web で発見する。
Zabbix サーバは監視データを逐一記録するため、数多くデータを書き込むが、
SD カード(Raspberry pi記憶媒体)の寿命は書き換え回数ので数千回くらいなんだとか。
SD書き換え回数制限について - その他(ハードウェア) [解決済 - 2015/03/14] | 教えて!goo

ってことは、1分に1回データ書き込んでいたら、書き込み回数が一日 60 * 24 = 1460回 だから、一週間持たないということになる。
ってことで、データは外付け HDD に格納しなければならないと思い購入。

Raspberry Pi のコンパクトさを維持するため、なるべくかさばらないものを選択。
また、対象機種は Windows だけでなく Mac OS X も含まれていると Linux でも動作は問題ないだろうと判断。
容量としては500GB あれば十分と思い、メーカーと値段を考慮して以下に決定。

HDD を認識しない

注文から3日後、HDD が届く。Raspberry pi の USB に HDD を差し込み、起動。
どうやら最初から NTFS でフォーマットされているらしく、フォーマットとかパーティション切らなくていいらしい。
Linux コマンドの分からない自分にとっては調べる手間が省けて嬉しい。

・・・しかし。df コマンド叩くも、50GB くらいの ディスクが出てこない。
マウントしてないから? → fdisk -l コマンドを叩く。→ そもそも外付け HDD の情報がない。

おまけに HDD からカチッカチッと音がする。よくわからないが Raspberry pi は問題なく動作しているように見える。
HDD の初期不良かと思って、 Windows PC に差してみたが動作する。仕方がないので調査。
Raspberry pi 2が外付けHDDを自動認識してくれなかった - Qiita

同じ症状が出てる人結構いるみたいです。
/etc/fstab down't work with RPI2 · Issue #824 · raspberrypi/linux

  • raspi2では起動プロセスが複数コアで並列して行われてる
    • /etc/fstabのパースがHDD認識と同時またはそれ以前に行われてしまう
    • /etc/fstabを読んでる段階ではUUIDとHDDが紐ついていないので、そんなHDDないよってことですっとばされる

各HDDのUUIDが判明するまでマウント作業を待ってもらえばいい。
/boot/cmdline.txtにrootdelay=5を追記。

上記を試してみるも、変化なし。

調べてみると電源が原因の可能性が浮上。私が使っている電源は 1.8A 出力するので、問題ないと思っていたが、
Windows PC に差したときにはカチッカチッという音はしなかったため、この可能性が高いと思い電源ハブを購入。

NTFS じゃだめだった?

電源ハブが届き、apt-get から ntfs-3g パッケージをインストールし、NTFS も読めるようにした。
すると、HDD を認識した。どうやら原因は電源だったようだ。ファイルの一覧も表示できており、動作は問題なさそうだ。

Zabbix サーバでは、監視データなどは基本的に、データベース内に作成するということで、mysql が本来ある場所に
シンボリックリンクを張って、mysql 自体を HDD に移動。
kakakikikekeのブログ: 【CentOS】zabbixのmysqlデータの保存先を変更する方法

service mysqld stop
cd /var/lib
mv mysql [HDD パス]
ln -s [HDD パス]/mysql mysql
service mysqld start

すると。【Can't connect to local MySQL server through socket '/var/lib/mysqld/mysqld.sock'】が出た。
そして、調べてみると、mysqlのファイル/フォルダの所有者が mysql ではなく pi になっている。
なので、chown を使って mysql に変更しようとするが、できない。

エラーが出ていないのに、実行しても所有者が変わらない?・・権限?
念のため、chmod ・・・が、アクセス権自体が変更できない。なんだこれは?

どうやら、ntfs では、マウント後でアクセス権変更したりとかできないらしい。
chmod で変更できません。 - linux 初心者ですlinux mint 14 を... - Yahoo!知恵袋

なので、ディスクを NTFS から ext4 に変更。参考にしたのは以下。
Raspberry Piでファイルサーバ、Part2 外付けハードディスクの導入編 | ものづくりエクスペリメント

UUID 調べて、etc/fstab に設定。

Zabbix も最新バージョンに移行

古いバージョンの zabbix を念のため、全て削除。
Zabbix の本家から最新ソースコードをダウンロードして、適用する。

下記の記事は、Zabbix 2.4.7 環境についてだったが、2.4.7 でも同様の手順で立ち上げられた。
端的にやることとコマンドだけ書かれているため、動かすことを目的としている私にはとても助かった。
raspberry2B/zabbix2.4.4

しかし、動かすことを優先しすぎて設定内容がよくわかってないため、これから設定内容は見直してみようと思う。

SSHJ による暗号化通信

概要

様々な処理をサーバに任せ、その処理結果だけをサーバから取得したい場合がある。

このような通信を行う際、FTPTELNET を用いて行うと
通信が暗号化されないため、その内容を第三者に盗聴されるリスクを生んでしまう。

ここでは、SSH による暗号化通信を Java で実装する方法について記述する。
また、SSH 用のライブラリは多数存在するが、今回は SSHJ(version 0.15.0) を用いる

実装内容

今回は「サーバと簡単な通信が行える」を前提として、次の機能を提供するクラスを作成。

  • 認証 (パスワード/公開鍵(OpenSSHキーファイル)認証)
  • リモート先でのコマンド実行
  • SCP/SFTP を用いたファイルダウンロード
  • SCP/SFTP を用いたファイルアップロード

SCP と SFTP は場合によって使い分ける可能性があると思い、両方使えるようにした。
SCP と SFTP の違いはこちらが参考になる。セキュリティプロトコルマスター(8):軽快なscpか高機能なsftp、sshサーバに向いているのは? (1/2) - @IT

今回は、 SSH 用のライブラリを今後取り替える可能性も考慮して、ロジックにこれらを記述せず
Facade クラス(Facade - GoF デザインパターン - Status Code 303 - See Other) を作成、これらを利用する。

詳細コード

ビルドパス設定

SSHJ

今回の SSH のコアとなるライブラリ

  • sshj-0.15.0.jar
SSHJ を使用する場合に必要なライブラリ

暗号化ライブラリ

  • bcpkix-jdk15on-154.jar
  • bcprov-jdk15on-154.jar
  • ecc-25519-java-1.0.1.jar

圧縮ライブラリ (ダウンロード/アップロードで必要)

  • jzlib-1.1.3.jar

ログ出力ライブラリ

Apache Mina の提供する SSH ライブラリ

  • sshd-core-1.0.0.jar
ボイラープレートコード排除

クラス

SSHJ のFacade クラス (SSHClientFacade.java)

package communicate.ssh;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.TimeUnit;

import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.common.IOUtils;
import net.schmizz.sshj.connection.channel.direct.Session;
import net.schmizz.sshj.connection.channel.direct.Session.Command;
import net.schmizz.sshj.transport.TransportException;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import net.schmizz.sshj.userauth.UserAuthException;
import net.schmizz.sshj.userauth.keyprovider.FileKeyProvider;
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile;
import net.schmizz.sshj.xfer.FileSystemFile;

public class SSHClientFacade implements AutoCloseable {

	/**
	 * SSH Client
	 */
	SSHClient ssh = new SSHClient();

	/**
	 * Connect server.
	 * 
	 * @param host
	 * @param port
	 * @throws IOException
	 */
	public void connect(String host, int port) throws IOException {
		ssh.useCompression(); // need jzlib
		ssh.addHostKeyVerifier(new PromiscuousVerifier());
		ssh.connect(host, port);
	}

	/**
	 * Add user information with key file.
	 * 
	 * @param user
	 * @param keyFile
	 * @throws TransportException
	 * @throws UserAuthException
	 */
	public void authPublickey(String user, File keyFile) throws UserAuthException, TransportException {
		FileKeyProvider provider = new OpenSSHKeyFile();
		provider.init(keyFile);
		ssh.authPublickey(user, provider);
	}
	
	/**
	 * Add user information with password.
	 * 
	 * @param user
	 * @param password
	 * @throws IOException
	 */
	public void authPassword(String user, String password) throws UserAuthException, TransportException {
		ssh.authPassword(user, password);
	}
	
	/**
	 * Execute Command.
	 * 
	 * @param command
	 * @return SSH Result
	 * @throws IOException
	 */
	public SSHResult execute(String command) throws IOException {
		try (final Session session = ssh.startSession()) {
			Command cmd = session.exec(command);
			cmd.join(30, TimeUnit.SECONDS);
			String output = IOUtils.readFully(cmd.getInputStream()).toString();
			Integer status = cmd.getExitStatus();
			if (status == 0) {
				return new SSHResult(status, output, "");
			}
			return new SSHResult(status, output, IOUtils.readFully(cmd.getErrorStream()).toString());
		}
	}

	/**
	 * Download file.
	 * 
	 * @param src
	 *            Source file path (remote)
	 * @param dest
	 *            Destination directory or file path (local)
	 * @param method
	 *            SCP or SFTP
	 * @throws IOException
	 */
	public void download(String src, File dest, TransferMethod method) throws IOException {
		method.download(ssh, src, dest);
	}

	/**
	 * Upload file.
	 * 
	 * @param src
	 *            Source file path (local)
	 * @param dest
	 *            Destination directory or file path (remote)
	 * @param method
	 * @throws IOException
	 */
	public void upload(File src, String dest, TransferMethod method) throws IOException {
		method.upload(ssh, src, dest);
	}

	/**
	 * Tear down.
	 * 
	 * @throws IOException
	 */
	@Override
	public void close() throws IOException {
		try {
			ssh.close();
		} finally {
			ssh.disconnect();
		}
	}

	/**
	 * Transfer method strategy.
	 *
	 */
	public static enum TransferMethod {
		SCP {
			@Override
			void download(SSHClient ssh, String src, File dest) throws IOException {
				ssh.newSCPFileTransfer().download(src, new FileSystemFile(dest));
			}

			@Override
			void upload(SSHClient ssh, File src, String dest) throws IOException {
				ssh.newSCPFileTransfer().upload(new FileSystemFile(src), dest);
			}
		},
		SFTP {
			@Override
			void download(SSHClient ssh, String src, File dest) throws IOException {
				ssh.newSFTPClient().get(src, new FileSystemFile(dest));
			}

			@Override
			void upload(SSHClient ssh, File src, String dest) throws IOException {
				ssh.newSFTPClient().put(new FileSystemFile(src), dest);
			}
		};

		/**
		 * Download File.
		 * 
		 * @param ssh
		 *            SSHClient
		 * @param src
		 *            Source file(remote)
		 * @param dest
		 *            Destination directory or file path(local)
		 * @throws IOException
		 */
		abstract void download(SSHClient ssh, String src, File dest) throws IOException;

		/**
		 * Upload File.
		 * 
		 * @param ssh
		 *            SSHClient
		 * @param src
		 *            Source file(local)
		 * @param dest
		 *            Destination directory or file path(remote)
		 * @throws IOException
		 */
		abstract void upload(SSHClient ssh, File src, String dest) throws IOException;
	}
}

SSHClient から返る結果を格納するクラス (SSHResult.java)

package communicate.ssh;

import lombok.Value;

@Value
public class SSHResult {
	
	/**
	 * Exit status of command.
	 */
	public int status;
	
	/**
	 * Output.
	 */
	public String output;

	/**
	 * Error.
	 */
	public String error;
}

設定ファイル (ssh.properties)

# Connection Settings
ssh.host = [必須, IPアドレス、ホスト名どちらでも可]
ssh.port = [必須]

# User Authentication
ssh.user = [必須]
ssh.keyfile = [キーファイルのパスを指定、今回はOpenSSH形式のキーファイル対応]
ssh.password = [パスワード、公開鍵認証なら空でも良い]

使い方

  1. ssh.properties を変更する
    • ssh.host, ssh.port, ssh.user は設定必須
    • パスワード認証 → ssh.password 設定
    • 公開鍵認証 → ssh.keyfile 設定 (今回は、OpenSSLキーファイルのみ対応)
  2. ロジックを記述する

(Property クラス → プロパティファイルの使用方法 - Status Code 303 - See Other)

package communicate.ssh;

import java.io.File;
import java.io.IOException;

import communicate.ssh.SSHClientFacade.TransferMethod;
import resources.properties.Property;

public class Main {

	public static void main(String[] args) throws IOException {

		Property property = new Property("ssh");
		try (SSHClientFacade ssh = new SSHClientFacade()) {			
			// 接続
			ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"));

			// 認証
			ssh.authPublickey(property.getString("ssh.user"), new File(property.getString("ssh.keyfile")));
			// ssh.authPassword(property.getString("ssh.user"), property.getString("ssh.password"));

			// コマンド実行
			SSHResult result = ssh.execute("ls -al > test.txt");
			System.out.println(result);

			// ファイルのダウンロード
			ssh.download("/home/pi/test.txt", new File("./sample"), TransferMethod.SCP); // SCP の場合
			// ssh.download("~/test.txt", new File("./"), TransferMethod.SFTP);  //SFTP の場合

			// ファイルのアップロード
			ssh.upload(new File("./sample"), "/home/pi/", TransferMethod.SCP); // SCP の場合
			// ssh.upload(new File("./test.txt"), "/home/pi/", TransferMethod.SFTP); // STFP の場合
		}
	}	
}

注意点

リモート先のパスは、なるべくフルパスで記述した方が良い。不思議なことに、ヘテムルでは
download メソッドの引数ではホームディレクトリ(~/xxx/yyy) を解釈するが、upload 時は認識できないようで、
下記の例外が発生した。なお、フルパスに変更すると動作した。

Exception in thread "main" net.schmizz.sshj.xfer.scp.SCPRemoteException: Remote SCP command had error: scp: ~/xxx/yyy: No such file or directory

接続方法を様々に変更する拡張

今回は単純化のため、サーバへの接続方法が同じであると仮定して実装した。
しかし、様々なサーバに接続する際に、その接続設定が異なることもあるだろう。
これらは、SSHClient の設定を変更することで実現できる。

接続方法の追加

まず SSHClientFacade クラスに以下を追加する。

	// アクセス方法に関する Strategy
	public static interface AccessStrategy {
		public abstract void execute(SSHClient ssh) throws IOException;
	}
	// connect メソッドを追加する
	public void connect(String host, int port, AccessStrategy method) throws IOException {
		method.execute(ssh);
		ssh.connect(host, port);
	}

そして、ロジックでは次のように使う想定。

	ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), new AccessStrategy(){
		@Override
		public void execute(SSHClient ssh) throws IOException {
			ssh.setConnectTimeout(60 * 1000);
			ssh.setTimeout(30 * 60 * 1000);
			ssh.loadKnownHosts();
			ssh.addHostKeyVerifier(new PromiscuousVerifier());
		}				
	});

これで、サーバによって柔軟な設定が可能になり、SSHClient を次のメソッドを用いて設定する。


addHostKeyVerifier
コネクションの確立と鍵交換において、公開鍵の正当性を確かめるインスタンスを追加する。
addAlgorithmsVerifier
接続要求のアルゴリズムの正当性を確かめるインスタンスを追加する
loadKnownHosts
known_hostsファイルを読み、それに記述されているホストを正当な接続先とする
useCompression
データを圧縮して通信を行う (JZLib が必要)
setSocketFactory
ソケットのファクトリインスタンスを設定する
setTimeout
タイムアウト時間を設定する(ミリ秒)
setConnectTimeout
接続に関するタイムアウト時間を設定する(ミリ秒)

Facade を崩さない

このままではロジック部分が SSHJ を意識しなければならず、 SSHJ 特有の設定が SSHClientFacade 外部に漏れてしまう。
これでは、Facade を作った意味がない。なので、これらアクセス方法を定義した Strategy パターンを Enum 型で実装し、利用する。

実装一例。下記を SSHClientFacade に追加。

	/**
	 * Definition how to access to your server. 
	 *
	 */
	public static enum AccessStrategy {
		SERVER_A {
			@Override
			void execute(SSHClient ssh) throws IOException {
				ssh.setConnectTimeout(60 * 1000);
				ssh.setTimeout(30 * 60 * 1000);
				ssh.loadKnownHosts();
				ssh.addHostKeyVerifier(new PromiscuousVerifier());
			}			
		},
		SERVER_B {
			@Override
			void execute(SSHClient ssh) throws IOException {
				ssh.setConnectTimeout(10 * 1000);
				ssh.setTimeout(10 * 60 * 1000);
				ssh.loadKnownHosts();
			}
		};

		/**
		 * write your settings.
		 * @param ssh
		 * @throws IOException
		 */
		abstract void execute(SSHClient ssh) throws IOException;
	}
	/**
	 * Access server with your customize settings.
	 * @param host
	 * @param port
	 * @param method
	 * @throws IOException
	 */
	public void connect(String host, int port, AccessStrategy method) throws IOException {
		method.execute(ssh);
		ssh.connect(host, port);
	}

ロジック部の使用例

	ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), AccessStrategy.SERVER_A);

SSH はセキュリティ上重要なため、ライブラリ開発者の都合によっては、今後取り替えることを想定すべきかもしれない。
もしそうであったとしても、おそらくサーバの接続方法や設定は概念的には変わらない。
このように設計しておけば、ロジックの変更は SSHClientFacade クラスを変更するだけなので、取り替えは容易になる。
(またこのメソッドを直接ユーザに使わせないよう、ストラテジーのメソッドアクセスレベルはパッケージにしている)
ただし、この拡張はコードが複雑になるため、本当に必要なときだけ行った方が良い。

また、ここでは触れないが、認証方法に関しても様々なものが用意されているため、
本家のリファレンスは見ておくといいかもしれない。