外からブラウザ上で terminal を使う

cugel.hatenablog.com のその後

動機

ぼかして書くが、アプリケーションゲートウェイを通しているので http、https くらいしか外と通信する手段がなく、ssh が使えない環境で家のマシンに接続してターミナル環境を使いたい。勝手に使われると大惨事なので、認証と通信の安全性も必要という要求がある。

まずはできあいのものから

FreeBSDports を探したところ、 www/anyterm, www/shellinabox, www/butterfly, www/gotty などが見つかった。しかしこれらには問題があって、まず AnyTerm は segfault するので使えない。 ほかの3つは、 http://example.jp/ のようにルートパスで公開されるのが前提のコードになっていた。 私は https://example.jp/myterm/ のような URI で裏で動いているプログラムに reverse proxy で接続するようにしたいので、これでは使えない。

無ければ作ればいいじゃない

ちょっと調べるとブラウザで動くターミナルを作成するのは簡単だよという内容の blog ブラウザでTerminal実装してみたら簡単だった - tmytのらくがき を見つけた。 これに簡単な form 認証を組み合わせて作ってみることにした。認証を行う経路の安全性についてはHTTPSのみでしかページを見せないことでよいことにした。

使ったもの

サーバー側

ページの作成と表示

クライアントとの通信

socket.io の設定で一番はまったかもしれない。

パスワード処理

パスワードは、htpasswd の形式で SHA1 ハッシュを持つようにした。 passport-local-htpasswd というのもあったのだけど、 なんかうまく動かなくて使わなかった。

クライアント側

できたもの

サーバー側 - node.js

罠だったのが socket.io の設定で、デフォルトでは /socket.io というパスで待っている。/myterm/socket.io というパスで応答させるには、URI ではなく path パラメータを指定しないといけない。 動作確認には、https://example.jp/myterm/socket.io/socketio.js が取れるかを見るのがよいと思う。 認証を追加した以外はほとんど上記の blog のまま。

'use strict'

const serverPort = 8080,
        htpasswd_file = '/somewhere/.htpasswd',
        root_url = '/myterm',
        static_page = '/myterm/static'
const express = require('express'),
        app = express(),
        bodyParser = require('body-parser'),
        cookieParser = require('cookie-parser'),
        expressSession = require('express-session'),
        htpasswd = require('htpasswd-js'),
        passport = require('passport'),
        path = require('path'),
        LocalStrategy = require('passport-local').Strategy,
        pty = require('node-pty'),
        server = require('http').createServer(app),
        socketIO = require('socket.io');

passport.use(new LocalStrategy(
        function(user, passwd, done) {
                htpasswd.authenticate({
                        username: user,
                        password: passwd,
                        file: htpasswd_file
                })
                .then((result) => {
                        if(result){ return done(null, user); }
                        else { return done(null, false); }
                })
        }
));

passport.serializeUser((user, done) => {
        done(null, user);
});
passport.deserializeUser((user, done) => {
        done(null, user);
});

app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(expressSession({
        secret: 'xxxxxxx',
        resave: false,
        saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(static_page, express.static('static'));

app.get(root_url, (req, res) => {
        if(!req.user){
                res.render('login');
        }
        else{
                res.render('terminal');
        }
});

app.post(root_url,
        passport.authenticate('local', {
                failureRedirect: root_url,
                successRedirect: root_url
        }));

let io = new socketIO(server, {path: '/myterm/socket.io/'});
io.on('connect', socket => {
        let term = pty.spawn('/bin/tcsh', [], {
                name: 'xterm-color',
                cols: 80,
                rows: 30,
                cwd: process.env.HOME,
                env: process.env
        });
        term.on('data', d => socket.emit('data', d));
        socket.on('data', d => term.write(d));
        socket.on('disconnect', () => term.destroy());
});

server.listen(serverPort, 'localhost');
client 側 - express + pug で返すページ
doctype html
html
  head
    title='My Terminal'

    link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/xterm/3.12.0/xterm.min.css')
  body
    #terminal
    script(src='https://cdnjs.cloudflare.com/ajax/libs/xterm/3.12.0/xterm.min.js')
    script(src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js')
    script.
      var term = new Terminal();
      var socket = io('https://example.jp/', {path: '/myterm/socket.io/'});
      term.open(document.getElementById('terminal'));
      term.on('data', d => socket.emit('data', d));
      socket.on('data', d => term.write(d));
doctype html

head
  title= 'My terminal login'
body
  form(method='POST' action='/myterm')
    div.form-group
      label(for='username') User:
      input#username.form-control(type='text' name='username')
    div.form-group
      label(for='password') Password:
      input#password.form-control(type='password', name='password')
    button.btn.btn-primary(type='submit') login

これを localhost (127.0.0.1 か ::1) port 8080 でユーザー権限で動かす。

ログアウトを実装していないことに今気づいた。

リバースプロキシ (h2o の設定)

表でサービスしている httpdh2o なので、これでリバースプロキシの設定を行う。 幸い、socket.io が使う websocket の転送にも対応している。 proxy.reverse.urlproxy.websocket ディレクティブを使えばよい。 あとは、つくりがつくりなので TLS 接続でないと怖くて使えない。http は https にリダイレクトする設定がいる。 ちょっと罠があって、サンプルの設定ファイルをぱくるとけっこう強い Content-Security-Policy がつく設定になっている。 上で書いた terminal.pug と login.pug では javascript ライブラリは CDN から持ってくるようにしているので、 それが動く設定を加えないといけない。 というわけで、header.set も追加する。 以上をごにょごにょした結果、設定ファイル h2o.conf に加える内容は次のとおり。

hosts:
  "example.jp:80":
    paths:
      "/myterm":
        redirect:
          status: 301
          url: "https://example.jp/myterm"

  "example.jp":
    paths:
      "/myterm":
        proxy.reverse.url: "http://127.0.0.1:8080/myterm"
        header.set: "Content-Security-Policy: default-src 'self' cdnjs.cloudflare.com 'unsafe-inline'; script-src 'self' cdnjs.cloudflare.com 'unsafe-inline'"
      "/myterm/socket.io/":
        proxy.reverse.url: "http://127.0.0.1:8080/myterm/socket.io/"
        proxy.preserve-host: on
        proxy.websocket: on