Jamf Pro

다운로드 31
업데이트 2025. 1. 10.

권한 점검 스크립트

확장 명령어나 수집기가 권한 오류(INVALID_PRIVILEGE)로 실패하는데 원인을 찾기 어렵다면, 점검 스크립트로 소나 접속 프로파일의 설정 그대로 Jamf Pro API를 직접 호출해 확인할 수 있습니다. 계정이 실제로 가진 권한과, 앱이 쓰는 API의 성공 여부를 함께 보여 줍니다.

  • 조회만 하며 Jamf Pro나 소나의 설정을 바꾸지 않습니다. 항목마다 1건만 조회하고, 점검이 끝나면 발급받은 토큰을 바로 무효화합니다.
  • 결과는 한국어로 출력하며, -l en 옵션을 주면 영어로 출력합니다.

스크립트 구성

스크립트실행할 서버하는 일
sonar_profile_export.sh소나 웹 콘솔(API)에 접속할 수 있는 서버소나 접속 프로파일의 설정값(API 주소, 계정, HTTP 프록시)을 파일로 저장
jamf_permission_check.shJamf Pro에 접속할 수 있는 서버저장한 파일과 계정 암호로 Jamf Pro API를 호출해 권한을 점검

소나 서버에서 Jamf Pro에 직접 접속할 수 있다면 두 스크립트를 같은 서버에서 실행해도 됩니다.

준비물

  • 리눅스 서버의 bash, curl 명령 (awk, sed 등 기본 명령 외에 별도 설치 불필요)
  • 소나 API 키: 소나 웹 콘솔 계정 > 계정 > (해당 계정) > API 키에서 발급합니다. 점검할 접속 프로파일을 볼 수 있는 계정이어야 합니다.
  • 접속 프로파일 계정의 암호: 소나는 보안을 위해 암호를 API로 제공하지 않으므로, 점검할 때 직접 입력합니다.

단계 1: 스크립트 파일 만들기

이 페이지 맨 아래 스크립트 전문의 두 스크립트를 각각 같은 이름의 파일로 저장합니다. 복사한 내용을 붙여 넣을 때 줄바꿈이 바뀌지 않도록 리눅스 서버에서 직접 편집기로 저장하는 것을 권장합니다.

단계 2: 접속 프로파일 값 추출

소나에 접속할 수 있는 서버에서 실행합니다. Jamf Pro 접속 프로파일의 유형은 jamf-pro입니다. 모든 Jamf Pro 접속 프로파일을 한 번에 저장하려면 -t jamf-pro를 지정합니다.

sh sonar_profile_export.sh -s https://(소나 주소) -t jamf-pro

특정 접속 프로파일만 저장하려면 -t 대신 -n에 식별자를 지정합니다. 쉼표로 여러 개를 지정할 수 있습니다.

sh sonar_profile_export.sh -s https://(소나 주소) -n (식별자)

실행하면 소나 API 키를 묻습니다. 입력한 키는 화면에 표시되지 않으며, SONAR_API_KEY 환경 변수로 줄 수도 있습니다. 소나는 보통 사설 인증서를 쓰므로 스크립트는 소나에 접속할 때 인증서를 검증하지 않습니다.

접속 프로파일마다 profile_(식별자)_(실행시각).txt 파일이 만들어집니다. 이 파일에는 암호 같은 보호 항목이 들어 있지 않으며, 실행한 계정만 읽을 수 있습니다.

단계 3: 권한 점검 실행

단계 2에서 만든 파일을 Jamf Pro에 접속할 수 있는 서버로 옮긴 뒤 실행합니다. 실행하면 계정의 암호를 묻습니다. 입력한 암호는 화면에 표시되지 않으며 결과 파일에도 저장되지 않습니다. 접속 프로파일이 여러 개라면 파일마다 실행합니다.

sh jamf_permission_check.sh -f (단계 2에서 추출한 파일)
옵션필수 여부설명
-f필수단계 2에서 추출한 파일. API 주소와 계정을 이 파일에서 읽습니다
-p선택이 서버에서 Jamf Pro에 접속할 때 HTTP 프록시가 필요하면 IP:PORT로 지정합니다. 접속 프로파일의 프록시는 소나 서버 기준 값이라 자동으로 쓰지 않습니다
-l선택출력 언어. en이면 영어로 출력합니다

점검 결과 해석

단계이름점검 내용
[1]토큰 발급계정과 암호로 로그인되는지, Jamf Pro 버전
[2]계정이 실제로 가진 권한접근 수준(accessLevel), 권한 세트(privilegeSet), 컴퓨터 관련 권한 목록, Read Computers 보유 여부
[3]앱 기능별 API 호출jamf-pro-computers가 쓰는 컴퓨터 인벤토리 조회, 감사 로그 수집기가 쓰는 컴퓨터 목록·이력 조회

[2]에서 확인할 값은 다음과 같습니다.

항목정상 값
접근 수준(accessLevel)FullAccess
권한 세트(privilegeSet)AUDITOR (설치 매뉴얼 기준) 또는 ADMINISTRATOR
컴퓨터 관련 권한Read Computers 포함

마지막 요약에 발견한 문제와 조치 방법이 함께 표시됩니다.

요약 예시 (계정에 Read Computers 권한이 없는 경우):

요약: 문제 1건

  1. 계정에 'Read Computers' 권한이 없음 (실패: jamf-pro-computers, 감사 로그 수집기)
     -> 조치: Jamf Pro > Settings > System > User accounts and groups > (logpresso) 에서
              Account 탭의 Privilege Set을 설치 매뉴얼대로 Auditor로 지정하거나,
              Privileges 탭 > Jamf Pro Server Objects > Computers의 Read를 체크한 뒤 Save 하세요.

결과 전달

결과는 화면과 함께 현재 폴더의 jamf_check_result_(실행시각).txt 파일에 저장됩니다. 실패한 호출이 있으면 파일 끝 부록에 Jamf Pro의 응답 원문이 함께 저장됩니다.

조치 후에도 해결되지 않으면 이 결과 파일을 로그프레소 기술지원에 전달해 주세요. 결과 파일에는 암호와 인증 토큰이 포함되지 않지만, Jamf Pro 주소와 계정 이름은 포함됩니다.

스크립트 전문

sonar_profile_export.sh

#!/usr/bin/env bash
#
# 로그프레소 소나 접속 프로파일 추출 스크립트 / Logpresso Sonar connect profile export script
#
# 소나 API로 접속 프로파일의 설정값을 읽어 프로파일마다 파일로 저장합니다. 저장한 파일은 외부 통신이 되는
# 서버로 옮겨 앱별 점검 스크립트의 -f 옵션으로 사용합니다. 암호, 서비스 키 같은 보호 항목은 소나가 API로
# 돌려주지 않으므로 파일에 포함되지 않습니다.
#
# 요구 사항 / Requirements: bash, curl, awk (소나 API에 접속할 수 있는 서버에서 실행)
#
# 사용법 / Usage:
#   sh sonar_profile_export.sh -s https://SONAR -t TYPE         유형의 모든 프로파일 저장 / export all of a type
#   sh sonar_profile_export.sh -s https://SONAR -n PROFILE[,..] 지정한 프로파일 저장 / export profiles
#
#   -s  소나 주소 / Sonar URL (e.g. https://192.0.2.10:443)
#   -n  접속 프로파일 식별자 또는 이름, 쉼표로 여러 개 / profile identifier or name, comma separated
#   -t  접속 프로파일 유형 (앱 설치 매뉴얼에 표기) / profile type (see the app install guide)
#   -l  출력 언어 ko(기본) 또는 en / output language ko (default) or en
#
# 소나 API 키(계정 > 계정 > 해당 계정 > API 키)는 실행 중에 입력받습니다 (화면에 표시되지 않음).
# SONAR_API_KEY 환경 변수로도 줄 수 있습니다. / The Sonar API key is prompted, or set SONAR_API_KEY.
# 결과 파일 / Output: profile_<식별자>_<실행시간>.txt (소유자만 읽기 가능 / owner-only)
# 소나는 보통 사설 인증서를 쓰므로 소나 접속 시 인증서 검증은 하지 않습니다.

# sh(dash 등)로 실행해도 bash 로 다시 실행
if [ -z "$BASH_VERSION" ] || case ":$SHELLOPTS:" in *:posix:*) true ;; *) false ;; esac; then
	exec bash "$0" "$@"
fi

# 만드는 파일은 소유자만 읽을 수 있게
umask 077

SONAR=""
NAMES=""
TYPE=""
LANG_OPT=ko

usage() {
	sed -n '11,18p' "$0" | sed 's/^# \{0,1\}//'
	exit 2
}

while getopts "s:n:t:l:h" opt; do
	case "$opt" in
		s) SONAR="${OPTARG%/}" ;;
		n) NAMES="$OPTARG" ;;
		t) TYPE="$OPTARG" ;;
		l) LANG_OPT="$OPTARG" ;;
		*) usage ;;
	esac
done
[ -z "$SONAR" ] && usage
[ -z "$NAMES" ] && [ -z "$TYPE" ] && usage

# 언어별 문구 선택 / pick message by language
t() {
	if [ "$LANG_OPT" = "en" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}

if ! command -v curl >/dev/null 2>&1; then
	t "curl 명령이 필요합니다." "curl is required."; echo
	exit 2
fi

API_KEY="${SONAR_API_KEY:-}"
if [ -z "$API_KEY" ]; then
	printf '%s: ' "$(t "소나 API 키" "Sonar API key")"
	read -r -s API_KEY
	echo ""
fi
[ -z "$API_KEY" ] && usage

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

# curl 설정 파일 값의 따옴표·역슬래시 처리
cfg_esc() {
	printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}

# 프로파일 배열 원소를 한 줄에 하나씩 출력 (문자열 안의 괄호는 무시)
split_profiles() {
	tr -d '\n\r' | awk '
	{
		s = $0; n = length(s); depth = 0; instr = 0; esc = 0; start = 0; arr = 0
		p = index(s, "\"profiles\"")
		if (p == 0) exit
		for (i = p; i <= n; i++) {
			c = substr(s, i, 1)
			if (instr) {
				if (esc) esc = 0
				else if (c == "\\") esc = 1
				else if (c == "\"") instr = 0
				continue
			}
			if (c == "\"") { instr = 1; continue }
			if (!arr) { if (c == "[") arr = 1; continue }
			if (c == "{") { if (depth == 0) start = i; depth++ }
			else if (c == "}") { depth--; if (depth == 0) print substr(s, start, i - start + 1) }
			else if (c == "]" && depth == 0) exit
		}
	}'
}

# 프로파일 객체의 최상위 문자열 필드 값 (code, name, type)
top_str() {
	awk -v key="$1" '
	{
		s = $0; n = length(s); depth = 0; instr = 0; esc = 0; buf = ""; want = 0
		for (i = 1; i <= n; i++) {
			c = substr(s, i, 1)
			if (instr) {
				if (esc) { esc = 0; buf = buf c; continue }
				if (c == "\\") { esc = 1; continue }
				if (c == "\"") {
					instr = 0
					if (want) { print buf; exit }
					if (depth == 1) last = buf
					continue
				}
				buf = buf c
				continue
			}
			if (c == "\"") { instr = 1; buf = ""; continue }
			if (c == "{" || c == "[") depth++
			else if (c == "}" || c == "]") depth--
			else if (c == ":" && depth == 1 && last == key) {
				j = i + 1
				while (substr(s, j, 1) == " ") j++
				if (substr(s, j, 1) == "\"") want = 1
				else exit
			} else if (c == ",") last = ""
		}
	}'
}

# configs 객체를 "키<TAB>값" 줄로 출력. null은 출력하지 않음
configs_lines() {
	awk '
	{
		s = $0; n = length(s)
		p = index(s, "\"configs\"")
		if (p == 0) exit
		i = p + 9
		while (i <= n && substr(s, i, 1) != "{") i++
		i++
		while (i <= n) {
			while (i <= n && index(" ,\t", substr(s, i, 1))) i++
			c = substr(s, i, 1)
			if (c == "}" || i > n) break
			# 키
			i++; key = ""
			while (i <= n) {
				c = substr(s, i, 1)
				if (c == "\\") { key = key substr(s, i + 1, 1); i += 2; continue }
				if (c == "\"") { i++; break }
				key = key c; i++
			}
			while (i <= n && substr(s, i, 1) != ":") i++
			i++
			while (i <= n && substr(s, i, 1) == " ") i++
			c = substr(s, i, 1)
			if (c == "\"") {
				i++; val = ""
				while (i <= n) {
					c = substr(s, i, 1)
					if (c == "\\") {
						d = substr(s, i + 1, 1)
						if (d == "n") val = val " "
						else if (d == "t") val = val " "
						else val = val d
						i += 2; continue
					}
					if (c == "\"") { i++; break }
					val = val c; i++
				}
				print key "\t" val
			} else if (c == "{" || c == "[") {
				depth = 0; st = i
				while (i <= n) {
					c = substr(s, i, 1)
					if (c == "{" || c == "[") depth++
					else if (c == "}" || c == "]") { depth--; if (depth == 0) { i++; break } }
					i++
				}
				print key "\t" substr(s, st, i - st)
			} else {
				st = i
				while (i <= n && !index(",}", substr(s, i, 1))) i++
				val = substr(s, st, i - st)
				gsub(/ +$/, "", val)
				if (val != "null") print key "\t" val
			}
		}
	}'
}

# protected_keys 배열의 값 목록
protected_keys() {
	grep -o '"protected_keys"[[:space:]]*:[[:space:]]*\[[^]]*\]' | grep -o '"[^"]*"' | sed -n '2,$p' | tr -d '"'
}

# 소나 API에서 필요한 프로파일만 조회 (-t: 유형 필터, -n: 이름마다 검색어 필터)
fetch() {
	# API 키가 프로세스 목록에 보이지 않도록 curl 설정을 표준 입력으로 전달
	STATUS=$(printf 'header = "Authorization: Bearer %s"\n' "$(cfg_esc "$API_KEY")" | \
		curl -s -S -k --max-time 30 -K - -o "$WORK_DIR/profiles" -w '%{http_code}' -G \
		--data-urlencode "$1" "$SONAR/api/sonar/connect-profiles" 2>"$WORK_DIR/curl_err")
	if [ "$STATUS" = "000" ] || [ -z "$STATUS" ]; then
		echo "$(t "[실패] 소나에 접속하지 못했습니다" "[FAIL] Cannot connect to Sonar"): $(head -c 300 "$WORK_DIR/curl_err")"
		exit 1
	elif [ "$STATUS" = "401" ] || [ "$STATUS" = "403" ]; then
		echo "[$(t "실패" "FAIL")] HTTP $STATUS - $(t "소나 API 키가 틀렸거나 접속 프로파일 조회 권한이 없습니다." "Invalid Sonar API key or no permission to read connect profiles.")"
		exit 1
	elif [ "$STATUS" != "200" ]; then
		echo "[$(t "실패" "FAIL")] HTTP $STATUS $(head -c 300 "$WORK_DIR/profiles")"
		exit 1
	fi
	split_profiles < "$WORK_DIR/profiles" >> "$WORK_DIR/lines"
}

: > "$WORK_DIR/lines"
[ -n "$TYPE" ] && fetch "type=$TYPE"
if [ -n "$NAMES" ]; then
	IFS=',' read -r -a wanted <<< "$NAMES"
	for w in "${wanted[@]}"; do
		fetch "keywords=$w"
	done
fi
# 여러 조회에 같은 프로파일이 나오면 한 번만 처리
sort -u "$WORK_DIR/lines" -o "$WORK_DIR/lines"

TS=$(date +%Y%m%d_%H%M%S)
COUNT=0
FOUND_NAMES=","
while IFS= read -r line; do
	code=$(printf '%s' "$line" | top_str code)
	pname=$(printf '%s' "$line" | top_str name)
	ptype=$(printf '%s' "$line" | top_str type)
	match=0
	if [ -n "$TYPE" ] && [ "$ptype" = "$TYPE" ]; then
		match=1
	fi
	if [ -n "$NAMES" ]; then
		IFS=',' read -r -a wanted <<< "$NAMES"
		for w in "${wanted[@]}"; do
			if [ "$w" = "$code" ] || [ "$w" = "$pname" ]; then
				match=1
				FOUND_NAMES="$FOUND_NAMES$w,"
			fi
		done
	fi
	[ "$match" -eq 1 ] || continue

	# 파일 이름에는 영문·숫자·_·- 만 사용 (경로 문자 차단)
	safe=$(printf '%s' "$code" | tr -c 'A-Za-z0-9_-' '_')
	out="$(pwd)/profile_${safe}_${TS}.txt"
	{
		echo "# $(t "로그프레소 소나 접속 프로파일" "Logpresso Sonar connect profile") (sonar_profile_export.sh, $(date '+%Y-%m-%d %H:%M:%S'))"
		echo "sonar=$SONAR"
		echo "profile_code=$code"
		echo "profile_name=$pname"
		echo "profile_type=$ptype"
		prot=$(printf '%s' "$line" | protected_keys | paste -sd, -)
		echo "protected_keys=$prot"
		# 보호 항목은 소나가 빈 값으로 주므로 저장하지 않음 (빈 값과 헷갈리지 않게)
		printf '%s' "$line" | configs_lines | while IFS="$(printf '\t')" read -r k v; do
			case ",$prot," in
				*",$k,"*) continue ;;
			esac
			echo "config.$k=$(printf '%s' "$v" | tr -s ' ')"
		done
	} > "$out"
	COUNT=$((COUNT + 1))

	echo ""
	echo "[$COUNT] $(t "식별자" "Identifier"): $code"
	echo "    $(t "이름" "Name"): $pname"
	echo "    $(t "유형" "Type"): $ptype"
	echo "    $(t "설정" "Settings"):"
	grep '^config\.' "$out" | sed 's/^config\./      /'
	echo "    $(t "저장 파일" "Saved file"): $out"
done < "$WORK_DIR/lines"

if [ -n "$NAMES" ]; then
	IFS=',' read -r -a wanted <<< "$NAMES"
	for w in "${wanted[@]}"; do
		case "$FOUND_NAMES" in
			*",$w,"*) ;;
			*) echo ""; echo "[$(t "실패" "FAIL")] $(t "식별자나 이름이 '$w'인 접속 프로파일이 없습니다." "No connect profile with identifier or name '$w'.")" ;;
		esac
	done
fi

echo ""
if [ "$COUNT" -eq 0 ]; then
	echo "$(t "저장한 프로파일이 없습니다. 식별자나 유형을 확인하세요." "No profile exported. Check the identifier or type.")"
	exit 1
fi
echo "$(t "저장한 프로파일" "Exported profiles"): $COUNT"
echo "$(t "보호 항목(암호, 서비스 키 등)은 소나가 제공하지 않아 파일에 포함되지 않습니다." "Protected values (password, service key, etc.) are not provided by Sonar and are not included.")"
echo "$(t "파일을 외부 통신이 되는 서버로 옮겨 앱별 점검 스크립트의 -f 옵션으로 사용하세요." "Copy the files to a server with internet access and pass them to the app check script with -f.")"

jamf_permission_check.sh

#!/usr/bin/env bash
#
# Jamf Pro 앱 권한 점검 스크립트 / Jamf Pro app permission check script
#
# 로그프레소 Jamf Pro 앱과 같은 방식(계정/암호로 토큰 발급 후 API 호출)으로 Jamf Pro API를 호출해 보고,
# 계정이 가진 권한과 어느 단계에서 권한 오류(403 INVALID_PRIVILEGE)가 나는지 알려줍니다.
# 데이터를 변경하는 호출은 하지 않으며, 조회는 1건만 요청합니다.
#
# 요구 사항 / Requirements: Linux, bash, curl (Jamf Pro에 접속할 수 있는 서버에서 실행)
#
# 사용법 / Usage:
#   sh jamf_permission_check.sh -f profile_FILE.txt [-p IP:PORT] [-l en]
#
#   -f  sonar_profile_export.sh 가 만든 프로파일 파일 (API 주소, 계정을 읽음) / profile file
#   -p  이 서버에서 Jamf Pro에 접속할 때 쓸 HTTP 프록시 / HTTP proxy for this server
#   -l  출력 언어 ko(기본) 또는 en / output language ko (default) or en
#
# 암호는 실행 중에 입력받습니다 (화면에 표시되지 않음). JAMF_PASSWORD 환경 변수로도 줄 수 있습니다.
# 결과는 화면과 함께 jamf_check_result_<실행시간>.txt 파일에 저장됩니다 (소유자만 읽기 가능).
# 암호와 토큰 값은 출력하거나 저장하지 않습니다.

# sh(dash 등)로 실행해도 bash 로 다시 실행
if [ -z "$BASH_VERSION" ] || case ":$SHELLOPTS:" in *:posix:*) true ;; *) false ;; esac; then
	exec bash "$0" "$@"
fi

# 만드는 파일은 소유자만 읽을 수 있게
umask 077

PROFILE_FILE=""
ENDPOINT=""
JAMF_USER=""
PROXY=""
LANG_OPT=ko

usage() {
	sed -n '11,16p' "$0" | sed 's/^# \{0,1\}//'
	exit 2
}

while getopts "f:p:l:h" opt; do
	case "$opt" in
		f) PROFILE_FILE="$OPTARG" ;;
		p) PROXY="$OPTARG" ;;
		l) LANG_OPT="$OPTARG" ;;
		*) usage ;;
	esac
done

# 언어별 문구 선택 / pick message by language
t() {
	if [ "$LANG_OPT" = "en" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}

# 프로파일 파일에서 API 주소, 계정을 읽음
[ -z "$PROFILE_FILE" ] && usage
if [ -n "$PROFILE_FILE" ]; then
	if [ ! -r "$PROFILE_FILE" ]; then
		echo "$(t "프로파일 파일을 읽을 수 없습니다" "Cannot read profile file"): $PROFILE_FILE"
		exit 2
	fi
	pf_get() {
		grep "^$1=" "$PROFILE_FILE" | head -n 1 | cut -d= -f2-
	}
	F_ENDPOINT=$(pf_get config.endpoint)
	F_USER=$(pf_get config.username)
	F_PROXY=$(pf_get config.http_proxy)
	ENDPOINT="${F_ENDPOINT%/}"
	JAMF_USER="$F_USER"
fi
if [ -z "$ENDPOINT" ] || [ -z "$JAMF_USER" ]; then
	echo "$(t "프로파일 파일에 API 주소(config.endpoint) 또는 계정(config.username)이 없습니다" "The profile file has no API endpoint (config.endpoint) or account (config.username)"): $PROFILE_FILE"
	exit 2
fi

if ! command -v curl >/dev/null 2>&1; then
	echo "curl $(t "명령이 필요합니다." "is required.")"
	exit 2
fi

JAMF_PASS="${JAMF_PASSWORD:-}"
if [ -z "$JAMF_PASS" ]; then
	printf '%s: ' "$(t "$JAMF_USER 계정의 암호" "Password of $JAMF_USER")"
	read -r -s JAMF_PASS
	echo ""
fi

RESULT_FILE="$(pwd)/jamf_check_result_$(date +%Y%m%d_%H%M%S).txt"
: > "$RESULT_FILE"
WORK_DIR="$(mktemp -d)"
chmod 700 "$WORK_DIR"
TOKEN=""

cleanup() {
	# 발급받은 토큰은 점검이 끝나면 바로 무효화
	if [ -n "$TOKEN" ]; then
		auth_cfg "$TOKEN" | curl "${CURL_OPTS[@]}" -K - -o /dev/null -X POST \
			"$ENDPOINT/api/v1/auth/invalidate-token" 2>/dev/null
	fi
	rm -rf "$WORK_DIR"
}
trap cleanup EXIT

PROBLEMS=()
FIXES=()

log() {
	echo "$*"
	echo "$*" >> "$RESULT_FILE"
}

add_problem() {
	PROBLEMS+=("$1")
	FIXES+=("$2")
}

# 실패한 호출의 응답 원문을 결과 파일 부록용으로 모음. $1=제목, $2=요청 설명
save_raw() {
	{
		echo ""
		echo "### $1"
		echo "$(t "요청" "Request"): $2"
		echo "HTTP: $HTTP_STATUS"
		head -c 3000 "$WORK_DIR/body"
		echo ""
	} >> "$WORK_DIR/appendix"
}

finish() {
	log ""
	log "======================================================================"
	if [ ${#PROBLEMS[@]} -gt 0 ]; then
		log "$(t "요약: 문제" "Summary: problems") ${#PROBLEMS[@]}$(t "건" "")"
		for i in "${!PROBLEMS[@]}"; do
			log ""
			log "  $((i + 1)). ${PROBLEMS[$i]}"
			if [ -n "${FIXES[$i]}" ]; then
				prefix="-> $(t "조치" "Action"):"
				while IFS= read -r line; do
					log "     $prefix $line"
					prefix="        "
				done <<< "${FIXES[$i]}"
			fi
		done
	else
		log "$(t "요약: 모든 점검 통과" "Summary: all checks passed")"
	fi
	log "======================================================================"
	if [ -s "$WORK_DIR/appendix" ]; then
		{
			echo ""
			echo "$(t "부록: 실패한 호출의 Jamf Pro 응답 원문 (원인 분석용, 로그프레소에 전달)" "Appendix: raw Jamf Pro responses of failed calls (send to Logpresso for analysis)")"
			echo "----------------------------------------------------------------------"
			cat "$WORK_DIR/appendix"
		} >> "$RESULT_FILE"
		echo ""
		echo "$(t "실패한 호출의 응답 원문은 결과 파일 끝 '부록'에 저장했습니다." "Raw responses of failed calls are saved in the appendix at the end of the result file.")"
	fi
	echo ""
	echo "$(t "결과 파일" "Result file"): $RESULT_FILE"
	[ ${#PROBLEMS[@]} -gt 0 ] && exit 1
	exit 0
}

# JSON 에서 첫 번째 문자열 값 추출 (jq 없이)
json_str() {
	grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n 1 | sed "s/^\"$1\"[[:space:]]*:[[:space:]]*\"//; s/\"\$//"
}

# JSON 에서 첫 번째 숫자/불린 값 추출
json_val() {
	grep -o "\"$1\"[[:space:]]*:[[:space:]]*[-0-9a-z]*" | head -n 1 | sed 's/.*:[[:space:]]*//'
}

CURL_OPTS=(-s -S --max-time 60)
[ -n "$PROXY" ] && CURL_OPTS+=(-x "$PROXY")

# curl 설정 파일 값의 따옴표·역슬래시 처리
cfg_esc() {
	printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}

# 토큰 인증 헤더를 curl 설정 줄로 출력 (토큰이 프로세스 목록에 보이지 않게 표준 입력으로 넘김)
auth_cfg() {
	printf 'header = "Authorization: Bearer %s"\n' "$(cfg_esc "$1")"
}

# 토큰으로 GET 호출
api_get() {
	HTTP_STATUS=$(auth_cfg "$TOKEN" | curl "${CURL_OPTS[@]}" -K - -o "$WORK_DIR/body" -w '%{http_code}' \
		-H "Accept: application/json" "$ENDPOINT$1" 2>"$WORK_DIR/curl_err")
	if [ "$HTTP_STATUS" = "000" ] || [ -z "$HTTP_STATUS" ]; then
		HTTP_STATUS="000"
		cp "$WORK_DIR/curl_err" "$WORK_DIR/body"
	fi
}

# Jamf Pro 오류 응답의 code/description 추출
jamf_error() {
	local code desc
	code=$(json_str code < "$WORK_DIR/body")
	desc=$(json_str description < "$WORK_DIR/body")
	if [ -n "$code$desc" ]; then
		echo "$code $desc"
	else
		head -c 200 "$WORK_DIR/body" | tr '\n' ' '
	fi
}

ACCOUNT_PATH="Jamf Pro > Settings > System > User accounts and groups > ($JAMF_USER)"

log "======================================================================"
log "$(t "Jamf Pro 앱 권한 점검" "Jamf Pro app permission check")  ($(date '+%Y-%m-%d %H:%M:%S'))"
log "======================================================================"
log ""
if [ -n "$PROFILE_FILE" ]; then
	log "  $(t "프로파일 파일" "Profile file"): $PROFILE_FILE"
	log "  $(t "식별자" "Identifier"): $(pf_get profile_code)"
	log "  $(t "이름" "Name"): $(pf_get profile_name)"
fi
log "  $(t "API 주소" "API endpoint"): $ENDPOINT"
log "  $(t "계정" "Account"): $JAMF_USER"
log "  $(t "HTTP 프록시" "HTTP proxy"): ${PROXY:-$(t "(사용 안 함)" "(none)")}"
if [ -n "$F_PROXY" ] && [ -z "$PROXY" ]; then
	log "  -> $(t "소나 서버는 프록시($F_PROXY)로 Jamf Pro에 접속합니다. 이 서버도 프록시가 필요하면 -p 옵션으로 지정하세요." "Sonar connects to Jamf Pro through proxy $F_PROXY. Use -p if this server also needs a proxy.")"
fi

# 1. 토큰 발급
log ""
log "[1] $(t "토큰 발급 (앱과 같은 방식: 계정/암호로 POST /api/v1/auth/token)" "Token (same as the app: POST /api/v1/auth/token with account/password)")"
# 암호가 프로세스 목록에 보이지 않도록 curl 설정을 표준 입력으로 전달
CRED=$(cfg_esc "$JAMF_USER:$JAMF_PASS")
HTTP_STATUS=$(printf 'user = "%s"\n' "$CRED" | curl "${CURL_OPTS[@]}" -K - \
	-o "$WORK_DIR/body" -w '%{http_code}' -X POST -H "Accept: application/json" \
	"$ENDPOINT/api/v1/auth/token" 2>"$WORK_DIR/curl_err")
JAMF_PASS=""
CRED=""
if [ "$HTTP_STATUS" = "000" ] || [ -z "$HTTP_STATUS" ]; then
	HTTP_STATUS="000"
	cp "$WORK_DIR/curl_err" "$WORK_DIR/body"
fi
TOKEN=$(json_str token < "$WORK_DIR/body")
if [ "$HTTP_STATUS" = "200" ] && [ -n "$TOKEN" ]; then
	log "  [$(t "정상" "OK")] $(t "토큰 발급 성공" "Token issued")"
else
	TOKEN=""
	log "  [$(t "실패" "FAIL")] HTTP $HTTP_STATUS $(jamf_error)"
	save_raw "$(t "토큰 발급 실패" "Token request failed")" "POST $ENDPOINT/api/v1/auth/token"
	case "$HTTP_STATUS" in
		000) add_problem "$(t "Jamf Pro에 접속하지 못함" "Cannot connect to Jamf Pro")" \
			"$(t "API 주소($ENDPOINT)가 맞는지, 이 서버에서 Jamf Pro로 HTTPS 통신이 되는지 확인하세요. 프록시가 필요하면 -p 옵션으로 지정하세요." "Check the API endpoint ($ENDPOINT) and HTTPS access from this server. Use -p if a proxy is required.")" ;;
		401) add_problem "$(t "계정 또는 암호가 틀렸거나 계정이 비활성 상태" "Wrong account or password, or the account is disabled")" \
			"$(t "$ACCOUNT_PATH 에서 계정의 Access Status 가 Enabled 인지 확인하고, 소나 접속 프로파일의 계정·암호를 다시 입력하세요." "In $ACCOUNT_PATH, check that Access Status is Enabled, and re-enter the account and password in the connect profile.")" ;;
		404) add_problem "$(t "API 주소가 Jamf Pro 주소가 아님 (HTTP 404)" "The API endpoint is not a Jamf Pro address (HTTP 404)")" \
			"$(t "API 주소를 https://TENANT.jamfcloud.com 형식(경로 없이)으로 입력했는지 확인하세요." "Use the https://TENANT.jamfcloud.com format without a path.")" ;;
		*) add_problem "$(t "토큰 발급 실패" "Token request failed") (HTTP $HTTP_STATUS)" \
			"$(t "결과 파일의 부록을 로그프레소에 전달하세요." "Send the appendix of the result file to Logpresso.")" ;;
	esac
	finish
fi

# Jamf Pro 버전 (권한 불필요)
api_get "/api/v1/jamf-pro-version"
JAMF_VERSION=$(json_str version < "$WORK_DIR/body")
log "  $(t "Jamf Pro 버전" "Jamf Pro version"): ${JAMF_VERSION:-$(t "(확인 불가)" "(unknown)")}"

# 2. 계정 권한
log ""
log "[2] $(t "계정이 실제로 가진 권한 (GET /api/v1/auth)" "Privileges the account actually has (GET /api/v1/auth)")"
api_get "/api/v1/auth"
HAS_READ_COMPUTERS=unknown
NONE=$(t "(응답에 없음)" "(not in response)")
if [ "$HTTP_STATUS" = "200" ]; then
	tr -d '\n\r' < "$WORK_DIR/body" > "$WORK_DIR/auth"
	A_USER=$(json_str username < "$WORK_DIR/auth")
	A_LEVEL=$(json_str accessLevel < "$WORK_DIR/auth")
	A_SET=$(json_str privilegeSet < "$WORK_DIR/auth")
	A_TYPE=$(json_str authenticationType < "$WORK_DIR/auth")
	log "  $(t "계정" "Account"): ${A_USER:-$NONE}"
	log "  $(t "인증 방식" "Authentication type")(authenticationType): ${A_TYPE:-$NONE}"
	log "  $(t "접근 수준" "Access level")(accessLevel): ${A_LEVEL:-$NONE}"
	log "  $(t "권한 세트" "Privilege set")(privilegeSet): ${A_SET:-$NONE}"
	# 권한 이름 중 컴퓨터 관련 항목만 표시
	grep -o '"[A-Za-z ]*Computer[A-Za-z ]*"' "$WORK_DIR/auth" | tr -d '"' | sort -u > "$WORK_DIR/computer_privs"
	if [ -s "$WORK_DIR/computer_privs" ]; then
		log "  $(t "컴퓨터 관련 권한" "Computer related privileges"):"
		while IFS= read -r line; do
			log "    - $line"
		done < "$WORK_DIR/computer_privs"
	else
		log "  $(t "컴퓨터 관련 권한: (없음)" "Computer related privileges: (none)")"
	fi
	if grep -qx "Read Computers" "$WORK_DIR/computer_privs"; then
		HAS_READ_COMPUTERS=yes
		log "  -> $(t "'Read Computers' 권한이 있습니다." "The account has 'Read Computers'.")"
	else
		HAS_READ_COMPUTERS=no
		log "  -> $(t "'Read Computers' 권한이 없습니다. jamf-pro-computers 명령과 감사 로그 수집에 필요한 권한입니다." "The account does not have 'Read Computers', which jamf-pro-computers and the audit logger need.")"
	fi
	if [ -n "$A_LEVEL" ] && [ "$A_LEVEL" != "FullAccess" ]; then
		log "  -> $(t "접근 수준이 FullAccess 가 아닙니다($A_LEVEL). 사이트/그룹 단위로 권한이 제한될 수 있습니다." "Access level is not FullAccess ($A_LEVEL). Privileges may be limited to a site or group.")"
	fi
else
	log "  [$(t "참고" "INFO")] $(t "계정 권한을 조회하지 못했습니다" "Could not read account privileges") (HTTP $HTTP_STATUS $(jamf_error)). $(t "점검은 계속합니다." "Continuing.")"
	save_raw "$(t "계정 권한 조회 실패" "Privilege lookup failed")" "GET /api/v1/auth"
fi

# 3. 앱과 같은 API 호출
log ""
log "[3] $(t "앱 기능별 API 호출 (각 1건)" "API call per app feature (1 item each)")"
N_INV=$(t "컴퓨터 인벤토리 (jamf-pro-computers)" "Computer inventory (jamf-pro-computers)")
N_LIST=$(t "컴퓨터 목록 (감사 로그 수집기, Classic API)" "Computer list (audit logger, Classic API)")
N_HIST=$(t "컴퓨터 이력 (감사 로그 수집기, Classic API)" "Computer history (audit logger, Classic API)")
INV="/api/v1/computers-inventory?section=GENERAL&section=OPERATING_SYSTEM&section=HARDWARE&section=USER_AND_LOCATION&page=0&page-size=1"
api_get "$INV"
INV_STATUS="$HTTP_STATUS"
if [ "$HTTP_STATUS" = "200" ]; then
	log "  [$(t "정상" "OK")] $N_INV"
else
	INV_ERR=$(jamf_error)
	log "  [$(t "실패" "FAIL")] $N_INV"
	log "         HTTP $HTTP_STATUS $INV_ERR"
	save_raw "$N_INV" "GET $INV"
	# 특정 섹션 때문인지 구분하기 위해 GENERAL 섹션만으로 다시 조회
	if [ "$HTTP_STATUS" = "403" ]; then
		api_get "/api/v1/computers-inventory?section=GENERAL&page=0&page-size=1"
		if [ "$HTTP_STATUS" = "200" ]; then
			INV_GENERAL_OK=yes
			log "         [$(t "비교" "COMPARE")] $(t "GENERAL 섹션만 조회하면 성공 -> 운영체제/하드웨어/사용자·위치 섹션 중 일부에 권한이 없습니다." "GENERAL section only works -> no permission for some of OS, hardware, user/location sections.")"
		else
			log "         [$(t "비교" "COMPARE")] $(t "GENERAL 섹션만 조회해도 HTTP $HTTP_STATUS -> 컴퓨터 인벤토리 자체에 권한이 없습니다." "GENERAL section only also returns HTTP $HTTP_STATUS -> no permission for computer inventory itself.")"
			save_raw "$N_INV (GENERAL)" "GET /api/v1/computers-inventory?section=GENERAL"
		fi
	fi
fi

api_get "/JSSResource/computers"
if [ "$HTTP_STATUS" = "200" ]; then
	log "  [$(t "정상" "OK")] $N_LIST"
	FIRST_ID=$(json_val id < "$WORK_DIR/body")
	if [ -n "$FIRST_ID" ]; then
		api_get "/JSSResource/computerhistory/id/$FIRST_ID"
		if [ "$HTTP_STATUS" = "200" ]; then
			log "  [$(t "정상" "OK")] $N_HIST"
		else
			log "  [$(t "실패" "FAIL")] $N_HIST"
			log "         HTTP $HTTP_STATUS $(jamf_error)"
			save_raw "$N_HIST" "GET /JSSResource/computerhistory/id/$FIRST_ID"
			add_problem "$(t "감사 로그 수집기의 컴퓨터 이력 조회 실패" "Computer history lookup of the audit logger failed") (HTTP $HTTP_STATUS)" \
				"$(t "$ACCOUNT_PATH > Privileges 탭에서 Computers의 Read 가 체크되어 있는지 확인하세요." "In $ACCOUNT_PATH > Privileges tab, check that Read is selected for Computers.")"
		fi
	else
		log "  [$(t "참고" "INFO")] $(t "등록된 컴퓨터가 없어 컴퓨터 이력 조회는 건너뜁니다." "No computers registered, computer history skipped.")"
	fi
	CLASSIC_OK=yes
else
	log "  [$(t "실패" "FAIL")] $N_LIST"
	log "         HTTP $HTTP_STATUS $(jamf_error)"
	save_raw "$N_LIST" "GET /JSSResource/computers"
	CLASSIC_OK=no
	CLASSIC_STATUS="$HTTP_STATUS"
fi

# 요약: [2] 권한 정보와 [3] 결과를 합쳐 원인을 좁힘
if [ "$INV_STATUS" = "403" ] || { [ "$CLASSIC_OK" = "no" ] && [ "$CLASSIC_STATUS" = "403" ]; }; then
	FAILED=""
	[ "$INV_STATUS" = "403" ] && FAILED="jamf-pro-computers"
	[ "$CLASSIC_OK" = "no" ] && FAILED="${FAILED:+$FAILED, }$(t "감사 로그 수집기" "audit logger")"
	if [ "$HAS_READ_COMPUTERS" = "no" ]; then
		add_problem "$(t "계정에 'Read Computers' 권한이 없음" "The account does not have 'Read Computers'") ($(t "실패" "failed"): $FAILED)" \
			"$(t "$ACCOUNT_PATH 에서" "In $ACCOUNT_PATH,")
$(t "Account 탭의 Privilege Set을 설치 매뉴얼대로 Auditor로 지정하거나," "set Privilege Set to Auditor on the Account tab as in the install guide, or")
$(t "Privileges 탭 > Jamf Pro Server Objects > Computers의 Read를 체크한 뒤 Save 하세요." "select Read for Computers under Privileges tab > Jamf Pro Server Objects, then Save.")"
	elif [ "$INV_GENERAL_OK" = "yes" ]; then
		add_problem "$(t "컴퓨터 인벤토리 일부 섹션에 권한 없음" "No permission for some computer inventory sections") ($(t "실패" "failed"): $FAILED)" \
			"$(t "Privilege Set을 Auditor로 지정하면 필요한 읽기 권한이 모두 포함됩니다" "Privilege Set Auditor includes all required read privileges"): $ACCOUNT_PATH > Account
$(t "이미 Auditor 인데도 실패하면 결과 파일을 로그프레소에 전달하세요." "If it is already Auditor, send the result file to Logpresso.")"
	elif [ "$HAS_READ_COMPUTERS" = "yes" ]; then
		add_problem "$(t "'Read Computers' 권한이 있는데도 거부됨" "Denied although the account has 'Read Computers'") ($(t "실패" "failed"): $FAILED)" \
			"$(t "접근 수준(accessLevel)이 FullAccess 가 아니면 사이트 단위로 제한될 수 있습니다" "If access level is not FullAccess, access may be limited to a site"): $ACCOUNT_PATH > Account
$(t "권한을 바꾼 직후라면 잠시 후 다시 실행하고, 같은 결과면 결과 파일을 로그프레소에 전달하세요." "If privileges were just changed, retry later; otherwise send the result file to Logpresso.")"
	else
		add_problem "$(t "컴퓨터 조회 권한 부족" "Missing computer read permission") ($(t "실패" "failed"): $FAILED)" \
			"$(t "$ACCOUNT_PATH 에서" "In $ACCOUNT_PATH, check that")
$(t "1) Account 탭의 Privilege Set 이 Auditor(또는 Administrator)인지" "1) Privilege Set on the Account tab is Auditor (or Administrator)")
$(t "2) Custom 이면 Privileges 탭 > Jamf Pro Server Objects > Computers의 Read 가 체크되어 있는지 확인하세요." "2) if Custom, Read is selected for Computers under Privileges tab > Jamf Pro Server Objects.")"
	fi
elif [ "$INV_STATUS" != "200" ]; then
	add_problem "$(t "컴퓨터 인벤토리 조회 실패" "Computer inventory lookup failed") (HTTP $INV_STATUS $INV_ERR)" \
		"$(t "결과 파일의 부록을 로그프레소에 전달하세요." "Send the appendix of the result file to Logpresso.")"
fi
if [ "$CLASSIC_OK" = "no" ] && [ "$CLASSIC_STATUS" != "403" ]; then
	add_problem "$(t "감사 로그 수집기의 컴퓨터 목록 조회 실패" "Computer list lookup of the audit logger failed") (HTTP $CLASSIC_STATUS)" \
		"$(t "결과 파일의 부록을 로그프레소에 전달하세요." "Send the appendix of the result file to Logpresso.")"
fi

finish