Menu

메모용 개발 블로그

전체보기 > 개발일기 >

원격 WOL API 서버 개발기

2024-05-22 14:12:53

WOL?

Wake On Lan의 약자로 네트워크 카드에서 특정 신호를 수신하면 컴퓨터를 부팅할 수 있게 하는 기능입니다.

여러가지 제약 조건이 있지만 컴퓨터와 다른 공간에 있어도 컴퓨터를 켤 수 있다라는 점은 필요할 경우 매력적인 기능입니다.

그러나 몇 가지 제약 사항이 존재하는데.

메인보드/OS 마다 바이오스 혹은 OS에서 활성화해줘야 하는 구성들이 존재하며, 네트워크 카드(메인보드 내장 네트워크)가 신호를 받아야 하므로 컴퓨터가 부팅은 안되어 있어도 콘센트에 연결되어 최소 전류가 흘러야 합니다.

또한, 네트워크 상으로 WOL 신호를 보내줄 기기가 동일한 망에 존재하여야 합니다. 이는 WOL 신호를 브로드캐스트(컴퓨터가 꺼져있으므로 아이피가 없고 맥 주소만 구분 가능)로 보내야 하기 때문입니다.

이런걸 왜?

외부에서 가끔 데스크탑의 성능이나 OS를 활용해야하는 경우가 있습니다.

그러나 항상 켜둘 경우 자원의 낭비가 발생합니다.

필요할땐 켜고 필요없으면 끌 수 있었으면 했습니다.

요즘 나오는 공유기에는 WOL 기능이 존재하여 공유기 페이지를 외부 공개하거나 앱을 사용하면 되지만 제 공유기는 그러한 기능이 없었으며, 다른 서비스에 붙이기를 원했습니다.

그래서 API를 통하여 WOL 신호를 내부 네트워크에 켜져있는 기기에서 보내줄 수 있기를 원하였습니다.

계획

서비스 -> API 서버 -> WOL 서버

이러한 순서로 호출하는 구조로 개발하였습니다.

왜?

웹 서버에서 수신을 받고 바로 그 프로세스에서 WOL 요청을 보내면 가장 간단하겠지만, 디스코드 봇이나 웹 페이지 등 다양한 곳에서 실행할 수 있기를 원하였습니다.

또한 기존에 도커를 켜고 끄는 기능을 하기 위해서 API 서버를 만들어놓았으므로 여기에 해당 기능이 붙었으면 하였습니다.

그래서 API 서버에 기능을 추가하려하였으나,

그러나...

API 서버는 Docker 환경에서 구동되고 있었으며 네트워크가 외부와 분리되어 있는 환경이였습니다.

WOL 패킷은 브로드캐스트로 Host 기기가 속한 네트워크에서 보내야 합니다.

그럼 그냥 API 서버를 호스트에서 구동하는 간단한 방법이 있지만 포트를 하나 더 쓰기에는 이것저것 켜고 끄고 방화벽에 외부에서 연결하려면 포트포워딩에 관리하기 너무나 귀찮았습니다. 그저 외부에 80번 443번 포트만 열어놓고 쓰고 싶었던 것 이죠.

그리하여 WOL 기능을 하는 서버를 별도로 분리하고 API 서버가 또 API 서버를 호출하는 구조로 변경하였습니다.

이렇게 하면 WOL 서버는 도커 호스트 네트워크 모드로 올려서 간단한 관리를 할 수 있고 WOL 패킷 송신도 가능합니다.

그런데 이렇게 하면 WOL 서버도 API 서버로부터 요청을 받으려면 포트 하나를 점유해야 하는 것 인데. 되도록 포트를 적게 쓰고 싶었기에 네트워크를 통한 통신이 아닌 Unix Domain Socket을 이용하는 것으로 하였습니다.

개발

API 서버는 Go + Fiber를 활용하여 이미 구동중인 상태였고, WOL 서버만 간단하게 작성하고 API 서버는 단순히 호출만 해주면 되는 것으로 간단한 개발입니다.

개발 언어 선정은 난이도 대비 적은 자원을 소모할 Go 언어로 선정하였습니다.

마침 Go 1.22에 net/http 패키지 업데이트로 언어 기본 패키지가 편하게 개선되었기에 기본 패키지로 간단하게 개발하는 것으로 정하였습니다.

go.mod 파일이 아주 심플하죠?

module wol-server

go 1.22.2

물론 WOL 패킷을 보내주는 훌륭한 라이브러리가 존재하였지만 의존성이 Go 밖에 없는 심플함을 지키고 싶었기에 WOL 패킷 구조를 확인해봅니다.

  • UDP
  • 9 번 포트
  • 1로 채운 비트 48자리 헤더
  • 맥 주소 (48자리 비트) 16번 반복 바디

이렇게만 만족해서 브로드캐스팅(255.255.255.255)을 쏴주면 되므로 그냥.. 그대로 충실하게 작성하였습니다.

0xFF x 6 + 맥주소 x 16

wol.go

package main

import (
	"errors"
	"net"
)

// SendWOLPacket WOL 패킷을 전송해주는 메소드
func SendWOLPacket(macAddr string) error {
	addr, err := net.ResolveUDPAddr("udp", "255.255.255.255:9")
	dg := []byte{}

	if err != nil {
		return errors.New("브로드캐스트 주소 객체 생성 중 오류 발생")
	}

	conn, err := net.DialUDP("udp", nil, addr)

	if err != nil {
		return errors.New("UDP 요청 객체 생성")
	}

	defer conn.Close()

	hwAddr, err := net.ParseMAC(macAddr)

	// MAC 주소 오류
	if err != nil {
		return errors.New("MAC 주소 파싱 오류: " + macAddr)
	}

	// header
	for i := 0; i < 6; i++ {
		dg = append(dg, 0xFF)
	}

	// body
	for i := 0; i < 16; i++ {
		dg = append(dg, hwAddr...)
	}

	size, err := conn.Write(dg)

	// 잘못된 요청
	if err != nil {
		return errors.New("요청 오류")
	}

	// 요청 길이 오류(wol 패킷 양식에 맞지 않음)
	if size != 102 {
		return errors.New("요청의 패킷 양식이 맞지 않습니다")
	}

	return nil
}

이어서 API 서버와 통신할 WOL API를 HTTP로 작성하였습니다.

main.go

package main

import (
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
)

// SockFilePath unix socket path
var SockFilePath string = "/var/run/wol-server/wol-server.sock"

func main() {
	socket, err := net.Listen("unix", SockFilePath)
	if err != nil {
		log.Fatal(err)
	}

	// Cleanup the sockfile.
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
	go func() {
		<-c
		os.Remove(SockFilePath)
		os.Exit(1)
	}()

	mux := http.NewServeMux()

	mux.HandleFunc("POST /wol/{macAddr}", func(w http.ResponseWriter, r *http.Request) {
		log.Println("WOL request recived: " + r.PathValue("macAddr"))
		SendWOLPacket(r.PathValue("macAddr"))
	})

	server := http.Server{
		Handler: mux,
	}

	err = server.Serve(socket)

	if err != nil {
		log.Fatalln(err)
	}
}


유닉스 도메인 소켓을 통해서 요청 대기를 하도록 작성하였습니다.

이제는 별도의 mux를 추가안해도 POST /wol/{macAddr}처럼 경로명을 인자로 쉽게 받고 메소드까지 제한할 수 있다는 점이 좋습니다.

남은 것은 서버에서 호출입니다.

서버가 유닉스 도메인 소켓으로 대기하고 있으므로 클라이언트(API 서버) 역시 유닉스 도메인 소켓을 통하여야 합니다.

wolService.go

package v1

import (
	"context"
	"net"
	"net/http"

	"github.com/gofiber/fiber/v2"
)

type WOLResponse struct {
	Ok bool `json:"ok"`
}

// WOLService godoc
//
//	@Summary		Send WOL Packet
//	@Description	WOL 패킷으로 컴퓨터를 깨웁니다.
//	@Tags			WOL
//	@Accept			json
//	@Param			Authorization	header		string	false	"인증 키"
//	@Success		200				{object}	v1.WOLResponse
//	@Param			mac_addr		path		string	true	"맥 주소"
//	@Router			/api/v1/wol/{mac_addr} [post]
func WOLService(c *fiber.Ctx) error {
	client := http.Client{
		Transport: &http.Transport{
			DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
				return net.Dial("unix", "/var/run/wol-server/wol-server.sock")
			},
		},
	}

	_, err := client.Post("http://unix/wol/"+c.Params("mac_addr"), "application/json", nil)

	if err != nil {
		c.JSON(WOLResponse{
			Ok: false,
		})
		return err
	}

	c.JSON(WOLResponse{
		Ok: true,
	})

	return nil
}

어짜피 혼자만 사용하는 서버이므로 요청할 WOL는 응답같은 것은 작성하지 않았으므로 그냥 내가 잘보냈으면 Ok를 해줍니다.

혼자 쓴다지만 API이므로 Swagger를 이용해서 문서도 작성해주었습니다.

배포

배포는 그냥 도커로 통일하였으며, GitLab에 저장소가 위치하므로 GitLab Runner를 통하여 배포도 작성하였습니다.

.gitlab-ci.yml

stages:
  - build
  - release
  - deploy-service

.if-default-deploy: &if-default-deploy
  if: ($CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_REF_NAME =~ "/gitlab-ci/") && $CI_PIPELINE_SOURCE != 'merge_request_event'

Build:
  stage: build
  image: golang:1.22.2-alpine
  rules:
    - <<: *if-default-deploy
  script:
    - go build
        -o wol-server
        -ldflags="-s
                  -w"
  cache:
    paths:
      - .go/pkg/mod/
  artifacts:
      paths:
        - ./wol-server
      expire_in: 1 week
  tags:
    - docker

Release docker image:
  stage: release
  image: docker:20.10.16
  rules:
    - <<: *if-default-deploy
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
  script:
    - docker pull $CI_REGISTRY_IMAGE:latest || true
    - docker build --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:latest
  resource_group: registry
  dependencies:
    - Build
  tags:
    - docker-host

Deploy Server:
    stage: deploy-service
    image: docker:20.10.16
    rules:
    - <<: *if-default-deploy
    before_script:
      - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
    script:
      - docker stop $CI_PROJECT_NAME || true
      - docker rm $CI_PROJECT_NAME || true
      - |
        docker run \
          --name=$CI_PROJECT_NAME \
          --network=host \
          -v /srv/wol-server/socks:/var/run/wol-server \
          --restart=unless-stopped \
          -d \
          $CI_REGISTRY_IMAGE:latest
    resource_group: deploy
    tags:
      - docker-host

GitLab에서 도커 레지스트리도 제공하므로 배포에 관한 모든 것을 GitLab에 의존합니다.

스크립트만 요약해서 보면

go build -o wol-server -ldflags="-s -w"

wol-server 파일명으로 빌드를 합니다. 링커 플래그로 각종 불필요한 것을 빼도록 하여 용량을 절약해줍니다.

docker pull $CI_REGISTRY_IMAGE:latest || true
docker build --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:latest .
docker push $CI_REGISTRY_IMAGE:latest

도커 이미지를 빌드하고 올립니다.

이미지 버전따위는 저에게 필요없으므로 latest로 통일합니다.

$CI_REGISTRY_IMAGE는 GitLab에서 제공하는 레지스트리 이미지 변수 입니다.

docker stop $CI_PROJECT_NAME || true
docker rm $CI_PROJECT_NAME || true
docker run --name=$CI_PROJECT_NAME \
          --network=host \
          -v /srv/wol-server/socks:/var/run/wol-server \
          --restart=unless-stopped \
          -d \
          $CI_REGISTRY_IMAGE:latest

기존 배포된 컨테이너를 중지, 삭제하고 새로운 컨테이너를 받아서 실행합니다. 중지, 삭제는 컨테이너가 없을 경우 에러 출력을 낼 수도 있으므로 || true를 붙여 에러가 없도록 하였습니다.

--network=host 호스트 네트워크 모드로 구동

-v /srv/wol-server/socks:/var/run/wol-server 유닉스 도메인 소켓 파일을 꺼냅니다. (API 서버에서 사용할 수 있어야 합니다.)

--restart=unless-stopped docker stop 하지 않는 이상 무한 재시작

-d 백그라운드에서 실행

마찬가지로 API 서버에서도 -v 옵션을 통해 도메인 소켓을 연결해주도록 변경하고 실행합니다.

실사용

우선 디스코드 봇에다가 추가하였습니다.

image-20240522230846421

그리고 컴퓨터는 몹시 잘 켜졌습니다!

후기

개발 노력은 적게 들고 만족도는 좋았습니다.