ブラウザからLinuxサーバのログを気軽に参照するWebサーバ

UbuntuなどのLinuxサーバにあるログを参照するためにわざわざSSHでログインするのが面倒なので、ブラウザから参照できるようにした。
適当につくったものなので、セキュリティも何もない。自己責任でお願いします。

画面はこんな感じ。一応行番号も付くようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/dadc9c26-bdd8-42c0-c07f-e63802fe1864.png)

ログの先頭や末尾をサクッと見れたり、前ページ・後ページに移動できたりします。
なんならファイルダウンロードもできます。
一応、APIKeyで守ってますが、適当実装なので、ローカルネットワークでのみ使てください。

ソースコードもろもろはGitHubに上げておきました。

poruruba/SimpleLogViewer

https://github.com/poruruba/SimpleLogViewer

#ソースコード(サーバ)

そのままソースコード載せておきます。

```js:api/controllers/tail-file/index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const BinResponse = require(HELPER_BASE + 'binresponse');
const TextResponse = require(HELPER_BASE + 'textresponse');

const APIKEY = "【お好きなAPIKey】";

const logfile_list = [
// 参照したいログファイル名の配列
];

const { exec } = require('child_process');
const streamBuffers = require('stream-buffers');
const archiver = require('archiver');
const path = require('path');

exports.handler = async (event, context, callback) => {
	if( event.path == '/tail-view-file'){
		if (!event.requestContext.apikeyAuth || event.requestContext.apikeyAuth.apikey != APIKEY )
			throw "wrong apikey";

		var body = JSON.parse(event.body);
		console.log(body);

		if( logfile_list.indexOf(body.fname) < 0 )
			throw 'not allowed';
		
		var num = Number(body.num);
		var start = Number(body.start);
		return new Promise((resolve, reject) =>{
			var exec_batch;
			if (body.order == 'head'){
				exec_batch = `cat -n ${body.fname} | head -n ${start - 1 + num} | tail -n ${num} | sed -r "s/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})*)?m//g" | col -bx`;
			} else if (body.order == 'tail'){
				exec_batch = `cat -n ${body.fname} | tail -n ${start - 1 + num} | head -n ${num} | sed -r "s/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})*)?m//g" | col -bx`;
			}else{
				reject('unknown order');
			}
			exec(exec_batch, (err, stdout, stderr) => {
				if (err) {
					reject(err);
					return;
				}
				resolve(new TextResponse("text/plain", stdout));
			});
		});
	}else
	if( event.path == '/tail-get-file'){
		if (!event.requestContext.apikeyAuth || event.requestContext.apikeyAuth.apikey != APIKEY )
			throw "wrong apikey";

		var body = JSON.parse(event.body);
		console.log(body);

		if( logfile_list.indexOf(body.fname) < 0 )
			throw 'not allowed';
		
		return new Promise((resolve, reject) =>{
			var dest_stream = new streamBuffers.WritableStreamBuffer();
			const archive = archiver('zip', {
				zlib: { level: 9 }
			});
			dest_stream.on('finish', () => {
				console.log('stream finish');
				var response = new BinResponse('application/zip', dest_stream.getContents());
				response.set_filename(path.basename(body.fname) + '.zip');
				resolve(response);
			});

			archive.pipe(dest_stream);
			archive.on('error', (err) => {
				reject(err);
			});

			archive.file(body.fname, { name: path.basename(body.fname) });
			archive.finalize();
		});
	}else
	if( event.path == '/tail-list' ){
		return new Response({ list: logfile_list });
	}
};
```

要は、以下のようなコマンドを実行しているだけです。
行番号付けたり、画面制御コードを省いたりしています。

```
cat -n ${body.fname} | tail -n ${start - 1 + num} | head -n ${num} | sed -r "s/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})*)?m//g" | col -bx
```

#ソースコード(クライアント)

クライアント側ソースコード。
特に難しいことはしていません。Vueのおかげで。

```js:public/log_viwer/js/start.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

const base_url = "http://【本サーバのURL】";

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        apikey: '',
        file_list: [],
        num_of_col: 1,

        select_file: ["", ""],
        log_data: ['', ''],
        select_order: ["tail", "tail"],
        start_line: [1, 1],
        get_line: [30, 30],
        top_line: [-1, -1]
    },
    computed: {
        class_row: function () {
            return "col-sm-" + Math.floor(12 / this.num_of_col);
        }
    },
    methods: {
        check_top_line: function (index, log_data) {
            try {
                var i = 0;
                for (; ; i++)
                    if (log_data.charAt(i) != ' ' || !log_data.charAt(i))
                        break;
                var j = i;
                for (; ; j++)
                    if (log_data.charAt(j) == ' ' || !log_data.charAt(j))
                        break;
                if (i < j)
                    this.top_line[index] = Number(log_data.substring(i, j));
                else
                    this.top_line[index] = -1;
            } catch (error) {
                console.log(error);
            }
        },
        add_num: function (index, target, num) {
            if (target == 'start_line') {
                var line = this.start_line[index] + num;
                if (line < 1) line = 1;
                this.$set(this.start_line, index, line);
            } else
                if (target == 'get_line') {
                    var line = this.get_line[index] + num;
                    if (line < 1) line = 1;
                    this.$set(this.get_line, index, line);
                }
        },
        log_get_file: async function (index) {
            try {
                this.progress_open();
                var param = {
                    fname: this.select_file[index],
                };
                var blob = await do_post_blob_with_apikey(base_url + "/tail-get-file", param, this.apikey);
                Cookies.set('tail-apikey', this.apikey, { expires: 3650 });

                var url = window.URL.createObjectURL(blob);
                var a = document.createElement('a');
                a.href = url;
                a.target = '_blank';
                a.download = "download.zip";
                a.click();
                window.URL.revokeObjectURL(url);
            } catch (error) {
                console.error(error);
                alert(error);
            } finally {
                this.progress_close();
            }
        },
        log_view_file: async function (index) {
            var param = {
                fname: this.select_file[index],
                order: this.select_order[index],
                start: this.start_line[index],
                num: this.get_line[index],
            };
            try {
                var log_data = await do_post_text_with_apikey(base_url + "/tail-view-file", param, this.apikey);
                if (!log_data)
                    return;
                this.check_top_line(index, log_data);
                this.$set(this.log_data, index, log_data);
                Cookies.set('tail-apikey', this.apikey, { expires: 3650 });
            } catch (error) {
                console.error(error);
                alert(error);
            }
        },
        log_next: async function (index) {
            if (this.top_line[index] < 0)
                return;

            var start;
            if (this.select_order[index] == 'tail') {
                start = this.top_line[index] - this.get_line[index];
                if (start < 1) start = 1;
            } else if (this.select_order[index] == 'head') {
                start = this.top_line[index] + this.get_line[index];
            }
            var param = {
                fname: this.select_file[index],
                order: 'head',
                start: start,
                num: this.get_line[index],
            };
            try {
                var log_data = await do_post_text_with_apikey(base_url + "/tail-view-file", param, this.apikey);
                if (!log_data)
                    return;
                this.check_top_line(index, log_data);
                this.$set(this.log_data, index, log_data);
            } catch (error) {
                console.error(error);
                alert(error);
            }
        },
        log_prev: async function (index) {
            if (this.top_line[index] < 0)
                return;
            var start;
            if (this.select_order[index] == 'tail') {
                start = this.top_line[index] + this.get_line[index];
            } else if (this.select_order[index] == 'head') {
                start = this.top_line[index] - this.get_line[index];
                if (start < 1) start = 1;
            }
            var param = {
                fname: this.select_file[index],
                order: 'head',
                start: start,
                num: this.get_line[index],
            };
            try {
                var log_data = await do_post_text_with_apikey(base_url + "/tail-view-file", param, this.apikey);
                if (!log_data)
                    return;
                this.check_top_line(index, log_data);
                this.$set(this.log_data, index, log_data);
            } catch (error) {
                console.error(error);
                alert(error);
            }
        },
    },
    created: function () {
    },
    mounted: async function () {
        proc_load();

        this.apikey = Cookies.get('tail-apikey');

        try {
            var result = await do_post(base_url + "/tail-list", {});
            this.file_list = result.list;
            this.select_file[0] = this.file_list[0];
            this.select_file[1] = this.file_list[0];
        } catch (error) {
            console.error(error);
            alert(error);
        }
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */

window.vue = new Vue(vue_options);


function do_post_text_with_apikey(url, body, apikey) {
    const headers = new Headers({ "Content-Type": "application/json; charset=utf-8", "X-API-KEY": apikey });

    return fetch(url, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: headers
    })
        .then((response) => {
            if (!response.ok)
                throw 'status is not 200';
            //            return response.json();
            return response.text();
            //    return response.blob();
            //    return response.arrayBuffer();
        });
}

function do_post_blob_with_apikey(url, body, apikey) {
    const headers = new Headers({ "Content-Type": "application/json; charset=utf-8", "X-API-KEY": apikey });

    return fetch(url, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: headers
    })
        .then((response) => {
            if (!response.ok)
                throw 'status is not 200';
            //    return response.json();
            //    return response.text();
            return response.blob();
            //    return response.arrayBuffer();
        });
}
```

以上