cugel.hatenablog.com のその後
動機
ぼかして書くが、アプリケーションゲートウェイを通しているので http、https くらいしか外と通信する手段がなく、ssh が使えない環境で家のマシンに接続してターミナル環境を使いたい。勝手に使われると大惨事なので、認証と通信の安全性も必要という要求がある。
まずはできあいのものから
FreeBSD の ports を探したところ、 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 というのもあったのだけど、 なんかうまく動かなくて使わなかった。
クライアント側
- xterm.js
- socket.io
できたもの
サーバー側 - 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 の設定)
表でサービスしている httpd は h2o なので、これでリバースプロキシの設定を行う。 幸い、socket.io が使う websocket の転送にも対応している。 proxy.reverse.url と proxy.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