시놀로지 디스크에 대용량 업로드를 여러 원격지에서 하다보니 원격지의 접속 IP들을 화이트리스트 처리하더라도 PC 리부팅 시 IP가 변경될 때마다 새로 기재해야 하는 번거로움 등이 발생하였습니다.
이에 네트워크 드라이브(SMB)로 연결하게 할 VPN 대역을 구축하기로 하였습니다.
VPN용 서브도메인 생성한 뒤 Headscale 용 LXC를 구축해서 NginxProxyGuard로 도메인에 LXC를 연결하겠습니다.
그룹 별로 다른 도메인을 부여했기 때문에 도메인 A, B의 서브 도메인이 두 개 있는 상황입니다.
1번 도메인 (내부인용) ─┐
→ 같은 공유기 공인 IP → NPG → 172.30.1.103:8080 (Headscale)
2번 도메인 (외부인용) ─┘
위 구조로 연결할 예정이니 서로 다른 도메인이더라도 A 레코드 값은 같은 공유기 IP를 바라보고 있습니다.
다만, Headscale 정책 상 config 파일 안에 선언하는 server_url은 한 개의 도메인만 사용할 수 있으니 가능한 외부에 공개되어도 괜찮은 도메인을 메인으로 지정하거나 vpn 전용 도메인을 생성하는 것을 권유드립니다.
apt update -y && apt upgrade -y
apt install -y curl gnupg wget net-tools
cd /usr/local/src
# 다운로드
HEADSCALE_VERSION="0.29.3"
wget --output-document=headscale.deb \
"https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_amd64.deb"
# 설치
apt install ./headscale.deb
# 확인
headscale version
===
headscale version v0.29.3
===
systemctl status headscale
cat /etc/headscale/config.yaml | head -20
===
---
# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order:
#
# - `/etc/headscale`
# - `~/.headscale`
# - current working directory
# The url clients will connect to.
# Typically this will be a domain like:
#
# https://myheadscale.example.com:443
#
server_url: http://127.0.0.1:8080
# Address to listen to / bind to on the server
#
# For production:
# listen_addr: 0.0.0.0:8080
listen_addr: 127.0.0.1:8080
===
# 백업
cp -arp /etc/headscale/config.yaml /etc/headscale/config.yaml_ori
# 수정
/etc/headscale/config.yaml_ori
===
# server_url: http://127.0.0.1:8080
server_url: https://$Domain
# listen_addr: 127.0.0.1:8080
listen_addr: 0.0.0.0:8080
tls_cert_path: ""
tls_key_path: ""
===
# 검증
headscale configtest
검증 시 INFO 출력만 되는 건 상관 없습니다. 문제 없을 시 서비스를 재시작합니다.
# 서비스 재시작
systemctl restart headscale
systemctl status headscale
Local Address 값 변경이 잘 되었는지 확인합니다.
netstat -nltp | head -n 1 && netstat -nltp | grep 8080
Headscale 서버 자체는 백엔드(API) 기능만 제공하며 시각적인 웹 화면이 포함되어 있지 않습니다.
아무것도 출력되지 않고
/web같은 특정 경로로 접근 시404 not found가 발생합니다.
조금의 시간 이후 SSL 인증을 적용해줍니다. Let's Encrypt로 새로 생성해주면 됩니다.

외부 기기 특히 모바일 디바이스에서 원활하게 연결되려면 아래 두 가지 사항을 체크해야 합니다.
① 봇 필터 예외 추가
로그 확인 시
Go-http-client/ 2.0으로 초기 진입하고 이후 마무리 연결에서Go-http-client/ 1.1을 사용하였습니다. 둘 다 에이전트 예외 설정을 해주어야 합니다.Go-http-client/2.0 Go-http-client/1.1
② fail2ban 조건 완화
maxretry = 10 findtime = 10m
WEB UI는 기본적인 headscale-ui와 headplane이 있습니다. 더 최근에 개발되어 다양한 기능을 제공하는 headplane으로 진행하겠습니다.
명령어로 WEB에서 연결한 API Key를 생성합니다. 아래 명령어 입력 시 단 한 번만 출력되기 때문에 반드시 별도로 복사해두어야 합니다.
headscale apikeys create --expiration 3650d
cookie_secret 생성headplane은 엄격한 32글자의 문자열을 cookie_secret으로 요구합니다. 실제로 23글자를 넣었다가 에러가 발생하였으니 꼭 아래와 같이 그냥 길이에 맞는 난수를 생성해서 사용하시는 게 좋습니다.
openssl rand -hex 16
===
문자열 복사
===
github에서 확인 시 docker 배포 방식을 사용합니다.
curl -fsSL https://get.docker.com | sh
systemctl enable docker --now
mkdir -p /opt/headplane
만일 여러 위치에 컨테이너를 두는 방식이 싫다면 NginxProxyGuard 안에 컨테이너로 생성하고 웹만 npg LXC에서, 실제 VPN은 headscale LXC에서 운영하는 방법도 가능합니다.
cd /opt/headplane
vi config.yaml
===
# Headplane 설정 파일
server:
host: 0.0.0.0
port: 3000
cookie_secret: "여기에_생성된_정확히_32글자_문자열_입력"
headscale:
url: http://172.30.1.103:8080
===
cd /opt/headplane
vi docker-compose.yaml
===
version: "3"
services:
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: always
ports:
- "3000:3000"
volumes:
- /var/lib/headscale:/var/lib/headscale # Headscale 데이터 경로 (읽기/쓰기)
- /etc/headscale:/etc/headscale # Headscale 설정 경로
- ./config.yaml:/etc/headplane/config.yaml # [추가] 설정 파일 마운트
environment:
- PORT=3000
- COOKIE_SECRET=$여기에_생성된_정확히_32글자_문자열_입력
- HEADSCALE_BACKEND_URL=http://127.0.0.1:8080
- MAGIC_DNS_ENABLED=true
===
docker compose up -d
프록시 호스트 도메인을 http://$IP:3000으로 연결되도록 설정합니다.
브라우저 접근 시 초기 접속의 경우 https://$domain/admin 이렇게 admin 경로로 들어가야 아래와 같이 초기 설정 사이트가 출력됩니다.

Key 입력 후 성공하면 아래와 같이 출력됩니다.
# 필요 패키지 설치
apt install -y jq
# 스크립트 생성
mkdir -p /root/bin/
vi /root/bin/
===
#!/usr/bin/env bash
#
# headscale VPN 계정 생성 자동화 스크립트
# - 아이디/비밀번호를 입력받아 headscale 사용자 + pre-auth key 를 만들고 결과를 출력합니다.
# - 비밀번호는 입력할 때 화면에 보이도록 되어 있습니다.
#
# 사용법: chmod +x create-vpn-account.sh && ./create-vpn-account.sh
#
set -euo pipefail
# ==================== 설정 (환경에 맞게 수정) ====================
# headscale 를 실행하는 방법. LXC 안에서 직접 실행하면 아래 그대로 두세요.
HEADSCALE_CMD=(headscale)
# 예) 호스트에서 LXC 로 실행: HEADSCALE_CMD=(lxc exec <컨테이너명> -- headscale)
# 예) Docker 컨테이너로 실행: HEADSCALE_CMD=(docker exec headscale headscale)
# 클라이언트 접속 안내 문구에만 쓰는 headscale 주소
HEADSCALE_URL="https://headscale.example.com"
# pre-auth key 옵션
KEY_EXPIRATION="24h" # 키 유효기간 (예: 1h, 24h, 7d)
KEY_REUSABLE="false" # 여러 기기에서 재사용 허용: true / false
# 생성 기록을 남길 파일 (평문 저장 → 권한 600). 남기지 않으려면 "" 로 두세요.
RECORD_FILE="${HOME}/headscale-accounts.log"
# ================================================================
# ponytail: headscale JSON 출력을 안전하게 파싱하기 위해 jq 사용.
# (사람이 읽는 표 형식은 버전마다 열 구성이 달라 grep/awk 파싱이 불안정함)
command -v jq >/dev/null 2>&1 || {
echo "오류: 'jq' 가 필요합니다. 설치: sudo apt-get install -y jq" >&2
exit 1
}
# headscale(또는 lxc/docker) 실행 파일 확인
command -v "${HEADSCALE_CMD[0]}" >/dev/null 2>&1 || {
echo "오류: '${HEADSCALE_CMD[0]}' 명령을 찾을 수 없습니다. 스크립트 상단 HEADSCALE_CMD 를 확인하세요." >&2
exit 1
}
# 사용자명으로 headscale 사용자 ID(숫자)를 찾음. 없으면 빈 문자열.
find_user_id() {
"${HEADSCALE_CMD[@]}" users list --output json 2>/dev/null \
| jq -r --arg u "$USERNAME" \
'(if type=="object" then (.users // []) else . end)
| map(select(.name==$u)) | .[0].id // empty' 2>/dev/null || true
}
# ---------- 입력 ----------
echo "================================"
echo " Headscale VPN 계정 생성"
echo "================================"
read -rp "아이디(username): " USERNAME
# 비밀번호를 화면에 보이게 입력받음 (숨기려면 -s 추가: read -rsp)
read -rp "비밀번호(password): " PASSWORD
# ---------- 입력 검증 ----------
# (read 는 앞뒤 공백을 이미 제거함)
if [[ -z "$USERNAME" ]]; then
echo "오류: 아이디가 비어 있습니다." >&2
exit 1
fi
if [[ "$USERNAME" == *@ ]]; then
# headscale 제약: 사용자명은 '@' 로 끝날 수 없음
echo "오류: 아이디는 '@' 로 끝날 수 없습니다." >&2
exit 1
fi
# ---------- 사용자 조회 / 생성 ----------
echo
echo "[1/3] 사용자 확인/생성: $USERNAME"
USER_ID="$(find_user_id)"
if [[ -n "$USER_ID" ]]; then
echo " → 이미 존재하는 사용자입니다 (ID: $USER_ID). 기존 사용자를 사용합니다."
else
"${HEADSCALE_CMD[@]}" users create "$USERNAME" >/dev/null
USER_ID="$(find_user_id)"
[[ -n "$USER_ID" ]] || { echo "오류: 사용자 생성 후 ID 를 찾지 못했습니다." >&2; exit 1; }
echo " → 생성 완료 (ID: $USER_ID)"
fi
# ---------- pre-auth key 발급 ----------
echo "[2/3] pre-auth key 발급 (유효기간: $KEY_EXPIRATION, 재사용: $KEY_REUSABLE)"
key_args=(preauthkeys create --user "$USER_ID" --expiration "$KEY_EXPIRATION" --output json)
[[ "$KEY_REUSABLE" == "true" ]] && key_args+=(--reusable)
KEY_JSON="$("${HEADSCALE_CMD[@]}" "${key_args[@]}" 2>/dev/null || true)"
AUTHKEY="$(printf '%s' "$KEY_JSON" | jq -r '.key // .preAuthKey.key // empty' 2>/dev/null || true)"
if [[ -z "$AUTHKEY" ]]; then
echo "오류: pre-auth key 발급에 실패했습니다. 아래 명령을 직접 실행해 오류를 확인하세요:" >&2
echo " ${HEADSCALE_CMD[*]} ${key_args[*]}" >&2
exit 1
fi
echo " → 발급 완료"
# ---------- 기록 저장 ----------
if [[ -n "$RECORD_FILE" ]]; then
echo "[3/3] 기록 저장: $RECORD_FILE"
umask 077
touch "$RECORD_FILE"; chmod 600 "$RECORD_FILE"
printf '%s | user=%s | id=%s | password=%s | authkey=%s\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" "$USERNAME" "$USER_ID" "$PASSWORD" "$AUTHKEY" >> "$RECORD_FILE"
else
echo "[3/3] 기록 저장 생략"
fi
# ---------- 결과 출력 ----------
cat <<EOF
================================
생성 결과
================================
아이디 : $USERNAME
사용자 ID : $USER_ID
비밀번호 : $PASSWORD
pre-auth key : $AUTHKEY
유효기간 : $KEY_EXPIRATION (재사용: $KEY_REUSABLE)
클라이언트에서 접속:
tailscale up --login-server $HEADSCALE_URL --authkey $AUTHKEY
================================
EOF
[Users → Add user]
https://tailscale.com/download/windows
다운로드한 프로그램을 설치만 하고 로그인이나 기타 과정은 어차피 CMD 명령어로 할 것이므로 필요 없습니다.
Windows 프로그램을 설치 후에 CMD 명령어로 접근해야 인증 서버를 자신의 것으로 바꿀 수 있습니다.
tailscale up --login-server=https://$domain
연결은 잘 되었습니다.
그럼 이제, SMB로 연결해보겠습니다.
시놀로지에도 headscale VPN 연결을 하기 위해 api key와 user를 생성해야 합니다. 재사용할 수 있도록 했습니다.
modprobe tun
ls -al /dev/net/tun
tailscale 설치윈도우 클라이언트 연결과 방식은 동일합니다. 설치 후 명령어로 개인 서버를 지정하여 실행합니다. 다만 패키지 센터는 구형 버전이 있어 설치 후에도 제대로 연결이 되지 않습니다. 따라서 수동 설치해줍니다.
[제어판 → 정보 센터 → DSM 버전]
https://pkgs.tailscale.com/stable/#spks
작업중인 PC에 받아서 불러오면 됩니다.
tailscale update
# 연결 명령어 실행
WEB에서 확인 시 연결이 잘 되었습니다.
[내 PC → 점 세 개 클릭 → 네트워크 드라이브 추가]
계정 정보 입력
연결 완료
https://play.google.com/store/apps/details?id=com.tailscale.ipn
공식 홈페이지 설명은 아래 링크를 참고 바랍니다.
[우측 상단 톱니바퀴 아이콘 클릭 → 점 세 개 클릭 →
Use an auth key클릭 →hskey-로 시작하는 키 값만 입력 →Add account클릭]
연결이 되었습니다. 아직 ACL이나 접근권한이 설정되어 있지 않은 순정 상태이므로 모든 접근자가 노출됩니다.
headscale node list
vi /etc/headscale/config.yaml
===
prefixes:
# 할당 대역
v4: 100.64.0.0/10
# 할당 방식 (sequential = 순차 분배)
allocation: sequential
===

초기 연결 시 IP가 할당되면 해당 IP는 변경되지 않고 이후 연결 시에도 고정으로 할당됩니다.
다만, 순차 분배로 설정되어 있어 연결도 순서대로 해야 한다는 불편함이 있습니다. 이에 이미 연결된 IP를 변경해보겠습니다.
자체 기능으로는 구현되어 있지 않아 장비에 VPN 연결 후 순차 IP를 할당받은 다음 DB(sqlite)에서 할당 받은 IP를 변경하는 방법입니다.
보통은 sqlite로 되어 있습니다. 확인 방법은 설정 파일에서 찾아보는 것입니다.
vi /etc/headscale/config.yaml
===
# database: 로 검색
sqlite:
path: /var/lib/headscale/db.sqlite
===

# sqlite3 설치 명령어
# apt install -y sqlite3
sqlite3 /var/lib/headscale/db.sqlite ".tables"
===
api_keys migrations policies users
database_versions nodes pre_auth_keys
===
조회가 잘 된다면 여기서 작업할 테이블은 nodes입니다. nodes 테이블의 구조만 다시 한 번 확인합니다.
sqlite3 /var/lib/headscale/db.sqlite "PRAGMA table_info(nodes);"
===
0|id|INTEGER|0||1
1|machine_key|TEXT|0||0
2|node_key|TEXT|0||0
3|disco_key|TEXT|0||0
4|endpoints|TEXT|0||0
5|host_info|TEXT|0||0
6|ipv4|TEXT|0||0
7|ipv6|TEXT|0||0
8|hostname|TEXT|0||0
9|given_name|varchar(63)|0||0
10|user_id|INTEGER|0||0
11|register_method|TEXT|0||0
12|tags|TEXT|0||0
13|auth_key_id|INTEGER|0||0
14|expiry|datetime|0||0
15|last_seen|datetime|0||0
16|approved_routes|TEXT|0||0
17|created_at|datetime|0||0
18|updated_at|datetime|0||0
19|deleted_at|datetime|0||0
===
내부 데이터 정보를 조회합니다.
sqlite3 -header -column /var/lib/headscale/db.sqlite \
"SELECT id, hostname, given_name, ipv4, ipv6, deleted_at FROM nodes;"
| 연결 디바이스 ID | 변경 전 할당 IP | 변경 후 할당 IP |
|---|---|---|
| 3 | 100.64.0.3 | 100.64.0.30 |
먼저 변경할 IP인 100.64.0.30 사용 여부를 확인합니다.
# 변경할 IP 사용 여부 확인
sqlite3 -header -column /var/lib/headscale/db.sqlite "SELECT id, hostname, given_name, ipv4 FROM nodes WHERE ipv4 = '100.64.0.30' AND deleted_at IS NULL;"
# 결과 없어야 변경 가능
변경을 위해 VPN 서비스 데몬 종료 및 기존 DB를 백업합니다.
# VPN 서비스 종료
systemctl stop headscale
# DB 백업
cp -arp /var/lib/headscale/db.sqlite \
/var/lib/headscale/db.sqlite.bak.$(date +%Y%m%d_%H%M%S)
이제 IP 정보를 변경합니다.
# 변경
sqlite3 /var/lib/headscale/db.sqlite \
"UPDATE nodes SET ipv4 = '100.64.0.30' WHERE id = 3;"
# 확인
sqlite3 -header -column /var/lib/headscale/db.sqlite "SELECT id, hostname, given_name, ipv4 FROM nodes WHERE ipv4 = '100.64.0.30' AND deleted_at IS NULL;"
# VPN 서비스 가동
systemctl start headscale
이제 위 과정을 간단하게 bash script로 처리할 수 있도록 제작합니다.
vi /root/bin/privIpChg.sh
#!/bin/bash
DB="/var/lib/headscale/db.sqlite"
SERVICE="headscale"
BACKUP="${DB}.bak.$(date +%Y%m%d_%H%M%S)"
# root 확인
if [ "$EUID" -ne 0 ]; then
echo "ERROR: root 권한으로 실행하세요."
exit 1
fi
# sqlite 확인
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "ERROR: sqlite3 명령어가 없습니다."
exit 1
fi
# DB 확인
if [ ! -f "$DB" ]; then
echo "ERROR: DB 파일이 없습니다: $DB"
exit 1
fi
echo
echo "=============================================="
echo " Headscale Node IPv4 변경"
echo "=============================================="
echo
# 1. 등록 정보 출력
echo "[1] 현재 등록된 노드"
echo
sqlite3 -header -column "$DB" \
"SELECT id, hostname, given_name, ipv4, ipv6, deleted_at
FROM nodes
WHERE deleted_at IS NULL
ORDER BY id;"
echo
echo "----------------------------------------------"
# 2. 변경할 노드 선택
read -rp "변경할 Node ID를 입력하세요: " NODE_ID
if ! sqlite3 "$DB" \
"SELECT 1 FROM nodes WHERE id=$NODE_ID AND deleted_at IS NULL;" \
| grep -q 1; then
echo
echo "ERROR: 존재하지 않는 Node ID입니다."
exit 1
fi
echo
echo "[선택한 노드]"
sqlite3 -header -column "$DB" \
"SELECT id, hostname, given_name, ipv4, ipv6
FROM nodes
WHERE id=$NODE_ID;"
CURRENT_IP=$(sqlite3 "$DB" \
"SELECT ipv4 FROM nodes WHERE id=$NODE_ID;")
echo
echo "현재 IPv4 : $CURRENT_IP"
# 3. 변경할 IP 입력
echo
read -rp "변경할 IPv4 주소를 입력하세요: " NEW_IP
# 기본적인 IPv4 형식 검사
if ! [[ "$NEW_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo
echo "ERROR: 올바른 IPv4 형식이 아닙니다."
exit 1
fi
# 각 옥텟 범위 검사
IFS='.' read -r O1 O2 O3 O4 <<< "$NEW_IP"
for OCTET in "$O1" "$O2" "$O3" "$O4"; do
if [ "$OCTET" -gt 255 ]; then
echo
echo "ERROR: 올바른 IPv4 주소가 아닙니다."
exit 1
fi
done
# 현재 IP와 동일한 경우
if [ "$CURRENT_IP" = "$NEW_IP" ]; then
echo
echo "현재 IP와 동일합니다. 변경할 필요가 없습니다."
exit 0
fi
# 4. 변경할 IP 사용 여부 조회
echo
echo "[4] IP 사용 여부 확인"
echo
USED_NODE=$(sqlite3 "$DB" \
"SELECT id FROM nodes
WHERE ipv4='$NEW_IP'
AND deleted_at IS NULL
AND id != $NODE_ID
LIMIT 1;")
if [ -n "$USED_NODE" ]; then
echo "ERROR: $NEW_IP 는 이미 사용 중입니다."
echo
sqlite3 -header -column "$DB" \
"SELECT id, hostname, given_name, ipv4
FROM nodes
WHERE ipv4='$NEW_IP'
AND deleted_at IS NULL;"
exit 1
fi
echo "사용 가능: $NEW_IP"
# 변경 내용 확인
echo
echo "=============================================="
echo " 변경 예정 정보"
echo "=============================================="
echo "Node ID : $NODE_ID"
echo "Hostname : $(sqlite3 "$DB" "SELECT hostname FROM nodes WHERE id=$NODE_ID;")"
echo "현재 IPv4 : $CURRENT_IP"
echo "변경 IPv4 : $NEW_IP"
echo "=============================================="
echo
read -rp "위 내용으로 변경하시겠습니까? (y/N): " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "변경을 취소했습니다."
exit 0
fi
# 5. DB 백업 및 VPN 서비스 종료
echo
echo "[5] DB 백업"
if ! cp -a "$DB" "$BACKUP"; then
echo "ERROR: DB 백업 실패"
exit 1
fi
echo "백업 완료:"
echo "$BACKUP"
echo
echo "Headscale 서비스 종료 중..."
if ! systemctl stop "$SERVICE"; then
echo "ERROR: Headscale 서비스 종료 실패"
exit 1
fi
echo "Headscale 서비스 종료 완료."
# 6. 변경
echo
echo "[6] IPv4 변경"
sqlite3 "$DB" <<EOF
BEGIN TRANSACTION;
UPDATE nodes
SET ipv4 = '$NEW_IP',
updated_at = datetime('now')
WHERE id = $NODE_ID;
COMMIT;
EOF
if [ $? -ne 0 ]; then
echo
echo "ERROR: DB 변경 실패"
echo "백업 파일: $BACKUP"
systemctl start "$SERVICE"
exit 1
fi
# 7. 변경 재확인
echo
echo "[7] DB 변경 결과 확인"
echo
sqlite3 -header -column "$DB" \
"SELECT id, hostname, given_name, ipv4, ipv6
FROM nodes
WHERE id=$NODE_ID;"
CHECK_IP=$(sqlite3 "$DB" \
"SELECT ipv4 FROM nodes WHERE id=$NODE_ID;")
if [ "$CHECK_IP" != "$NEW_IP" ]; then
echo
echo "ERROR: IP 변경 확인 실패"
echo "백업 파일: $BACKUP"
systemctl start "$SERVICE"
exit 1
fi
echo
echo "IP 변경 확인 완료: $CURRENT_IP -> $NEW_IP"
# 8. VPN 서비스 가동
echo
echo "[8] Headscale 서비스 시작"
if ! systemctl start "$SERVICE"; then
echo
echo "ERROR: Headscale 시작 실패"
echo "백업 파일: $BACKUP"
exit 1
fi
sleep 2
if ! systemctl is-active --quiet "$SERVICE"; then
echo
echo "ERROR: Headscale 서비스가 정상적으로 실행되지 않았습니다."
echo "백업 파일: $BACKUP"
systemctl status "$SERVICE" --no-pager
exit 1
fi
echo "Headscale 서비스 정상 가동."
# 9. 최종 노드 연결 리스트
echo
echo "=============================================="
echo "[9] 최종 Headscale Node 목록"
echo "=============================================="
echo
headscale nodes list
echo
echo "=============================================="
echo " 완료"
echo "=============================================="
echo
echo "Node ID : $NODE_ID"
echo "기존 IPv4 : $CURRENT_IP"
echo "변경 IPv4 : $NEW_IP"
echo "DB 백업 : $BACKUP"
echo