From b38db2357ba7fc8a7a3cb368e1600ed4e44498fc Mon Sep 17 00:00:00 2001 From: "kihwan.song" Date: Mon, 13 Jul 2026 16:57:57 +0900 Subject: [PATCH] =?UTF-8?q?vpn=20=EC=84=B8=ED=8C=85=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- connect-vpn/action.yml | 303 +++++++++++++++++++++++++---------------- 1 file changed, 182 insertions(+), 121 deletions(-) diff --git a/connect-vpn/action.yml b/connect-vpn/action.yml index 29f8e25..77a93f0 100644 --- a/connect-vpn/action.yml +++ b/connect-vpn/action.yml @@ -1,15 +1,15 @@ name: VPN 연결 (F5 BIG-IP) -description: F5 BIG-IP Edge 클라이언트(.deb)를 설치하고 TOTP를 로컬 생성해 VPN에 연결 +description: F5 BIG-IP VPN 클라이언트를 설치하고 웹 로그인/OTP를 자동화해 VPN에 연결 inputs: deb-path: required: false default: "" - description: F5 클라이언트 .deb 경로. 비어있으면 액션 폴더($GITHUB_ACTION_PATH)의 *.deb 자동 탐색 + description: F5 VPN 클라이언트 .deb 경로. 비어있으면 deb-url에서 다운로드 deb-url: required: false - default: "https://gitea.pitap.at/public/commonDeploy/releases/download/setup01/linux_f5cli.x86_64.deb" - description: .deb 다운로드 URL (deb-path가 비어있으면 이 URL에서 다운로드해서 설치) + default: "https://gitea.pitap.at/public/commonDeploy/releases/download/setup01/linux_f5vpn.x86_64.deb" + description: F5 VPN 클라이언트 .deb 다운로드 URL vpn-host: required: true @@ -18,9 +18,8 @@ inputs: required: true description: VPN 사용자 ID vpn-password: - required: false - default: "" - description: VPN 정적 비밀번호 (OTP만 쓰는 설정이면 비워둠) + required: true + description: VPN 정적 비밀번호 otp-seed: required: true description: TOTP 시드 키 (이 값으로 매 실행 OTP 코드를 로컬 생성) @@ -29,75 +28,67 @@ inputs: default: "base64" description: > OTP 시드 포맷. base32=구글OTP류 문자열(대문자/공백 자동 정규화), - hex=16진수 문자열, base64=base64로 인코딩된 키(예 R1F5...== → 디코딩 후 사용). + hex=16진수 문자열, base64=base64로 인코딩된 키. otp-mode: required: false - default: "append" + default: "form" description: > - OTP를 비밀번호 필드에 넣는 방식. - append=정적PW+OTP 붙여쓰기 / replace=OTP만 / none=OTP미사용(정적PW만). - F5 로그온 페이지 설정에 맞게 지정. + 호환성용 입력. 현재 웹 로그인 방식은 password 단계 후 OTP를 ga_code_attempt 폼에 별도 제출합니다. + resource-name: + required: false + default: "/Common/dw_default_na" + description: F5 Network Access 리소스 이름 timeout: required: false - default: "60" + default: "90" description: 연결 완료 대기 최대 시간(초) - connected-pattern: - required: false - default: "session established|Connection established|已连接|connected" - description: > - f5fpc --info 출력에서 '연결됨'으로 판단할 정규식. - 클라이언트 버전/언어에 따라 다르니 `f5fpc --info` 실제 출력에 맞게 조정. runs: using: composite steps: - - name: 의존성/클라이언트 설치 + - name: 의존성/F5 VPN 클라이언트 설치 shell: bash env: - ACTION_PATH: ${{ github.action_path }} DEB_PATH: ${{ inputs.deb-path }} DEB_URL: ${{ inputs.deb-url }} run: | set -eu - # root가 아니면 sudo 사용 SUDO="" [ "$(id -u)" -ne 0 ] && SUDO="sudo" - # oathtool(TOTP 생성) 설치 확인 - if ! command -v oathtool >/dev/null 2>&1; then - if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update && $SUDO apt-get install -y oathtool - else - echo "oathtool 설치 실패: apt 기반 러너가 아님" - exit 1 - fi + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt 기반 Linux 러너가 필요합니다." + exit 1 fi - # 설치할 .deb 결정: deb-path > deb-url > 액션 폴더의 *.deb + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + ca-certificates curl python3 coreutils dbus-x11 xvfb xdotool x11-utils procps iproute2 \ + libxslt1.1 libsqlite3-0 libgl1 libglib2.0-0 libx11-xcb1 libxcb1 \ + libxcb-render0 libxcb-shape0 libxcb-xfixes0 libxcb-shm0 libxrender1 \ + libxi6 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libxtst6 libfontconfig1 libfreetype6 + DEB="" if [ -n "$DEB_PATH" ]; then DEB="$DEB_PATH" elif [ -n "$DEB_URL" ]; then - DEB="/tmp/f5-client.deb" + DEB="/tmp/f5-vpn-client.deb" curl -fsSL -o "$DEB" "$DEB_URL" - else - DEB=$(ls "$ACTION_PATH"/*.deb 2>/dev/null | head -1 || true) fi if [ -z "$DEB" ] || [ ! -f "$DEB" ]; then - echo ".deb를 찾을 수 없습니다 (deb-path/deb-url/액션폴더 확인)" + echo ".deb를 찾을 수 없습니다 (deb-path/deb-url 확인)" exit 1 fi - echo "F5 클라이언트 설치: $DEB" - # dpkg 후 의존성 깨지면 apt-get install -f로 보정 + echo "F5 VPN 클라이언트 설치: $DEB" $SUDO dpkg -i "$DEB" || $SUDO apt-get install -f -y - # f5fpc 존재 확인 - if ! command -v f5fpc >/dev/null 2>&1; then - echo "f5fpc가 설치되지 않았습니다 (.deb 내용/PATH 확인)" + if [ ! -x /opt/f5/vpn/f5vpn ]; then + echo "/opt/f5/vpn/f5vpn 이 설치되지 않았습니다." exit 1 fi @@ -105,8 +96,6 @@ runs: shell: bash run: | set -eu - # VPN 인터페이스 생성에는 /dev/net/tun + NET_ADMIN이 필요합니다. - # 러너 컨테이너 옵션: --cap-add=NET_ADMIN --device /dev/net/tun if [ ! -c /dev/net/tun ]; then echo "/dev/net/tun 이 없습니다. 러너에 --device /dev/net/tun --cap-add=NET_ADMIN 필요" exit 1 @@ -120,97 +109,169 @@ runs: VPN_PASS: ${{ inputs.vpn-password }} OTP_SEED: ${{ inputs.otp-seed }} OTP_SEED_FORMAT: ${{ inputs.otp-seed-format }} - OTP_MODE: ${{ inputs.otp-mode }} + RESOURCE_NAME: ${{ inputs.resource-name }} TIMEOUT: ${{ inputs.timeout }} - CONNECTED_PATTERN: ${{ inputs.connected-pattern }} run: | set -eu - SUDO="" - [ "$(id -u)" -ne 0 ] && SUDO="sudo" + WORKDIR="${RUNNER_TEMP:-/tmp}/f5-vpn-action" + COOKIE_JAR="$WORKDIR/cookies.txt" + LAUNCH_URL_FILE="$WORKDIR/f5-vpn-launch-url.txt" + UA="Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0" + mkdir -p "$WORKDIR" + rm -f "$COOKIE_JAR" "$LAUNCH_URL_FILE" - # TOTP 코드 로컬 생성 (API 불필요) - # 시드 정규화: 앞뒤/중간 공백 제거 - SEED=$(printf '%s' "$OTP_SEED" | tr -d '[:space:]') - case "$OTP_SEED_FORMAT" in - base32) - # Base32는 대문자 필요 → 소문자 자동 변환 - SEED=$(printf '%s' "$SEED" | tr 'a-z' 'A-Z') - OTP=$(oathtool --totp -b "$SEED") - ;; - hex) - OTP=$(oathtool --totp "$SEED") - ;; - base64) - # base64가 raw secret을 감싼 경우와 base32 문자열을 감싼 경우를 모두 지원 - DECODED_SEED=$(printf '%s' "$SEED" | base64 -d 2>/dev/null || true) - if [ -z "$DECODED_SEED" ]; then - echo "base64 디코딩 실패: 시드가 올바른 base64인지 확인"; exit 1 - fi - DECODED_BASE32=$(printf '%s' "$DECODED_SEED" | tr -d '[:space:]' | tr 'a-z' 'A-Z') - if printf '%s' "$DECODED_BASE32" | grep -Eq '^[A-Z2-7]+=*$'; then - OTP=$(oathtool --totp -b "$DECODED_BASE32") - else - SEED_HEX=$(printf '%s' "$SEED" | base64 -d 2>/dev/null | od -An -v -tx1 | tr -d ' \n') - OTP=$(oathtool --totp "$SEED_HEX") - fi - ;; - *) - echo "잘못된 otp-seed-format: $OTP_SEED_FORMAT (base32|hex|base64)"; exit 1 - ;; - esac + CONNECTED=0 + cleanup() { + if [ "$CONNECTED" = "1" ]; then + return + fi + if [ -n "${F5VPN_PID:-}" ] && kill -0 "$F5VPN_PID" 2>/dev/null; then + kill "$F5VPN_PID" 2>/dev/null || true + fi + if [ -n "${XVFB_PID:-}" ] && kill -0 "$XVFB_PID" 2>/dev/null; then + kill "$XVFB_PID" 2>/dev/null || true + fi + } + trap cleanup EXIT - # OTP 결합 방식에 따라 password 필드 구성 - case "$OTP_MODE" in - append) PASSFIELD="${VPN_PASS}${OTP}" ;; - replace) PASSFIELD="${OTP}" ;; - none) PASSFIELD="${VPN_PASS}" ;; - *) echo "잘못된 otp-mode: $OTP_MODE (append|replace|none)"; exit 1 ;; - esac + totp() { + python3 - "$OTP_SEED" "$OTP_SEED_FORMAT" <<'PY' + import base64 + import hashlib + import hmac + import struct + import sys + import time - echo "VPN 연결 시도: ${VPN_HOST} (user=${VPN_USER}, otp-mode=${OTP_MODE})" - # 참고: f5fpc 플래그는 클라이언트 버전마다 다를 수 있음 (`f5fpc --help`로 확인). - # 이 클라이언트의 비동기 실행 옵션은 --nonblock이며, --nogui는 지원되지 않는 버전이 있음. - if ! $SUDO f5fpc --start \ - --host "$VPN_HOST" \ - --user "$VPN_USER" \ - --password "$PASSFIELD" \ - --nocheck --nonblock; then - echo "VPN 시작 명령 실패. f5fpc --help 출력과 입력값을 확인하세요." - $SUDO f5fpc --info 2>/dev/null || true + seed, fmt = sys.argv[1], sys.argv[2] + seed = "".join(seed.split()) + + if fmt == "base64": + decoded = base64.b64decode(seed) + try: + maybe_base32 = decoded.decode("ascii").replace(" ", "").upper() + if maybe_base32 and all(ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=" for ch in maybe_base32): + raw = base64.b32decode(maybe_base32) + else: + raw = decoded + except UnicodeDecodeError: + raw = decoded + elif fmt == "base32": + raw = base64.b32decode(seed.upper()) + elif fmt == "hex": + raw = bytes.fromhex(seed) + else: + raise SystemExit(f"잘못된 otp-seed-format: {fmt}") + + counter = int(time.time() // 30) + digest = hmac.new(raw, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0f + code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff) % 1000000 + print(f"{code:06d}") + PY + } + + curl_common() { + curl -k -sS --http1.1 -A "$UA" "$@" + } + + echo "F5 웹 로그인 시작: ${VPN_HOST} (user=${VPN_USER})" + curl_common -L -c "$COOKIE_JAR" "$VPN_HOST/" -o "$WORKDIR/01-login.html" + + curl_common -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -X POST "$VPN_HOST/my.policy" \ + --data-urlencode "username=$VPN_USER" \ + --data-urlencode "password=$VPN_PASS" \ + --data-urlencode "vhost=standard" \ + -o "$WORKDIR/02-otp.html" + + if ! grep -q "ga_code_attempt" "$WORKDIR/02-otp.html"; then + echo "OTP 입력 폼에 도달하지 못했습니다. 사용자명/비밀번호 또는 APM 정책을 확인하세요." exit 1 fi - # 연결은 백그라운드 데몬에서 비동기로 진행 → --info를 폴링해 완료 대기 - echo "연결 완료 대기 중 (최대 ${TIMEOUT}초)..." - DISCONNECTED_PATTERN="not connected|Client not connected|Please login|Unable to open message queue|disconnected|logon failed|로그인" - for i in $(seq 1 "$TIMEOUT"); do - INFO=$($SUDO f5fpc --info 2>&1 || true) - if [ "$i" -eq 1 ] || [ $((i % 5)) -eq 0 ]; then - echo "VPN 상태 확인 (${i}/${TIMEOUT}초):" - if [ -n "$INFO" ]; then - echo "$INFO" - else - echo "(f5fpc --info 출력 없음)" - fi - fi - if echo "$INFO" | grep -Eqi "logon failed"; then - echo "VPN 로그인 실패:" - echo "$INFO" - exit 1 - fi - if echo "$INFO" | grep -Eqi "$DISCONNECTED_PATTERN"; then - sleep 1 - continue - fi - if echo "$INFO" | grep -Eqi "$CONNECTED_PATTERN"; then - echo "VPN 연결 완료" - echo "$INFO" - exit 0 + while [ $(( $(date +%s) % 30 )) -gt 22 ]; do + sleep 1 + done + + curl_common -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -X POST "$VPN_HOST/my.policy" \ + --data-urlencode "ga_code_attempt=$(totp)" \ + --data-urlencode "vhost=standard" \ + -o "$WORKDIR/03-webtop.html" + + if ! grep -q "F5 Dynamic Webtop\|Network Access" "$WORKDIR/03-webtop.html"; then + echo "OTP 인증 후 Webtop에 도달하지 못했습니다." + exit 1 + fi + + host_no_scheme="${VPN_HOST#https://}" + host_no_scheme="${host_no_scheme#http://}" + host_no_scheme="${host_no_scheme%%/*}" + webtop_path="/vdesk/webtop.eui?z=$RESOURCE_NAME&webtop=/Common/dw_webtop&webtop_type=webtop_na_only" + + TOKEN_HEADERS="$WORKDIR/04-token.headers" + curl_common -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -D "$TOKEN_HEADERS" \ + -H "X-Requested-With: XMLHttpRequest" \ + -H "Accept: */*" \ + -e "$VPN_HOST$webtop_path" \ + "$VPN_HOST/vdesk/get_token_for_sessid.php3" \ + -o "$WORKDIR/04-token.body" + + otc="$( + awk 'BEGIN{IGNORECASE=1} /^X-ACCESS-Session-Token:/ { sub(/\r$/, ""); print substr($0, index($0, ":") + 2) }' "$TOKEN_HEADERS" | + tail -n 1 + )" + if [ -z "$otc" ]; then + echo "F5 session token 획득 실패" + exit 1 + fi + + base_url="f5-vpn://$host_no_scheme?server=$host_no_scheme&resourcename=$RESOURCE_NAME&resourcetype=network_access&cmd=launch&protocol=https&port=443&sid=nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn" + token="$(printf '%s' "$base_url" | md5sum | awk '{print $1}')" + printf '%s\n' "$base_url&token=$token&otc=$otc" > "$LAUNCH_URL_FILE" + + # F5 번들 Qt 5.5는 Xvfb RandR refresh rate에서 SIGFPE가 나므로 RandR 확장을 끈다. + Xvfb :99 -screen 0 1280x800x24 -extension RANDR >"$WORKDIR/xvfb.log" 2>&1 & + XVFB_PID=$! + sleep 1 + + export DISPLAY=:99 + export HOME="$WORKDIR/home" + mkdir -p "$HOME" + + echo "F5 VPN 클라이언트 실행" + /opt/f5/vpn/f5vpn "$(cat "$LAUNCH_URL_FILE")" >"$WORKDIR/f5vpn.out" 2>"$WORKDIR/f5vpn.err" & + F5VPN_PID=$! + + for _ in $(seq 1 20); do + if xwininfo -root -tree 2>/dev/null | grep -q "F5 VPN - Security Warning"; then + xdotool mousemove 650 398 click 1 + break fi sleep 1 done - echo "VPN 연결 실패 (${TIMEOUT}초 초과). 마지막 상태:" - $SUDO f5fpc --info 2>/dev/null || true + echo "연결 완료 대기 중 (최대 ${TIMEOUT}초)..." + for _ in $(seq 1 "$TIMEOUT"); do + if ip link show tun0 >/dev/null 2>&1; then + CONNECTED=1 + echo "VPN 연결 완료" + ip -br addr show tun0 + exit 0 + fi + if ! kill -0 "$F5VPN_PID" 2>/dev/null; then + echo "f5vpn이 tun0 생성 전에 종료되었습니다." + sed -n '1,120p' "$WORKDIR/f5vpn.err" || true + tail -120 "$HOME/.F5Networks/vpn.log" 2>/dev/null || true + exit 1 + fi + sleep 1 + done + + echo "VPN 연결 실패 (${TIMEOUT}초 초과)." + sed -n '1,120p' "$WORKDIR/f5vpn.err" || true + tail -120 "$HOME/.F5Networks/vpn.log" 2>/dev/null || true exit 1