Chrome 확장 프로그램으로 외부 프로그램과 통신하여 PC 정보 추출하기

멀티브라우저를 지원하면서 IE에서는 ActiveX를 통해 추가 편의 기능을 제공하던 중, IE 외 브라우저에서도 해당 편의기능을 추가해 달라는 요청이 있어 살펴본 내용을 공유한다. 이 글에서는 해당 기능이 사용자 PC의 hostname을 추출하는 기능이라고 가정한다. Chrome 브라우저의 자체 API로 직접 PC의 hostname을 추출할 수 있는 방법이 없다. 꼭 해야 한다면 크게 두 가지 방법으로 구현이 가능하다.

  1. PC에 웹서버 데몬을 설치/실행하고 XHR 또는 WebSocket을 통해 해당 데몬과 통신
  2. PC에 프로그램을 설치하고 Chrome 확장 프로그램의 Native Messaging 기능을 통해 프로그램 실행/통신

1번 방법은 소위 말하는 ‘EXE 방식’ 금융기관 보안 프로그램에서 사용하는 방법이다. 1번 방법에 대해서는 다음에 기회가 되면 따로 작성하고, 여기에서는 2번 방법에 살펴볼 것이다. 2번 방법은 크게 두 부분으로 나눌 수 있다.

확장 프로그램 → 실행 프로그램

먼저 위 흐름의 뒷 부분인 확장 프로그램 → 실행 프로그램부터 살펴보자. Chrome 확장 프로그램 문서의 Native Messaging 페이지 하단에서 sample appsample host를 다운로드 받을 수 있다. sample app은 확장 프로그램이고, sample host는 실행 프로그램이다.

먼저 sample app에서 manifest.json을 열고 아래처럼 app 부분을 주석처리 하자. 그래야 Chrome이 확장 프로그램으로 인식한다. 제거하지 않으면 Chrome 앱으로 인식하는데, Chrome 앱은 2020년 6월부로 공식 지원이 종료되었다.

{
  // Extension ID: knldjmfmopnpolahpmmgbagdohdnhkik
  "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcBHwzDvyBQ6bDppkIs9MP4ksKqCMyXQ/A52JivHZKh4YO/9vJsT3oaYhSpDCE9RPocOEQvwsHsFReW2nUEc6OLLyoCFFxIb7KkLGsmfakkut/fFdNJYh0xOTbSN8YvLWcqph09XAY2Y/f0AL7vfO1cuCqtkMt8hFrBGWxDdf9CQIDAQAB",
  "name": "Native Messaging Example",
  "version": "1.0",
  "manifest_version": 2,
  "description": "Send a message to a native application.",
  // "app": {
  //   "launch": {
  //     "local_path": "main.html"
  //   }
  // },
  "icons": {
    "128": "icon-128.png"
  },
  "permissions": [
    "nativeMessaging"
  ]
}

그 뒤 Chrome 확장 프로그램 페이지인 chrome://extensions 을 열고 Developer mode를 켠 뒤, manifest.json이 들어있는 폴더를 드래그 해 넣으면 확장 프로그램이 설치된다.

설치된 확장 기능의 모습

이 확장 프로그램은 main.html 페이지를 가지고 있는데 chrome-extension://knldjmfmopnpolahpmmgbagdohdnhkik/main.html 에서 볼 수 있다.

설치된 확장 프로그램 페이지에서 Connect 버튼을 눌렀을 때 발생하는 오류

샘플 확장 프로그램 페이지에 들어가서 Connect 버튼을 눌러도 아래처럼 에러만 나올텐데 아직 PC에 host 프로그램을 설치하지 않기 때문에 당연히 발생하는 오류이다. Connect 버튼을 눌렀을 때 확장 프로그램에서 수행되는 동작은 main.js를 살펴보면 되는데, 자세한 부분은 Native Messaging 문서를 참고하면 된다.

host 프로그램은 위에서 다운로드 받은 sample host를 활용하면 된다. Windows를 기준으로 보면 이 샘플은 아래와 같이 구성되어 있다.

  • Manifest: com.google.chrome.example.echo-win.json
  • 레지스트리 등록 batch file: install_host.bat
  • 레지스트리 제거 batch file: uninstall_host.bat
  • Python 실행 프로그램: native-messaging-example-host
  • Python 실행 프로그램을 실행해주는 batch file: native-messaging-example-host.bat

Manifest 파일에 프로그램 이름, 경로, 접근가능한 확장 프로그램 ID 등을 정의하고, 레지스트리 등록 bat 파일로 약속된 레지스트리 경로에 Manifest 파일의 경로를 저장하는 것이다. Chrome은 그 경로를 참조하여 프로그램을 실행하고, 그 프로그램과 통신하는 것이다. 샘플은 Python 프로그램을 포함하였는데, 나는 별도로 .NET Core를 가지고 간단한 프로그램을 만들어 사용하였다. Chrome 확장 프로그램과 통신하기 위해서는 메시지가 특정 프로토콜을 따라야 하는데, 샘플에 포함된 Python 프로그램에도 해당 부분이 구현되어 있다.

내가 사용한 .NET Core 3.1 기반 프로그램은 1회 PC의 hostname을 응답하는 프로그램으로, https://github.com/alexwiese/Lyre를 활용해 아래와 같이 구현하였다. Windows 10은 .NET 4.5가 기본적으로 설치되어 있으므로, Windows 10 대상으로 배포해야 한다면 .NET 4.5를 사용하면 편리하다.

using System;
using System.Threading.Tasks;
using Lyre;

namespace dotnet_console_test
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var host = new NativeMessagingHost();
            try
            {
                await host.Read<dynamic>();
                await host.Write(new { hostName =  $"{Environment.MachineName}"});
            }
            catch (Exception exception)
            {
                await host.Write(new { error = $"{exception}"});
            }
        }
    }
}

기본 Python 프로그램을 사용하지 않을 것이기 때문에 manifest 파일인 com.google.chrome.example.echo-win.json의 path 부분도 아래처럼 수정해 주었다.

{
  "name": "com.google.chrome.example.echo",
  "description": "Chrome Native Messaging API Example Host",
  "path": "dotnet-console-test.exe",
  "type": "stdio",
  "allowed_origins": [
    "chrome-extension://knldjmfmopnpolahpmmgbagdohdnhkik/"
  ]
}

이제 install_host.bat를 실행하면 manifest가 레지스트리에 등록되고, 설치한 확장 프로그램과 통신 할 수 있다. 오류가 났던 확장 프로그램 페이지에 다시 들어가 Connect 버튼을 누르면 텍스트박스가 나오고, 아무거나 입력한 뒤 Send 버튼을 누르면 아래처럼 PC의 설정된 프로그램이 응답한 값을 확인할 수 있다.

내가 사용한 프로그램은 실행된 뒤 확장 프로그램에서 메시지가 올 때 까지 기다리고, 메시지가 오면 hostname을 응답하고 즉시 종료된다. 그렇기 때문에 마지막에 Failed to connect: Native host has exited와 같은 메시지가 표시되는 것이다.

Javascript → 확장 프로그램

이제 Javascript에서 확장 프로그램을 불러 통신을 하는 부분을 구현해 볼 것이다. 상세한 정보는 Chrome 확장 프로그램 Message Passing 문서를 참조하면 된다. 로컬 웹서버에 올린 샘플 HTML 문서를 사용할 것이기에 먼저 확장 프로그램의 manifest에 externally_connectable을 추가하여 localhost 페이지에서 이 확장 프로그램과 통신할 수 있다고 설정해 준다. 또한, 사용자가 확장 프로그램 페이지를 열고 있지 않아도 통신이 되어야 하기 때문에 background 항목도 추가한다. 이 background 항목에 대한 자세한 설명은 Chrome 확장 프로그램 Manage Events with Background Scripts 문서를 참조하면 된다.

{
  // Extension ID: knldjmfmopnpolahpmmgbagdohdnhkik
  "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcBHwzDvyBQ6bDppkIs9MP4ksKqCMyXQ/A52JivHZKh4YO/9vJsT3oaYhSpDCE9RPocOEQvwsHsFReW2nUEc6OLLyoCFFxIb7KkLGsmfakkut/fFdNJYh0xOTbSN8YvLWcqph09XAY2Y/f0AL7vfO1cuCqtkMt8hFrBGWxDdf9CQIDAQAB",
  "name": "Native Messaging Example",
  "version": "1.0",
  "manifest_version": 2,
  "description": "Send a message to a native application.",
  // "app": {
  //   "launch": {
  //     "local_path": "main.html"
  //   }
  // },
  "icons": {
    "128": "icon-128.png"
  },
  "permissions": [
    "nativeMessaging"
  ],
  "externally_connectable": {
    "matches": ["*://localhost/*"]
  },
  "background": {
    "scripts": ["main.js"],
    "persistent": false
  }
}

확장 프로그램 main.js도 대폭 수정할 것이다. 샘플 프로그램에 있는 main.js는 main.html을 사용자가 직접 열어 상호작용한다는 가정 하에 작성되어 있으므로 사용자의 입력을 받고, DOM 객체를 조작하는 등 여러 기능이 존재하는데 우리는 외부 Javascript ↔ 확장 프로그램 통신을 할 것이기에 그런 기능들이 필요가 없다. 그래서 아래처럼 main.js를 수정했다. 수정이라기 보다는 그냥 모두 지우고 onMessageExternal 이벤트 리스너만 추가했다고 보면 된다. onMessageExternal를 통해 request.command가 getHostname인 외부 메시지를 받게 되면 sendNativeMessage로 PC의 프로그램을 실행하고, 그 프로그램에서 온 답변을 sendResponse 함수를 통해 외부로 응답하는 구조이다. 만약 request.command가 getHostname이 아니면 그냥 UNKNOWN COMMAND를 응답한다.

document.addEventListener('DOMContentLoaded', function () {
  var nativeHostAppName = "com.google.chrome.example.echo";
  chrome.runtime.onMessageExternal.addListener(
    function (request, sender, sendResponse) {
      if (request.command == "getHostname"){
        chrome.runtime.sendNativeMessage(nativeHostAppName, request, function(responseFromNativeHostApp){
          sendResponse(responseFromNativeHostApp.hostName);
        });
      } else {
        sendResponse("UNKNOWN COMMAND");
      }
    }
  );
});

수정한 manifest.json과 main.js를 담고 있는 폴더를 다시 Chrome 확장 프로그램 화면에 드래그 해 넣어 업데이트 해주면 확장 프로그램의 준비는 완료 된다.

마지막으로 확장 프로그램을 호출하는 부분을 구현하자. 확장 프로그램 호출은 Javascript에서 chrome.runtime.sendMessage를 이용해 간단히 구현할 수 있다. 만약 sendMessage를 활용하여 여러 기능을 구현할 때는 아래처럼 command에 값을 넣어 보내는 방법을 사용할 수 있다.

<!DOCTYPE html>
<html>
<head>
    <title>Title of the document</title>
    <script type="text/javascript">
        document.addEventListener('DOMContentLoaded', function () {
            var extensionId = "knldjmfmopnpolahpmmgbagdohdnhkik";

            chrome.runtime.sendMessage(extensionId, { command: 'getHostname' },
                function (response) {
                    document.getElementById("result1").innerText = response;
                });
            chrome.runtime.sendMessage(extensionId, { command: 'getSomethingElse' },
                function (response) {
                    document.getElementById("result2").innerText = response;
                });
        });
    </script>
</head>
<body>
    <div id="result1"></div>
    <div id="result2"></div>
</body>
</html>

이 HTML 파일을 localhost 주소에서 열면, 아래처럼 응답이 표시된다. getHostname과 getSomethingElse 두 가지 command를 각각 호출했으므로 하나는 제대로 된 응답이, 하나는 UNKNOWN COMMAND 응답이 오는 것을 볼 수 있다.

사용한 소스코드는 모두 글 내용에 포함되어 있지만 별도로 받길 원한다면 https://github.com/iamxeph/chrome-extension-nativemessaging-sample에서 받을 수 있다.

2 thoughts on “Chrome 확장 프로그램으로 외부 프로그램과 통신하여 PC 정보 추출하기”

  1. 안녕하세요 좋은 정보 감사드립니다. C#을 잘 몰라서요.
    질문드립니다. dotnet-console-test.exe 을 만드는 과정을 알 수 있을까요?
    제가 만들면 exe파일 크기가 아주 작고 정상작동을 하지 않습니다.
    아무래도 생성방법이 잘못된거 같아서요. 시간 되시면 부탁드리겠습니다.
    ps : 글내용은 hostname 추출인데 저는 맥 어드레스를 추출하고 싶습니다.
    그래서 dotnet-console-test.exe 파일을 수정하려고 합니다.

    Reply
    • 안녕하세요. 위 글의 마지막에 링크한 github 소스가 누락되어 있어 방금 수정 하였으니 다시 확인해 보시기 바랍니다. .NET Framework 4.5를 사용하실건지, .NET Core 3.1을 사용하실건지에 따라 빌드 방법이 조금 달라지는데요. 이 글에서는 .NET Core 3.1을 사용하였고, 빌드 시 아래 명령어로 빌드하면 하나의 exe 파일로 윈도우64비트에서 실행할 수 있게 빌드가 됩니다.

      dotnet publish -r win-x64 -p:PublishSingleFile=true –self-contained true

      Reply

Leave a Comment