😎
sshpiperでSSHプロキシを構築する - (Lua + MySQL) APIによる動的ユーザー管理
はじめに
sshpiperはSSHリバースプロキシで、クライアントからのSSH接続を動的に別のサーバーへルーティングできます。本記事では、Luaプラグインと自作APIを組み合わせて、MySQLでユーザー情報を管理する構成を紹介します。
システム構成
┌─────────────┐ ┌──────────────┐ ┌────────────┐
│ Client │────▶│ sshpiper │────▶│ upstream │
│ │ │ (Lua) │ │ (SSH) │
└─────────────┘ └──────┬───────┘ └────────────┘
│
┌──────▼───────┐
│ Flask API │
└──────┬───────┘
│
┌──────▼───────┐
│ MySQL │
└──────────────┘
認証フロー
- クライアントがsshpiperにパスワード認証でSSH接続
- sshpiperのLuaスクリプトがAPIを呼び出してパスワード検証
- 認証成功時、sshpiperが専用の秘密鍵でupstreamサーバーに接続
- クライアントのパスワードは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