😎

sshpiperでSSHプロキシを構築する - (Lua + MySQL) APIによる動的ユーザー管理

に公開

はじめに

sshpiperはSSHリバースプロキシで、クライアントからのSSH接続を動的に別のサーバーへルーティングできます。本記事では、Luaプラグインと自作APIを組み合わせて、MySQLでユーザー情報を管理する構成を紹介します。

システム構成

┌─────────────┐     ┌──────────────┐     ┌────────────┐
│   Client    │────▶│   sshpiper   │────▶│  upstream  │
│             │     │   (Lua)      │     │   (SSH)    │
└─────────────┘     └──────┬───────┘     └────────────┘

                   ┌──────▼───────┐
                   │   Flask API  │
                   └──────┬───────┘

                   ┌──────▼───────┐
                   │    MySQL     │
                   └──────────────┘

認証フロー

  1. クライアントがsshpiperにパスワード認証でSSH接続
  2. sshpiperのLuaスクリプトがAPIを呼び出してパスワード検証
  3. 認証成功時、sshpiperが専用の秘密鍵でupstreamサーバーに接続
  4. クライアントのパスワードはupstreamには透過されない(セキュア)

ディレクトリ構成

sshpiper-test/
├── api/
│   ├── Dockerfile
│   ├── init.sql          # DBスキーマと初期データ
│   ├── requirements.txt
│   └── server.py         # Flask API
├── keys/
│   ├── sshpiper_key      # sshpiper→upstream用秘密鍵
│   └── sshpiper_key.pub  # upstream側に登録する公開鍵
├── lua/
│   └── router.lua        # ルーティングロジック
├── docker-compose.yml
└── sshpiper.Dockerfile

実装

docker-compose.yml

services:
  mysql:
    image: mysql:8.0
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - ./api/init.sql:/docker-entrypoint-initdb.d/init.sql
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10

  api:
    build: ./api
    ports:
      - "5000:5000"
    environment:
      DB_HOST: mysql
      DB_USER: root
      DB_PASSWORD: password
      DB_NAME: sshpiper
    depends_on:
      mysql:
        condition: service_healthy

  sshpiper:
    build:
      context: .
      dockerfile: sshpiper.Dockerfile
    ports:
      - "2222:2222"
    volumes:
      - ./lua:/lua
      - ./keys:/keys:ro
    environment:
      SSHPIPER_API_URL: http://api:5000
    command: >
      --server-key-generate-mode notexist
      /sshpiperd/plugins/lua --script /lua/router.lua
    depends_on:
      - api

  upstream:
    image: ubuntu:22.04
    volumes:
      - ./keys/sshpiper_key.pub:/tmp/sshpiper_key.pub:ro
    command: >
      bash -c "
        apt-get update &&
        apt-get install -y openssh-server &&
        mkdir -p /run/sshd /root/.ssh &&
        chmod 700 /root/.ssh &&
        cat /tmp/sshpiper_key.pub >> /root/.ssh/authorized_keys &&
        chmod 600 /root/.ssh/authorized_keys &&
        sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config &&
        sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config &&
        sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config &&
        /usr/sbin/sshd -D
      "
    expose:
      - "22"

volumes:
  mysql_data:

sshpiper.Dockerfile

FROM alpine:latest

RUN apk add --no-cache ca-certificates curl

WORKDIR /sshpiperd

RUN curl -sL "https://github.com/tg123/sshpiper/releases/latest/download/sshpiperd_with_plugins_linux_arm64.tar.gz" | tar -xz

RUN chmod +x sshpiperd plugins/*

RUN mkdir -p /etc/ssh

EXPOSE 2222

ENTRYPOINT ["/sshpiperd/sshpiperd"]

api/init.sql

CREATE DATABASE IF NOT EXISTS sshpiper;
USE sshpiper;

CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100) NOT NULL UNIQUE,
    password_hash VARCHAR(64) NOT NULL,
    upstream_host VARCHAR(255) NOT NULL,
    upstream_port INT DEFAULT 22,
    upstream_user VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 初期ユーザー(パスワードはSHA256ハッシュ)
-- root: password, testuser: test123
INSERT INTO users (username, password_hash, upstream_host, upstream_port, upstream_user) VALUES
('root', SHA2('password', 256), 'upstream', 22, 'root'),
('testuser', SHA2('test123', 256), 'upstream', 22, 'root');

api/server.py

from flask import Flask, request, jsonify
from http import HTTPStatus
import stat
import mysql.connector
import hashlib
import os

app = Flask(__name__)

# 定数
DEFAULT_SSH_PORT = 22
API_PORT = 5000
DIR_PERMISSION = stat.S_IRWXU
FILE_PERMISSION = stat.S_IRUSR | stat.S_IWUSR
SSHPIPER_ROOT = os.environ.get('SSHPIPER_ROOT', '/var/sshpiper')

def hash_password(password):
    return hashlib.sha256(password.encode()).hexdigest()

def get_db():
    return mysql.connector.connect(
        host=os.environ.get('DB_HOST', 'mysql'),
        user=os.environ.get('DB_USER', 'root'),
        password=os.environ.get('DB_PASSWORD', 'password'),
        database=os.environ.get('DB_NAME', 'sshpiper')
    )

@app.route('/users', methods=['GET'])
def list_users():
    db = get_db()
    cursor = db.cursor(dictionary=True)
    cursor.execute('SELECT * FROM users')
    users = cursor.fetchall()
    db.close()
    return jsonify(users)

@app.route('/users', methods=['POST'])
def create_user():
    data = request.json
    username = data['username']
    password = data['password']
    upstream_host = data['upstream_host']
    upstream_port = data.get('upstream_port', DEFAULT_SSH_PORT)
    upstream_user = data.get('upstream_user', username)

    db = get_db()
    cursor = db.cursor()
    cursor.execute(
        'INSERT INTO users (username, password_hash, upstream_host, upstream_port, upstream_user) VALUES (%s, %s, %s, %s, %s)',
        (username, hash_password(password), upstream_host, upstream_port, upstream_user)
    )
    db.commit()
    db.close()

    return jsonify({'status': 'created', 'username': username})

@app.route('/auth', methods=['POST'])
def authenticate():
    data = request.json
    username = data.get('username')
    password = data.get('password')

    if not username or not password:
        return jsonify({'error': 'username and password required'}), HTTPStatus.BAD_REQUEST

    db = get_db()
    cursor = db.cursor(dictionary=True)
    cursor.execute(
        'SELECT * FROM users WHERE username = %s AND password_hash = %s',
        (username, hash_password(password))
    )
    user = cursor.fetchone()
    db.close()

    if not user:
        return jsonify({'authenticated': False}), HTTPStatus.UNAUTHORIZED

    return jsonify({
        'authenticated': True,
        'upstream_host': user['upstream_host'],
        'upstream_port': user['upstream_port'],
        'upstream_user': user['upstream_user']
    })

@app.route('/users/<username>', methods=['DELETE'])
def delete_user(username):
    db = get_db()
    cursor = db.cursor()
    cursor.execute('DELETE FROM users WHERE username = %s', (username,))
    db.commit()
    db.close()
    return jsonify({'status': 'deleted', 'username': username})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=API_PORT)

lua/router.lua

-- sshpiper lua router with HTTP API backend

local API_URL = os.getenv("SSHPIPER_API_URL") or "http://api:5000"
local PRIVATE_KEY_PATH = "/keys/sshpiper_key"

function read_file(path)
    local file = io.open(path, "r")
    if not file then return nil end
    local content = file:read("*a")
    file:close()
    return content
end

function http_post_json(url, json_data)
    local cmd = string.format("curl -s -X POST -H 'Content-Type: application/json' -d '%s' %s", json_data, url)
    local handle = io.popen(cmd)
    local result = handle:read("*a")
    handle:close()
    return result
end

function parse_auth_response(str)
    local authenticated = str:match('"authenticated"%s*:%s*true')
    if not authenticated then return nil end

    local host = str:match('"upstream_host"%s*:%s*"([^"]+)"')
    local port = str:match('"upstream_port"%s*:%s*(%d+)')
    local user = str:match('"upstream_user"%s*:%s*"([^"]+)"')
    return host, tonumber(port) or 22, user
end

function sshpiper_on_password(conn, password)
    local username = conn.sshpiper_user

    print("[LUA] Password auth for: " .. username)

    if not password or password == "" then
        print("[LUA] No password provided")
        return nil
    end

    -- APIでパスワード認証
    local json_data = string.format('{"username":"%s","password":"%s"}', username, password)
    local response = http_post_json(API_URL .. "/auth", json_data)

    local host, port, upstream_user = parse_auth_response(response)

    if not host then
        print("[LUA] Authentication failed for: " .. username)
        return nil
    end

    print("[LUA] Auth success, routing to: " .. (upstream_user or username) .. "@" .. host .. ":" .. port)

    local private_key = read_file(PRIVATE_KEY_PATH)
    if not private_key then
        print("[LUA] Failed to read private key")
        return nil
    end

    return {
        host = host .. ":" .. port,
        username = upstream_user or username,
        ignore_hostkey = true,
        private_key_data = private_key
    }
end

function sshpiper_on_publickey(conn, key)
    print("[LUA] Public key auth not supported")
    return nil
end

セットアップ

1. SSH鍵ペアの生成

mkdir -p keys
ssh-keygen -t ed25519 -f keys/sshpiper_key -N "" -C "sshpiper"

2. コンテナ起動

docker compose up -d

3. 接続テスト

# 正しいパスワードで接続
ssh -p 2222 root@localhost
# パスワード: password

# 間違ったパスワードは拒否される
ssh -p 2222 root@localhost
# パスワード: wrongpassword → Permission denied

ユーザー管理API

ユーザー一覧

curl http://localhost:5000/users

ユーザー追加

curl -X POST http://localhost:5000/users \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "password": "mypassword", "upstream_host": "upstream"}'

ユーザー削除

curl -X DELETE http://localhost:5000/users/alice

まとめ

sshpiperのLuaプラグインとFlask APIを組み合わせることで、MySQLベースの動的SSHルーティングを実現できました。ユーザー追加・削除がAPI経由で即座に反映され、再起動不要で運用できます。

参考

Discussion