권한 점검 스크립트
확장 명령어나 수집기가 권한 오류(INVALID_PRIVILEGE)로 실패하는데 원인을 찾기 어렵다면, 점검 스크립트로 소나 접속 프로파일의 설정 그대로 Jamf Pro API를 직접 호출해 확인할 수 있습니다. 계정이 실제로 가진 권한과, 앱이 쓰는 API의 성공 여부를 함께 보여 줍니다.
- 조회만 하며 Jamf Pro나 소나의 설정을 바꾸지 않습니다. 항목마다 1건만 조회하고, 점검이 끝나면 발급받은 토큰을 바로 무효화합니다.
- 결과는 한국어로 출력하며,
-l en옵션을 주면 영어로 출력합니다.
스크립트 구성
| 스크립트 | 실행할 서버 | 하는 일 |
|---|---|---|
sonar_profile_export.sh | 소나 웹 콘솔(API)에 접속할 수 있는 서버 | 소나 접속 프로파일의 설정값(API 주소, 계정, HTTP 프록시)을 파일로 저장 |
jamf_permission_check.sh | Jamf Pro에 접속할 수 있는 서버 | 저장한 파일과 계정 암호로 Jamf Pro API를 호출해 권한을 점검 |
소나 서버에서 Jamf Pro에 직접 접속할 수 있다면 두 스크립트를 같은 서버에서 실행해도 됩니다.
준비물
- 리눅스 서버의
bash,curl명령 (awk,sed등 기본 명령 외에 별도 설치 불필요) - 소나 API 키: 소나 웹 콘솔 계정 > 계정 > (해당 계정) > API 키에서 발급합니다. 점검할 접속 프로파일을 볼 수 있는 계정이어야 합니다.
- 접속 프로파일 계정의 암호: 소나는 보안을 위해 암호를 API로 제공하지 않으므로, 점검할 때 직접 입력합니다.
단계 1: 스크립트 파일 만들기
이 페이지 맨 아래 스크립트 전문의 두 스크립트를 각각 같은 이름의 파일로 저장합니다. 복사한 내용을 붙여 넣을 때 줄바꿈이 바뀌지 않도록 리눅스 서버에서 직접 편집기로 저장하는 것을 권장합니다.
단계 2: 접속 프로파일 값 추출
소나에 접속할 수 있는 서버에서 실행합니다. Jamf Pro 접속 프로파일의 유형은 jamf-pro입니다. 모든 Jamf Pro 접속 프로파일을 한 번에 저장하려면 -t jamf-pro를 지정합니다.
특정 접속 프로파일만 저장하려면 -t 대신 -n에 식별자를 지정합니다. 쉼표로 여러 개를 지정할 수 있습니다.
실행하면 소나 API 키를 묻습니다. 입력한 키는 화면에 표시되지 않으며, SONAR_API_KEY 환경 변수로 줄 수도 있습니다. 소나는 보통 사설 인증서를 쓰므로 스크립트는 소나에 접속할 때 인증서를 검증하지 않습니다.
접속 프로파일마다 profile_(식별자)_(실행시각).txt 파일이 만들어집니다. 이 파일에는 암호 같은 보호 항목이 들어 있지 않으며, 실행한 계정만 읽을 수 있습니다.
단계 3: 권한 점검 실행
단계 2에서 만든 파일을 Jamf Pro에 접속할 수 있는 서버로 옮긴 뒤 실행합니다. 실행하면 계정의 암호를 묻습니다. 입력한 암호는 화면에 표시되지 않으며 결과 파일에도 저장되지 않습니다. 접속 프로파일이 여러 개라면 파일마다 실행합니다.
| 옵션 | 필수 여부 | 설명 |
|---|---|---|
-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§ion=OPERATING_SYSTEM§ion=HARDWARE§ion=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