概要
このエントリでは、REST風にOS上のコマンドを呼び出せるcommand-as-a-serviceのコンテナを作ってみたときに作業した内容について紹介します。
対象読者
以下の方を対象とします
- ネタが嫌いではない方
- 厳格なREST主義以外でもネタとして受け付けられる方
- お仕事でそのまま使ってやろう、と思う前にいろいろ自分で確認できる心の余裕がある方
動機
「http://localhost/bin/grep」とかでコマンド呼び出せたら、意味もなく面白いのでは、と思ったので。
関連
限られた1つのコマンドだけを実行したいときには、「Goで外部コマンドを呼び出すのをHTTPリクエスト越しに実行してみる(Ginを使って)」も参照ください。Ginが対応しているものがいろいろ使えるので、カタく使うならおすすめです。
TL;DR; 一言で言ってこういうものです
様子だけ知りたい方
コンテナを実行しておいて、
$ docker run -p 8080:8080 hrkt/command-as-a-service:latest
curlでアクセスすると、こんな感じになります。
$ curl "http://localhost:8080/bin/date"
Sat Jul 27 07:08:47 UTC 2019
標準入力にはPOSTのbodyを渡して、コマンドのオプションはクエリパラメーターで与えます。
$ curl "http://localhost:8080/usr/bin/sort?-r&-n" --data-binary @testdata/test.txt
hello
goodbye
(補足)上記の例でのデータの内容
$ cat testdata/test.txt
goodbye
hello
ご自身でも動かしてみたい方
Dockerhubで公開しておりますので、上記のコマンドの通り起動してみて、(デフォルトではWhitelistで制限された)コマンドを試してみてください。
何をやっているか
URLのハンドル
Go-langのnet/httpを使い、自分でハンドラを書いています。
以下を行いました。
- リクエストのディレクトリをOSのコマンドへのパスに読み替えるために利用したい。一般的な用途に対して使いやすい、パスを固定的に関数にバインドしてくれるルータを使うと帰って大変になるので、一つのハンドラで足りるように書く
- OSのコマンドへのオプションとして利用するため、クエリパラメータを解析。goのモジュールのお便利関数を使うと解析結果がmapになってしまうので、そこを実装
- コマンドの実行は、別エントリで書いたものを踏襲。ただし、コマンド実行時のエラーハンドルを少しできるようにし、httpのリターンコードとして表現できるようにする
コードは↓な感じです。
func MyServer() http.Handler {
return &myHandler{}
}
type myHandler struct {
}
func (f *myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Println("called:" + r.URL.Path)
buffer, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf(err.Error())
}
unescaped, _ := url.QueryUnescape(r.URL.RawQuery)
params := strings.Split(unescaped, "&")
log.Println(params)
code, res := executeIt(r.URL.Path, string(buffer), params)
if code == 400 {
http.Error(w, res, http.StatusBadRequest)
return
}
w.Write([]byte(res))
}
OSコマンドの実行
OSのコマンド実行を全部できるようにしてしまうと、流石にイマイチではないかと考え、実行可能なコマンドは、設定ファイルでホワイトリストとして与えるようにしました。
(が、細かいことはいいんだよ、という目的も考え、設定ファイルで「DangerousMode」を有効化すると、何でもかんでも実行できるようにしてあります)
func executeIt(path string, requestBody string, params []string) (int, string) {
if !appConfig.DangerousMode {
_, ok := whitelistMap[path]
if !ok {
return 400, string("ERROR: command " + path + " not in the whitelist")
}
}
var cmd *exec.Cmd
if len(params) > 0 && params[0] == "" {
cmd = exec.Command(path)
} else {
cmd = exec.Command(path, params[:]...)
}
cmd.Stdin = strings.NewReader(requestBody)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
log.Printf(err.Error())
return 400, string("ERROR: command execution failed. reason: " + err.Error())
}
log.Println(stdout.String())
return 0, string(stdout.String())
}
まとめ
このエントリでは、REST風にコマンドを呼び出せるcommand-as-a-serviceのコンテナを作ってみるにあたり、あれこれ試してみた結果について紹介しました。
参照
- GitHub: https://github.com/hrkt/command-as-a-service
- DockerHub: https://cloud.docker.com/repository/registry-1.docker.io/hrkt/command-as-a-service
(参考)開発スタイル
以下でやってます。
- GitHub使う
- GitHubの「Project」を使って、タスクをIssueとして書く
- コード書くのはmacOSでVScode、GitHub Desktopを併用
- CIは、CircleCI使う。PRのMerge時にテストこけないようにする
- 出来上がったコンテナは、DockerHubの自分のアカウントで公開する
この作業をやっていた2019/7/27は、Fuji Rockの中継をYoutubeでやっていたので、BGMとして楽しみました。