본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

GUI 세션, ssh 세션, 그리고 launchd 작업

~18 min · macos-citizen, ssh, aqua, launchd, codesign, fleet

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"한 호스트에서 한 순간에 한 번 찔러본 결과는 찔러본 그 행동에 대한 증거일 뿐이야."

사용자는 하나, 맥락은 셋

Mac 화면에서 로그인하면 macOS가 너한테 Aqua 세션을 만들어줘. 여기엔 윈도 서버, 권한 창을 띄울 수 있는 로그인 키체인, gui/<uid> launchd 도메인이 딸려 와. 같은 사용자로 ssh 로그인하면 파일은 같은데 보안 세션은 달라. launchd 작업은 또 다른 세 번째 맥락이고, 환경도 따로야. 여러 Mac을 다루는 도구가 지금 어느 맥락에서 도는지 까먹으면 앱 버그처럼 보이는 에러가 나와.

ssh 세션이 할 수 있는 것과 없는 것

주인이 콘솔에 로그인해 있는 Mac에 ssh로 들어가서 잰 결과야.

  • 로그인 키체인이 "User interaction is not allowed"라고 대답해. 그래서 거기 든 신원으로 codesign하면 속을 알 수 없는 errSecInternalComponent로 실패해. 자체 서명 신원도, iOS 아카이브에 필요한 Apple Development 신원도 마찬가지야.
  • launchctl asuser는 "Could not switch to audit session … Operation not permitted"로 실패해. 세션을 바꾸려면 root가 필요하거든.
  • 시그널은 평소처럼 돼. 앱에 직접 보내는 Apple Event(tell application "Spark" to quit)도 됐어. 근데 System Events를 거치는 건 에러 -1712가 날 때까지 멈춰 있었어. osascript는 타임아웃을 걸고 백그라운드로 돌려. 저장하지 않은 변경 사항 대화상자에 막힌 종료 명령은 영영 안 돌아오거든.

open은 결론을 느슨하게 잡아둬야 하는 경우야. 7월에 어느 Mac에서 찔러봤을 땐 exit 0인데 아무것도 안 떴어. 9월에 또 다른 Mac에서 찔러봤을 땐 AppKit 앱이 떠서 applicationDidFinishLaunching까지 갔어. 두 측정이 서로 어긋나니까 어느 쪽도 법칙으로 삼으면 안 돼. 둘이 함께 증명하는 건 종료 상태만으론 아무것도 알 수 없다는 거야. 그러니 프로세스가 있는지 직접 확인해.

ssh에서 GUI 세션 일 하기

믿을 만한 다리는 gui/<uid>에 bootstrap하는 일회용 LaunchAgent야. 작업이 Aqua 세션 안에서 도니까 키체인을 쓸 수 있고 서명도 성공해. 창도 안 떠. 가족 iOS 아카이브가 원격 세션에서 이렇게 돌아. AppleScript로 터미널한테 스크립트를 돌리라고 시키는 방법도 끌리지만, 그렇게 했더니 맨 앞에 있던 탭에 경로를 쳐 넣었어. 그 탭은 주인이 보고 있던 시스템 모니터였어. 사람의 세션에 닿으려고 그 사람 앱을 조종하지 마.

이 다리는 콘솔에 로그인한 사용자가 있어야 통해. 로그인 창에 멈춰 있으면 GUI 도메인이 아예 없어서 launchctl bootstrap gui/<uid>125: Domain does not support specified action으로 실패해. 상시 에이전트라면 괜찮아. plist를 ~/Library/LaunchAgents에 넣어두면 그걸로 영구히 활성화된 거고, 다음 로그인 때 RunAtLoad가 켜주거든. 실패라고 하지 말고 대기 중이라고 보고해. 일회용 작업은 기다릴 다음 로그인이 없으니, 똑같이 콘솔 사용자가 없어도 그땐 이름을 대고 실패해야 해.

launchd 작업은 네 셸이 아니야

작업의 PATH는 plist가 따로 안 정하면 launchd 기본값인 /usr/bin:/bin:/usr/sbin:/sbin이라서 Homebrew 도구를 못 찾아. Homebrew를 넣으려고 plist에 PATH를 정하다가 /usr/sbin을 빼먹을 수도 있어. 어떤 엔진의 lsof 검사가 모든 포트에 대해 듣고 있는 프로세스가 없다고 보고한 게 딱 그 경우였어. 로컬 네트워크 개인정보 보호도 달라. 터미널이나 ssh로 돌린 도구는 자동으로 허용되는데 launchd 에이전트는 아니야. 에이전트로 돌던 엔진은 LAN 호스트 이름으로 접속하다 "No route to host"를 받았는데, 같은 사용자의 ssh 셸에서는 잘 접속됐어.

Code

ssh에서 일회용 LaunchAgent로 서명 파이프라인을 GUI 세션 안에서 돌리기·bash
#!/bin/zsh
set -euo pipefail
uid=$(id -u); label=com.example.spark.archive-once
job="$PWD/build/$label.plist"; log="$PWD/build/archive-once.log"

console=$(stat -f %Su /dev/console)
[[ $console == "$USER" ]] || { echo "HARD_FAIL: no GUI session for $USER (console=$console); a one-shot job has no next login" >&2; exit 1; }

mkdir -p build; rm -f "$log"
cat > "$job" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>$label</string>
  <key>ProgramArguments</key><array><string>/bin/zsh</string><string>$PWD/scripts/archive.sh</string></array>
  <key>WorkingDirectory</key><string>$PWD</string>
  <key>EnvironmentVariables</key><dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><false/>
  <key>StandardOutPath</key><string>$log</string>
  <key>StandardErrorPath</key><string>$log</string>
</dict></plist>
PLIST

launchctl bootstrap "gui/$uid" "$job"
for _ in {1..1800}; do grep -q '^PIPELINE_EXIT=' "$log" 2>/dev/null && break; sleep 1; done
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
code=$(sed -n 's/^PIPELINE_EXIT=//p' "$log" 2>/dev/null | tail -1)
[[ -n $code ]] || { echo "TIMEOUT: no exit line in $log" >&2; exit 1; }
echo "PIPELINE_EXIT=$code"; exit "$code"

External links

Exercise

두 번째 Mac에서 서명 신원이 있는 Mac으로 ssh로 들어가. 한 줄짜리 C 프로그램을 컴파일해서 바로 codesign --sign을 해보고 에러를 적어. 이번엔 같은 파일을 서명하고 PIPELINE_EXIT=0을 찍는 작은 scripts/sign-probe.sh를 써서, 위의 일회용 GUI 도메인 작업으로 돌려. 서명됐는지 적어. 마지막으로 EnvironmentVariables 블록이 있을 때와 없을 때 작업이 $PATH를 찍게 해서 ssh 셸의 PATH랑 비교해.
Hint
작업 스크립트에 set -e가 있으면 codesign이 실패할 때 종료 줄을 찍기 전에 끝나서, 기다리는 쪽이 TIMEOUT으로 끝나. 실패도 상태와 함께 보고받고 싶으면 EXIT에 건 trap에서 종료 줄을 찍어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.