회사에서 진행중인 프로젝트에 웹페이지에서 리눅스 서버로 명령어를 보내는 프롬프트 창을 만드는 과제가 생겼다.
세상에 하다하다 웹페이지에서 리눅스서버로 명령어를 보내는것도 만들어야하다니... 그냥 직접 리눅스에서 하면 안되나?
아무튼 까라면 까야하니까 어떻게든 내 방식대로 만들어보았다.
일단 내가 생각한 프로세스는 이렇다.
1. 웹페이지에서 Node.js 웹서버로 커맨드와 서버 url, 포트번호등을 담은 POST 요청을 보낸다.
2. Node.js 웹서버가 ssh 접속 요청이 들어오면 따로 정의해둔 라우터로 라우팅한다. (다른 api 요청도 처리해야해서 분리함)
3. 라우터에서 node-ssh 모듈을 이용하여 리눅스 서버와 연결 후 커맨드를 입력한다.
4. 리눅스 서버가 응답을 보내면 그대로 웹서버로 전달해준다.
내가 지금까지 구현해본건 API 요청을 라우팅하는 정도밖에 없어서 SSH 접속 요청도 API 요청 처리와 비슷하게 만들었다.
(Node.js 도 초보인데 리눅스는 만져본적도 없으니 그냥 되는대로 만들었다ㅎㅎ)
웹페이지
document.querySelector('#sshTest').addEventListener('click', () => {
const cmd = document.querySelector('#sshCommand').value;
const host = document.querySelector('#host').value;
const userName = document.querySelector('#userName').value;
const port = document.querySelector('#port').value;
const logClearChecked = document.querySelector('#logClear').checked;
if (logClearChecked) {
document.querySelector('#result pre').innerHTML = '';
}
fetch(`/ssh/ssh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'command': cmd,
'host': host,
'userName': userName,
'port': port
}),
}).then((res) => res.text())
.then((data) => {
document.querySelector('#result pre').innerHTML += data;
document.querySelector('#result pre').innerHTML += '\n-----------------------------------------------------------------\n';
})
})
여러 리눅스 서버로 요청을 보낼 수 도 있으니 Host name, User Name, Port 를 각각 입력하게 했다.
SSH 테스트 버튼을 누르면 Input 데이터들을 객체로 묶어서 "/ssh/ssh" URL로 POST 요청을 보낸다.
이 때
얘가 체크되어 있으면 <pre> 태그 내부에 이전에 받았던 응답들을 싹 지워버린다. (깔끔하게 보기 위함)
응답이 오면 res.text() 메서드를 이용하여 응답데이터를 문자열로 받은 다음,
<pre>태그에 집어넣어서 보여준다.
Node.js 웹서버 (server.js)
const express = require('express');
const app = express();
app.use('/ssh/ssh', require('./ssh/ssh'));
...
모든 서버로의 요청을 처리하는 메인 server.js 파일이다.
server.js 에 ssh 접속 요청을 처리하는 로직을 작성하면 너무 코드가 길어지기 때문에, 라우팅을 이용하여 분리하였다.
"/ssh/ssh" URL로의 요청이 들어오면 라우팅하여 "./ssh/ssh" 경로의 파일이 처리하도록 한다.
(본격 짬때리기)
ssh.js (라우터)
얘가 실제로 ssh 접속과 커맨드 입력 요청을 보내는 애다.
const express = require('express');
const router = express.Router();
const fs = require('fs');
router.use(express.json());
router.use(express.urlencoded({ extended: true }));
const { NodeSSH } = require('node-ssh');
router.post('/', function (req, res) {
let body = req['body'];
const keyPath = __dirname + `/secret/${body['userName']}.pem`;
const privateKey = fs.readFileSync(keyPath, 'utf8');
const ssh = new NodeSSH();
ssh.connect({
host: body['host'],
username: body['userName'],
port: body['port'],
privateKey: privateKey,
timeout: 5000, // ms
})
.then(() => {
console.log('connected');
ssh.execCommand(body['command'], {}).then(function (result) {
// console.log('결과: ' + result.stdout);
// console.log('에러: ' + result.stderr);
ssh.dispose(); //커넥션 종료
res.send(result.stdout);
});
})
.catch(function (error) {
console.error('Connection failed: ', error);
res.status(500);
res.send();
});
});
module.exports = router;
여기서 node-ssh 모듈을 이용하는데 얘는 미리 npm에서 설치해야한다.
npm install node-ssh
아까 server.js가 라우팅을 걸었으므로 해당 URL("/ssh/ssh")로 post 요청이 들어왔을 경우 처리할 로직을 router.post 안에작성한다.
여기서 keyPath, privateKey라는 이상한 놈이 있을텐데, 얘는 SSH 접속을 할때 필요한 프라이빗 키를 의미한다.
물론 물리서버를 이용한다면 PEM 키가 아닌 패스워드를 입력해서 로그인하겠지만, 클라우드서버를 이용한다면 아마 리눅스 인스턴스에 접속하기 위해 PEM 키를 사용할 것이다. 아님 말고^^
PEM 키란?
SSH를 구성하는 가장 핵심적인 키워드는 ‘KEY(키, 열쇠)’입니다. 사용자(클라이언트)와 서버(호스트)는 각각의 키를 보유하고 있으며, 이 키를 이용해 연결 상대를 인증하고 안전하게 데이터를 주고 받게 됩니다. 여기서 키를 생성하는 방식이 두 가지가 있는데, 그것이 SSH를 검색했을 때 가장 쉽게 볼 수 있는 ‘대칭키’와 ‘비대칭키(또는 공개 키)’ 방식입니다.
– 비대칭키 방식
작동하는 순서를 하나하나씩 짚어보도록 하겠습니다. 가장 먼저 사용자와 서버가 서로의 정체를 증명해야 합니다. 이 시점에서 사용되는 것이 비대칭키 방식입니다. 비대칭키 방식에서는 서버 또는 사용자가 Key Pair(키 페어, 키 쌍)를 생성합니다. 키 페어는 공개 키와 개인 키의 두 가지로 이루어진 한 쌍을 뜻하며, 보통 공개 키의 경우 .pub, 개인 키의 경우 .pem의 파일 형식을 띄고 있습니다.
출처 : 가비아 라이브러리
뭐 아무튼 보안을 위한 키인데, 얘를 같이 보내야 리눅스 서버에서 얘가 접속해도 되는 애인지 아닌지를 판단한다.
사원증 같은거라 생각하면 된다.
아무튼 나는 이 PEM 키를 서버에 secret이라는 폴더에 올려놓고 라우터에서 불러와 사용하고 있다.
물론 server.js 에서 static을 이용하여 secret이라는 폴더에는 임의로 접근할 수 없도록 설정해두었다.
그래도 보안상 좋은 방법 같지는 않지만 다른 방법은 당장 생각나는게 없기도 하고, 내가 만드는 페이지는 회사 내부에서만 쓸거라....보안 이슈가 걱정된다면 다른 방법을 찾아보세용
그 다음 new NodeSSH() 로 ssh 인스턴스를 생성해주고, ssh.connect() 메서드로 접속을 시도한다!
옵션에는 host, username, port ,privateKey, timeout(ms 단위) 를 주고 요청을 보낸다.
자바스크립트의 fetch 메서드랑 비슷하다.
연결이 되면 .then() 메서드가 실행되는데 (Promise 기반이라 then을 사용한다.)
ssh.execCommand(body['command'], {})
.then(function (result) {
console.log('결과: ' + result.stdout);
console.log('에러: ' + result.stderr);
ssh.dispose(); //커넥션 종료
res.send(result.stdout);
});
ssh.execCommand() 메서드로 아까 웹페이지가 보낸 command를 보낸다. 그럼 응답이 오겠지?
응답을 result 객체로 받아서 결과값이 나오면 result.stdout 에 들어가있을거고,
에러가 났다면 result.stderr 에 들어가있을 것이다.
그리고 ssh.dispose() 메서드로 연결을 종료한다.
그리고 res.send()로 결과값을 보내주면 끝!!!
근데 이렇게 하면 웹페이지에서 커맨드 요청을 보낼때마다 접속하고 접속끊고를 반복해서, 연속적인 응답은 받을 수 없다.
당장은 문제가 될게 없어서 해결을 못했는데 이것도 어떻게 해결할지 생각해봐야겠다...
아마 연결을 끊는 특정한 요청을 보냈을때만 ssh.dispose() 메서드를 실행하게 하면 되지않을까 싶다.
마무리
오늘은 node-ssh 모듈을 사용하여 Node.js 서버에서 리눅스 인스턴스로 접속하여 커맨드를 보내보았다.
여전히 위 코드에서도 해결할 점은 많지만 그래도 정상적으로 작동하니 다행....
추후에는 PEM키를 서버에 올려버리는 보안적인 문제도 해결하고 SSH 커넥션을 지속적으로 유지하는 방법을 찾아봐야겠다.