Ctrl K
ComunidadGuías…

server/dev/test-sshd.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 개발용 가짜 SSH 서버 (127.0.0.1 전용). 실제 셸을 열지 않고 흉내만 낸다.
//   비밀번호:    TEST_SSHD_USER / TEST_SSHD_PASSWORD
//   공개키:      data/dev-authorized_keys 에 적힌 키 (TEST_SSHD_USER 로)
//   2단계 인증:  사용자 otp — 비밀번호 + TEST_SSHD_OTP
//   --new-hostkey 로 띄우면 호스트 키가 바뀐다 (지문 불일치 경고 시험용)
//   TEST_SSHD_OS=ubuntu|debian|rocky|alpine|windows 면 그 OS 인 척한다 (인사말 + /etc/os-release — OS 로고 시험용)
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ssh2 from 'ssh2';
import { seedSftpRoot, serveSftp } from './test-sftp.ts';

const { Server, utils } = ssh2;
const dataDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });

const port = Number(process.env.TEST_SSHD_PORT ?? 2222);
const USER = process.env.TEST_SSHD_USER ?? 'demo';
const PASSWORD = process.env.TEST_SSHD_PASSWORD ?? '';
const OTP = process.env.TEST_SSHD_OTP ?? '';
if (!PASSWORD) throw new Error('TEST_SSHD_PASSWORD 를 .env 에 넣어 주세요');

const sftpRoot = path.join(dataDir, 'dev-sftp-root');
fs.mkdirSync(sftpRoot, { recursive: true });
seedSftpRoot(sftpRoot);

const keyFile = path.join(dataDir, 'dev-sshd-hostkey');
if (process.argv.includes('--new-hostkey') || !fs.existsSync(keyFile)) {
  const pair = (utils as unknown as { generateKeyPairSync: (t: string, o: object) => { private: string } }).generateKeyPairSync('ed25519', {
    comment: 'terminas-test-sshd',
  });
  fs.writeFileSync(keyFile, pair.private);
}

// OS 흉내: 인사말(서버 버전 문자열)과, OS 를 묻는 명령에 돌려줄 /etc/os-release
const FAKE_OS: Record<string, { ident: string; release: string }> = {
  ubuntu: { ident: 'OpenSSH_9.6p1 Ubuntu-3ubuntu13.5', release: 'PRETTY_NAME="Ubuntu 24.04.1 LTS"\nNAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n' },
  debian: { ident: 'OpenSSH_9.2p1 Debian-2+deb12u3', release: 'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"\nID=debian\n' },
  rocky: { ident: 'OpenSSH_8.7', release: 'NAME="Rocky Linux"\nID="rocky"\nID_LIKE="rhel centos fedora"\n' },
  alpine: { ident: 'OpenSSH_9.9', release: 'NAME="Alpine Linux"\nID=alpine\n' },
  windows: { ident: 'OpenSSH_for_Windows_9.5', release: '' },
};
const fakeOs = FAKE_OS[process.env.TEST_SSHD_OS ?? ''];

function authorizedKeys() {
  const file = path.join(dataDir, 'dev-authorized_keys');
  if (!fs.existsSync(file)) return [];
  return fs
    .readFileSync(file, 'utf8')
    .split(/\r?\n/)
    .filter((l) => l.trim() && !l.startsWith('#'))
    .map((l) => utils.parseKey(l))
    .filter((k): k is ssh2.ParsedKey => !(k instanceof Error) && !Array.isArray(k));
}

const wide = (ch: string) => /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch);

function runShell(stream: ssh2.ServerChannel, username: string, size: () => { cols: number; rows: number }) {
  let closed = false;
  const out = (s: string) => stream.write(s.replace(/\n/g, '\r\n'));
  const prompt = () => out(`\x1b[1;32m${username}@test-sshd\x1b[0m:\x1b[1;34m~\x1b[0m$ `);
  const exit = () => {
    closed = true;
    out('logout\n');
    stream.exit(0);
    stream.end();
  };
  const exec = (line: string) => {
    const [cmd, ...args] = line.split(/\s+/);
    switch (cmd) {
      case '':
        return;
      case 'help':
        return out('commands: help whoami hostname date echo ls uname size colors seq clear exit\n');
      case 'whoami':
        return out(`${username}\n`);
      case 'hostname':
        return out('test-sshd\n');
      case 'date':
        return out(`${new Date().toString()}\n`);
      case 'echo':
        return out(`${args.join(' ')}\n`);
      case 'ls':
        return out('\x1b[1;34mprojects\x1b[0m  \x1b[1;34mlogs\x1b[0m  notes.txt  \x1b[1;32mdeploy.sh\x1b[0m  한글파일.md\n');
      case 'uname':
        return out('Linux test-sshd 6.8.0-studio x86_64 GNU/Linux\n');
      case 'size': {
        const s = size();
        return out(`${s.cols}x${s.rows}\n`);
      }
      case 'colors': {
        let s = '';
        for (let i = 0; i < 16; i++) s += `\x1b[48;5;${i}m  \x1b[0m`;
        s += '\n';
        for (let i = 16; i < 232; i++) s += `\x1b[48;5;${i}m \x1b[0m${(i - 15) % 36 === 0 ? '\n' : ''}`;
        return out(`${s}\x1b[1mbold\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munderline\x1b[0m \x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m\n`);
      }
      case 'seq': {
        const n = Math.min(Number(args[0]) || 10, 100000);
        const lines: string[] = [];
        for (let i = 1; i <= n; i++) lines.push(String(i));
        return out(`${lines.join('\n')}\n`);
      }
      case 'clear':
        return out('\x1b[2J\x1b[H');
      case 'exit':
      case 'logout':
        return exit();
      default:
        return out(`${cmd}: command not found\n`);
    }
  };

  out(`Terminas test sshd — 진짜 서버가 아닙니다. 'help' 를 입력해 보세요.\n\n`);
  prompt();
  let line = '';
  stream.on('data', (data: Buffer) => {
    const text = data.toString('utf8').replace(/\x1b\[[0-9;?]*[A-Za-z~]|\x1bO[A-Za-z]/g, '');
    for (const ch of text) {
      if (closed) return;
      if (ch === '\r' || ch === '\n') {
        out('\n');
        exec(line.trim());
        line = '';
        if (!closed) prompt();
      } else if (ch === '\x7f' || ch === '\b') {
        const chars = [...line];
        const last = chars.pop();
        if (last) {
          line = chars.join('');
          stream.write(wide(last) ? '\b\b  \b\b' : '\b \b');
        }
      } else if (ch === '\x03') {
        out('^C\n');
        line = '';
        prompt();
      } else if (ch === '\x04') {
        if (!line) exit();
      } else if (ch >= ' ') {
        line += ch;
        stream.write(ch);
      }
    }
  });
}

new Server({ hostKeys: [fs.readFileSync(keyFile)], ...(fakeOs ? { ident: fakeOs.ident } : {}) }, (client) => {
  let username = '';
  client
    .on('authentication', (ctx) => {
      username = ctx.username;
      if (ctx.method === 'password' && ctx.username === USER && ctx.password === PASSWORD) return ctx.accept();
      if (ctx.method === 'publickey' && ctx.username === USER) {
        const match = authorizedKeys().find((k) => k.getPublicSSH().equals(ctx.key.data));
        if (match && (!ctx.signature || match.verify(ctx.blob!, ctx.signature, ctx.hashAlgo) === true)) return ctx.accept();
        return ctx.reject();
      }
      if (ctx.method === 'keyboard-interactive' && ctx.username === 'otp' && OTP) {
        return ctx.prompt(
          [
            { prompt: 'Password: ', echo: false },
            { prompt: 'Verification code: ', echo: true },
          ],
          (answers) => (answers[0] === PASSWORD && answers[1] === OTP ? ctx.accept() : ctx.reject()),
        );
      }
      ctx.reject(['password', 'publickey', 'keyboard-interactive']);
    })
    .on('ready', () => {
      // 포트 포워딩(direct-tcpip): 이 가짜 서버에서는 127.0.0.1 로만 이어 준다
      client.on('tcpip', (accept, reject, info) => {
        if (info.destIP !== '127.0.0.1' && info.destIP !== 'localhost') return reject();
        const sock = net.connect(info.destPort, '127.0.0.1');
        sock.once('error', () => reject());
        sock.once('connect', () => {
          const ch = accept();
          sock.removeAllListeners('error');
          sock.on('error', () => ch.close());
          ch.on('error', () => sock.destroy());
          sock.pipe(ch).pipe(sock);
        });
      });
      client.on('session', (acceptSession) => {
        const session = acceptSession();
        let cols = 80;
        let rows = 24;
        session.on('pty', (accept, _reject, info) => {
          cols = info.cols;
          rows = info.rows;
          accept?.();
        });
        session.on('window-change', (accept, _reject, info) => {
          cols = info.cols;
          rows = info.rows;
          accept?.();
        });
        session.on('shell', (accept) => runShell(accept(), username, () => ({ cols, rows })));
        session.on('sftp', (accept) => serveSftp(accept() as ssh2.SFTPWrapper & NodeJS.EventEmitter, sftpRoot, username));
        session.on('exec', (accept, _reject, info) => {
          const stream = accept();
          if (fakeOs && info.command.includes('/etc/os-release')) {
            stream.write(`${fakeOs.release}Linux\n`);
            stream.exit(0);
            stream.end();
            return;
          }
          stream.write(`exec: ${info.command}\n`);
          stream.exit(0);
          stream.end();
        });
      });
    })
    .on('error', () => {});
}).listen(port, '127.0.0.1', () => {
  console.log(`test sshd listening on 127.0.0.1:${port} (user ${USER})`);
});