# === INGSTORY fleet — salt 등록 + 기기 신원 각인 (Windows) === # # 목적 두 가지를 한 번에 끝낸다. # ① salt-minion 을 설치/수리해서 마스터에 실제로 붙인다. # ② "이 미니언이 매장의 어느 PC인가"를 grains 에 각인한다 — 매장번호·내부IP·COM포트· # NVCAT 단말번호·프린터 포트까지. 그래야 나중에 마스터에서 기기를 특정할 수 있다. # # 왜 ②가 필요한가 (2026-08-26 작심 양주옥정점 594 실측) # 그 매장은 PC 3대 중 2대가 salt 침묵이었고, 어드민 "운영pc" 행이 실제 운영PC가 아니라 # 키오스크 PC를 가리키고 있었다. 이걸 알아내는 데 COM 포트 지문·브로커 직렬화 측정까지 # 동원해야 했다. 등록할 때 아래 값들을 같이 박아두면 그 작업이 SQL 한 줄로 끝난다. # # 안전 # · 하는 일은 salt-minion 설치·설정·기동뿐. 매장 프로그램·프린터·결제단말을 건드리지 않는다. # · 🔴 미니언 자기 키(pki\minion\minion.pem/.pub)는 절대 지우지 않는다. 지우면 키 충돌로 # 영구 차단된다(메모리 salt-minion-pki-wipe-key-conflict). 침묵 상태일 때 지우는 것은 # 캐시된 마스터 공개키(minion_master.pub) 하나뿐이다. # · 기존 minion id 가 있으면 그대로 재사용한다 — 새 ID로 다시 등록되면 유령 키가 늘어난다. # · 릴레이 매장은 -Relay 로 is_relay grain 을 박는다. 빠지면 이후 대량작업 제외필터가 안 걸린다. # # 실행 (관리자 PowerShell) # iex ([Text.Encoding]::UTF8.GetString((New-Object Net.WebClient).DownloadData('https://.../s.ps1'))) # 매장번호를 아는 경우 앞에 붙이면 더 정확하다: $env:FLEET_SPNO='594'; [CmdletBinding()] param( [string]$Token = '', [string]$Master = 'salt.space-force.kr', [string]$Version = '3008.1', [switch]$Relay, [string]$SpNo = '', [string]$Note = '', [ValidateSet('auto', 'op-pc', 'kiosk', 'unknown')] [string]$DeviceType = 'auto', [switch]$CollectOnly # 신원 수집만 하고 salt 는 건드리지 않는다(사전 점검용) ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $STAMP_VER = '1' # 환경변수 우선 — `iex` 는 인자를 못 넘기므로 한 줄 앞에 $env:FLEET_SPNO='594'; 로 준다. if ($env:FLEET_TOKEN) { $Token = $env:FLEET_TOKEN } if ($env:FLEET_SPNO) { $SpNo = $env:FLEET_SPNO } if ($env:FLEET_NOTE) { $Note = $env:FLEET_NOTE } if ($env:FLEET_MASTER){ $Master= $env:FLEET_MASTER } if ($env:FLEET_RELAY) { $Relay = $true } if ($env:FLEET_ROLE -and 'op-pc','kiosk','unknown' -contains $env:FLEET_ROLE) { $DeviceType = $env:FLEET_ROLE } # 🔴 [Console]::OutputEncoding 을 건드리지 않는다. 한국어 콘솔은 949 이고, 여기서 UTF8 로 # 바꾸면 콘솔이 949 로 해석해 한글이 전부 물음표가 된다(2026-08-04 매장 실기 확인). try { [Net.ServicePointManager]::SecurityProtocol = 3072 -bor 12288 } catch { try { [Net.ServicePointManager]::SecurityProtocol = 3072 } catch { } } try { [Net.WebRequest]::DefaultWebProxy = [Net.WebRequest]::GetSystemWebProxy() } catch { } function Say($m, $c = 'Gray') { Write-Host $m -ForegroundColor $c } # 🔴 exit 금지 — iex 컨텍스트에서 exit 는 창을 닫아버려 담당자가 결과를 못 읽는다. function Fin($code) { $global:LASTEXITCODE = $code } $SALTROOT = Join-Path $env:ProgramData 'Salt Project\Salt' $CONFDIR = Join-Path $SALTROOT 'conf' $CFGDIR = Join-Path $CONFDIR 'minion.d' $PKIDIR = Join-Path $CONFDIR 'pki\minion' function Safe($v) { # grains 값으로 안전한 ASCII 만 남긴다. 한글은 별도 b64 필드로 넣는다. if ($null -eq $v) { return '' } return (([string]$v) -replace "[^\x20-\x7E]", '') -replace "['`"]", '' } function B64($v) { if (-not $v) { return '' } return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$v)) } # ── 0) 관리자 권한 ──────────────────────────────────────────────────────── $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin -and -not $CollectOnly) { Say '' Say ' 결과: 실패(권한) — PowerShell 을 "관리자로 실행" 한 뒤 다시 붙여넣어 주세요.' Red Fin 1; return } # ── 1) 신원 수집 — salt 가 붙든 안 붙든 이건 먼저 한다 ──────────────────── Say '[1/6] 기기 신원 수집' $info = [ordered]@{} $info.host = $env:COMPUTERNAME # 내부 IP (127.* 제외) · 기본 게이트웨이(= 매장 공유기) $ips = @(); $gw = '' try { $ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | ForEach-Object { $_.IPAddress }) $gw = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | Sort-Object RouteMetric | ForEach-Object { $_.NextHop } | Where-Object { $_ -and $_ -ne '0.0.0.0' })[0] } catch { $ips = @(Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue | Where-Object { $_.IPEnabled } | ForEach-Object { $_.IPAddress } | Where-Object { $_ -and $_ -notlike '127.*' -and $_ -match '^\d+\.\d+\.\d+\.\d+$' }) $gw = @(Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue | Where-Object { $_.IPEnabled -and $_.DefaultIPGateway } | ForEach-Object { $_.DefaultIPGateway })[0] } $info.lan_ip = ($ips -join ',') $info.gw_ip = $gw # COM 포트 — 594에서 "어느 PC가 카운터인가"를 가른 결정적 지문이다. $coms = @() try { $coms = @([System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object) } catch { } $info.com_ports = ($coms -join ',') # 브로커(WSB)·픽코 앱·BioStar 생존 $procs = @() try { $procs = @((Get-Process -ErrorAction SilentlyContinue).Name | Select-Object -Unique) } catch { } $apps = @() if ($procs -match 'WebSerialBrokerLauncher|WebBrokerSystemTray|usb-agent') { $apps += 'wsb' } if ($procs -contains 'PICKKO-KIOSK') { $apps += 'kiosk' } if ($procs -contains 'PICKKO-POS') { $apps += 'pos' } if ($procs -contains 'PICKKO-WS-AGENT') { $apps += 'ws-agent' } if ($procs -contains 'biostar-server') { $apps += 'biostar-proc' } # 설치 흔적(프로세스-비의존) — 앱 미실행이어도 잡는다. 2026-10-06 실측(op·kiosk 각 30대 대조): # op 확실신호=PICKKO-WS-AGENT 설치(op 10/10, kiosk 0/10). biostar-server 프로세스는 op 강신호(서버 운영 중). # BioStar 서버 풀세트(Web Server+MariaDB)는 kiosk 에도 설치된 사례가 있어(WIN-0007323B445C) 약신호로만 — # ws-agent·kiosk 설치가 모두 없을 때만 op-pc 로 본다. $svcnames = @(); try { $svcnames = @((Get-Service -ErrorAction SilentlyContinue).Name) } catch { } if ((@($svcnames -match '^BioStar 2 Web Server').Count -gt 0) -and (@($svcnames -match '^BioStar 2 MariaDB').Count -gt 0)) { $apps += 'biostar-srv' } $uninst = @() try { $uninst = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall','HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' -ErrorAction SilentlyContinue | ForEach-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).DisplayName }) } catch { } if ($uninst -match 'PICKKO-WS-AGENT') { $apps += 'ws-agent' } if ($uninst -match 'PICKKO-POS') { $apps += 'pos' } if ($uninst -match 'PICKKO-KIOSK|electron-kiosk') { $apps += 'kiosk' } $apps = @($apps | Select-Object -Unique) $info.apps = ($apps -join ',') $l8000 = $false try { $l8000 = [bool](Get-NetTCPConnection -State Listen -LocalPort 8000 -ErrorAction SilentlyContinue) } catch { } $info.broker8000 = $(if ($l8000) { 'true' } else { 'false' }) # NVCAT(NICE VAN) — CatID 는 결제 원장(n2_pay)의 CAT 번호와 1:1 이라 dv_no 확정에 쓸 수 있다. # PRTPORT/PRTBAUD 는 그 PC에 실제로 붙은 영수증 프린터 포트다(어드민 설정값과 대조용). $nv = @{} foreach ($p in 'C:\NICEVCAT\NiceOption.ini', 'C:\NICEVCAT2\NiceOption.ini', 'C:\NVCAT\NiceOption.ini') { if (-not (Test-Path $p)) { continue } try { foreach ($ln in (Get-Content $p -ErrorAction Stop)) { if ($ln -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$') { $nv[$Matches[1].ToUpper()] = $Matches[2] } } } catch { } break } $info.nvcat_catid = Safe $nv['CATID'] $info.nvcat_busno = Safe $nv['BUSNO'] $info.nvcat_prtport = $(if ($nv['PRTPORT']) { 'COM' + (Safe $nv['PRTPORT']) } else { '' }) $info.nvcat_prtbaud = Safe $nv['PRTBAUD'] $nvshop = '' if ($nv['SHOPNAME']) { # ini 는 시스템 ANSI(949)로 저장돼 있다. Get-Content 기본 인코딩이 이미 그걸 따르므로 # 여기서는 그대로 b64 로만 감싼다(설정파일에 한글을 직접 넣지 않기 위해서다). $nvshop = $nv['SHOPNAME'] } $info.nvcat_shop_b64 = B64 $nvshop # TeamViewer ClientID — 팀뷰어 콘솔과 salt 를 잇는 유일하게 실측된 조인키다 # (호스트명은 복제 이미지라 중복, MAC·자산API는 전부 막다른 길이었다 — 메모리 teamviewer-salt-join-key). $tvid = '' foreach ($k in 'HKLM:\SOFTWARE\WOW6432Node\TeamViewer', 'HKLM:\SOFTWARE\TeamViewer') { try { $v = (Get-ItemProperty -Path $k -Name ClientID -ErrorAction Stop).ClientID if ($v) { $tvid = [string][int64]$v; break } } catch { } } $info.tv_id = Safe $tvid # 매장번호 — 아는 값이 있으면 그걸 쓰고, 없으면 로컬 앱 저장소에서 찾는다. function Find-SpNo { foreach ($f in (Get-ChildItem 'C:\Users\*\AppData\Roaming\pickko-ws-agent\storage\system_storage.json' -ErrorAction SilentlyContinue)) { try { $j = Get-Content $f.FullName -Raw -ErrorAction Stop if ($j -match '"sp_no"\s*:\s*"?(\d{1,6})"?') { return $Matches[1] } } catch { } } $globs = @( 'C:\Users\*\AppData\Roaming\pickko-kiosk\Local Storage\leveldb\*', 'C:\Users\*\AppData\Roaming\PICKKO-POS\Local Storage\leveldb\*', 'C:\Users\*\AppData\Roaming\electron-kiosk\Local Storage\leveldb\*' ) foreach ($f in (Get-ChildItem $globs -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer })) { try { $t = [IO.File]::ReadAllText($f.FullName, [Text.Encoding]::ASCII) if ($t -match 'sp_no\D{0,6}(\d{1,6})') { return $Matches[1] } } catch { } } return '' } if (-not $SpNo) { try { $SpNo = Find-SpNo } catch { $SpNo = '' } } $info.sp_no = Safe $SpNo # 공인 IP — 어드민 n2_device.dv_ip 와 대조해 매장을 확정하는 열쇠다. 실패해도 계속 진행한다. $pub = '' foreach ($u in 'https://ifconfig.me/ip', 'https://api.ipify.org', 'https://icanhazip.com') { try { $pub = ((New-Object Net.WebClient).DownloadString($u)).Trim(); if ($pub -match '^\d+\.\d+\.\d+\.\d+$') { break } else { $pub = '' } } catch { } } $info.pub_ip = $pub # 기기 종류 if ($DeviceType -ne 'auto') { $dtype = $DeviceType } else { # 2026-10-06 실측(op·kiosk 각 30대, 분류표 class_recommended 와 일치). 우선순위: # ①op 강신호(ws-agent 설치/실행, biostar-server 실행) ②kiosk 설치/실행 ③BioStar 서버 풀세트(약신호) # ④ws-agent 폴더 ⑤kiosk 폴더 ⑥WSB 잔존. biostar-server 실행이 kiosk 설치보다 앞서는 건 분류표 로직 그대로다. $dtype = 'unknown' if ($apps -contains 'ws-agent' -or $apps -contains 'biostar-proc') { $dtype = 'op-pc' } elseif ($apps -contains 'kiosk' -or $apps -contains 'pos') { $dtype = 'kiosk' } elseif ($apps -contains 'biostar-srv') { $dtype = 'op-pc' } elseif (Get-ChildItem 'C:\Users\*\AppData\Roaming\pickko-ws-agent' -ErrorAction SilentlyContinue) { $dtype = 'op-pc' } elseif (Get-ChildItem 'C:\Users\*\AppData\*\pickko-kiosk','C:\Users\*\AppData\*\electron-kiosk' -ErrorAction SilentlyContinue) { $dtype = 'kiosk' } elseif ($apps -contains 'wsb') { $dtype = 'kiosk' } } $info.devicetype = $dtype Say (" 호스트 {0} · 내부IP {1} · COM {2}" -f $info.host, $info.lan_ip, $(if ($info.com_ports) { $info.com_ports } else { '없음' })) Say (" 매장 {0} · 종류 {1} · 앱 {2}" -f $(if ($info.sp_no) { $info.sp_no } else { '미상' }), $dtype, $(if ($info.apps) { $info.apps } else { '없음' })) if ($info.nvcat_catid) { Say (" NVCAT CatID {0} · 프린터 {1}@{2}" -f $info.nvcat_catid, $info.nvcat_prtport, $info.nvcat_prtbaud) } if ($CollectOnly) { Say '' Say ' 수집만 수행(-CollectOnly). salt 는 건드리지 않았습니다.' Yellow ($info.GetEnumerator() | ForEach-Object { "{0}={1}" -f $_.Key, $_.Value }) -join ' | ' Fin 0; return } # ── 2) 미니언 ID — 기존 값이 있으면 무조건 재사용한다 ───────────────────── $mid = ''; $idNote = '' $idFile = Join-Path $CONFDIR 'minion_id' if (Test-Path $idFile) { try { $mid = (Get-Content $idFile -Raw -ErrorAction Stop).Trim() } catch { } } # 🔴 minion.d 만 보면 안 된다. 설치본이 쓰는 id 는 대개 conf\minion 본체에 있다. # 이걸 빠뜨려 MAC 으로 새 ID 를 만들어버린 사고가 있었다(2026-08-26, WIN-...41DE → ...41DD). $confFiles = @() if (Test-Path (Join-Path $CONFDIR 'minion')) { $confFiles += (Get-Item (Join-Path $CONFDIR 'minion')) } if (Test-Path $CFGDIR) { $confFiles += @(Get-ChildItem $CFGDIR -Filter *.conf -ErrorAction SilentlyContinue) } if (-not $mid) { foreach ($f in $confFiles) { try { $m = [regex]::Match((Get-Content $f.FullName -Raw), '(?m)^\s*id:\s*(\S+)\s*$') if ($m.Success) { $mid = $m.Groups[1].Value.Trim("'`""); break } } catch { } } } # 그래도 못 찾았는데 salt 가 이미 깔려 있다면, 실행 중인 미니언에게 직접 묻는다. # 여기서 MAC 으로 새로 만들면 같은 기기가 다른 ID 로 재등록돼 유령 키가 생긴다. if (-not $mid -and (Get-Service -Name 'salt-minion' -ErrorAction SilentlyContinue)) { $sc = 'C:\Program Files\Salt Project\Salt\salt-call.exe' if (Test-Path $sc) { try { $out = & $sc --local --out=newline_values_only grains.get id 2>$null if ($out) { $mid = ([string]$out).Trim() } } catch { } } } if ($mid) { $idNote = ' (기존 ID 재사용)' } else { # 🔴 어댑터가 여럿이면 고르는 규칙이 흔들려 같은 기기가 두 번 등록된다. # 가상 배제 → 유선 우선 → MAC 사전순으로 연결상태와 무관하게 결정론화한다. $VIRT = 'vEthernet|Hyper-V|VirtualBox|VMware|Loopback|Bluetooth|TAP|TeamViewer|Npcap|WAN Miniport|Wintun|WireGuard|Tailscale' $cands = @() try { $cands = Get-NetAdapter -Physical -ErrorAction Stop | Where-Object { $_.MacAddress -and $_.InterfaceDescription -notmatch $VIRT -and $_.Name -notmatch $VIRT } | ForEach-Object { [pscustomobject]@{ Mac = $_.MacAddress Rank = $(if ($_.MediaType -eq '802.3' -or $_.PhysicalMediaType -eq '802.3') { 0 } else { 1 }) } } } catch { $cands = Get-WmiObject Win32_NetworkAdapter -ErrorAction SilentlyContinue | Where-Object { $_.PhysicalAdapter -and $_.MACAddress -and $_.Name -notmatch $VIRT } | ForEach-Object { [pscustomobject]@{ Mac = $_.MACAddress; Rank = 1 } } } $pick = $cands | Sort-Object @{E = { $_.Rank }}, @{E = { if ($_.Mac -match '^02') { 1 } else { 0 } }}, @{E = { ($_.Mac -replace '[:-]', '') }} | Select-Object -First 1 if ($pick) { $mid = 'WIN-' + ($pick.Mac -replace '[:-]', '').ToUpper() } else { $sn = (Get-WmiObject Win32_BIOS -ErrorAction SilentlyContinue).SerialNumber $sn = ($sn -replace '[^A-Za-z0-9]', '') $mid = $(if ($sn) { "WIN-SN-$sn" } else { "WIN-HOST-$env:COMPUTERNAME" }) $idNote = ' (MAC 미검출 → 대체 ID)' } } $info.minion_id = $mid Say "[2/6] 미니언 ID = $mid$idNote" # ── 3) 마스터로 나가는 길 ──────────────────────────────────────────────── Say "[3/6] 마스터 연결 확인 ($Master 4505/4506)" $blocked = @() foreach ($p in 4505, 4506) { $c = New-Object Net.Sockets.TcpClient try { $null = $c.BeginConnect($Master, $p, $null, $null).AsyncWaitHandle.WaitOne(4000, $false) if ($c.Connected) { Say " $p 열림" Green } else { $blocked += $p } } catch { $blocked += $p } finally { $c.Close() } } if ($blocked.Count -gt 0) { Say '' Say (" 결과: 실패(방화벽) — $Master 의 " + ($blocked -join ',') + ' 아웃바운드가 막혀 있습니다.') Red Say (" {0} · {1} · sp_no={2} · COM={3}" -f $mid, $info.host, $info.sp_no, $info.com_ports) Yellow Fin 2; return } # ── 4) 설치 또는 수리 ──────────────────────────────────────────────────── $svc = Get-Service -Name 'salt-minion' -ErrorAction SilentlyContinue $freshInstall = $false $stale = $false if ($svc) { Say '[4/6] 이미 설치됨 → 설정 재작성 + 침묵 여부 점검' Yellow # 침묵 미니언(인증 무한루프)의 원인은 캐시된 옛 마스터 공개키다. 그 파일 하나만 지운다. # 🔴 자기 키(minion.pem/.pub)는 절대 건드리지 않는다 — 지우면 키 충돌로 영구 차단된다. $mpub = Join-Path $PKIDIR 'minion_master.pub' if (Test-Path $mpub) { try { # 판정은 "충분히 오래 돌았는데도 4505 연결이 없다" 하나로 한다. # 로그의 'Token failed to decrypt' 는 원인이 아니라 계수기라 옛 줄이 남아 있을 수 있어 # 단독 근거로 쓰지 않는다(메모리 salt-minion-auth-loop-stale-master-pub). $est = Get-NetTCPConnection -RemotePort 4505 -State Established -ErrorAction SilentlyContinue $upMin = 999 try { $p = Get-Process -Name 'salt-minion' -ErrorAction SilentlyContinue | Sort-Object StartTime | Select-Object -First 1 if ($p) { $upMin = ((Get-Date) - $p.StartTime).TotalMinutes } } catch { } if (-not $est -and $svc.Status -eq 'Running' -and $upMin -gt 3) { $stale = $true } } catch { } } if ($stale) { # 🔴 여기서 지우면 안 된다. 서비스가 돌고 있는 동안엔 이 파일이 잠겨 Remove-Item 이 조용히 # 실패한다(2026-08-26 594 실측: "설치는 됐으나 미연결"의 실제 원인). 반드시 # stop → delete → start 순서로 해야 하므로, 삭제는 아래 재기동 단계에서 같이 처리한다. Say ' 침묵(인증루프) 정황 → 재기동할 때 캐시된 마스터 공개키를 지웁니다' Yellow $info.repaired = 'master_pub' } } else { $arch = $(if ([Environment]::Is64BitOperatingSystem) { 'AMD64' } else { 'x86' }) Say "[4/6] salt-minion 설치 ($arch, $Version) — 2~3분" $exe = Join-Path $env:TEMP 'salt-minion-setup.exe' $url = "https://packages.broadcom.com/artifactory/saltproject-generic/windows/$Version/Salt-Minion-$Version-Py3-$arch-Setup.exe" $got = $false foreach ($try in 1..3) { try { Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing -TimeoutSec 300; $got = $true; break } catch { Say " 내려받기 실패 ($try/3)" Yellow if ($try -eq 2) { try { (New-Object Net.WebClient).DownloadFile($url, $exe); $got = $true; break } catch { } } Start-Sleep 5 } } if (-not $got -or (Get-Item $exe -ErrorAction SilentlyContinue).Length -lt 20MB) { Say '' Say ' 결과: 실패(다운로드) — 매장 인터넷/프록시/백신을 확인해 주세요.' Red Fin 3; return } Start-Process -FilePath $exe -ArgumentList "/S /minion-name=$mid /master=$Master /start-minion=0" -Wait Remove-Item $exe -Force -ErrorAction SilentlyContinue if (-not (Get-Service -Name 'salt-minion' -ErrorAction SilentlyContinue)) { Say '' Say ' 결과: 실패(설치) — salt-minion 서비스가 만들어지지 않았습니다(백신 차단 확인).' Red Fin 4; return } $freshInstall = $true } # ── 5) 설정 + 신원 각인 ────────────────────────────────────────────────── Say '[5/6] 설정 주입 + 신원 grains 각인' if (-not $Token) { # 토큰이 없으면 기존 설정에서 물려받는다(재실행 시 토큰 없이도 동작하게). foreach ($f in (Get-ChildItem $CFGDIR -Filter *.conf -ErrorAction SilentlyContinue)) { try { $m = [regex]::Match((Get-Content $f.FullName -Raw), '(?m)^\s*fleet_token:\s*(\S+)\s*$') if ($m.Success) { $Token = $m.Groups[1].Value.Trim("'`""); break } } catch { } } } # 토큰이 없으면 실패로 끝내지 않고 '승인 대기'로 등록한다(영구 공개 주소판, 2026-11-13 NY5). # 키는 마스터에 pending 으로 들어가고, 운영자가 매장 IP 를 대조해 accept-minion.sh 로 수락한다. $pending = -not $Token New-Item -ItemType Directory -Force -Path $CFGDIR | Out-Null # 🔴 minion.d 에 옛 설정이 남아 있으면 두 마스터가 공존해 재인증 폭풍이 난다(repoint.ps1 사고). # _schedule.conf 는 salt 가 스스로 쓰는 파일이라 남긴다. 나머지 우리 설정은 하나로 통일한다. foreach ($f in (Get-ChildItem $CFGDIR -Filter *.conf -ErrorAction SilentlyContinue)) { if ($f.Name -eq '_schedule.conf' -or $f.Name -eq 'fleet.conf') { continue } Remove-Item $f.FullName -Force -ErrorAction SilentlyContinue } # 아래 루프가 키 앞에 stamp_ 를 붙이므로 여기서는 접두어 없이 둔다(안 그러면 stamp_stamp_at 이 된다). $info.at = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss') $info.ver = $STAMP_VER # SAC(스마트 앱 컨트롤) 상태 — 읽기만 한다. 0 꺼짐 / 1 켜짐 / 2 평가 / NA 값 없음(Win10 등). # 켜져 있으면 서명 없는 웹시리얼·NICEVCAT·PICKKO-POS 를 부팅 때 들쭉날쭉 차단한다(원장 TQ4). # 스크립트가 끄지 않는다: 공식 끄기는 Windows 보안 UI 뿐이고 레지스트리 편집은 "전부 막힘" 사례가 있다. $info.sac = 'NA' try { $sacv = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy' -ErrorAction Stop).VerifiedAndReputablePolicyState if ($null -ne $sacv) { $info.sac = [string]$sacv } } catch { } # 🔴 `id:` 는 **신규 설치일 때만** 쓴다. 이미 등록된 기기에 id 를 다시 쓰면, 우리가 고른 값이 # 실제 등록 ID 와 1글자라도 다를 때 다음 재기동에서 **다른 ID 로 재등록**돼 유령 키가 생긴다. # 기존 기기는 설치본이 쓴 conf\minion 의 id 를 그대로 두는 것이 항상 옳다. $lines = @("master: $Master") if ($freshInstall) { $lines += "id: $mid" } $lines += @( 'grains:' " devicetype: $dtype" ) if (-not $pending) { $lines += " fleet_token: $Token" } if ($Relay) { $lines += ' is_relay: true' } if ($SpNo -and ($SpNo -match '^\d{1,6}$')) { $lines += " sp_no: '$SpNo'" } if ($Note) { $lines += (" enroll_note: '{0}'" -f (Safe $Note)) } foreach ($k in 'host','lan_ip','gw_ip','pub_ip','com_ports','apps','broker8000','tv_id', 'nvcat_catid','nvcat_busno','nvcat_prtport','nvcat_prtbaud','nvcat_shop_b64', 'at','ver','sac') { $v = Safe $info[$k] if ($v -ne '') { $lines += (" stamp_{0}: '{1}'" -f $k, $v) } } if (-not $pending) { $lines += @('autosign_grains:', ' - fleet_token') } # 🔴 BOM 이 붙으면 Salt YAML 파서가 파일을 통째로 거부한다 — 조용히 미등록이 된다. # 값은 전부 ASCII 로 정제했으므로 ASCII 인코딩으로 쓴다. [IO.File]::WriteAllText((Join-Path $CFGDIR 'fleet.conf'), ($lines -join "`n") + "`n", (New-Object Text.ASCIIEncoding)) # 로컬에도 한 벌 남긴다 — salt 가 끝내 안 붙어도 사람이 읽어 기기를 특정할 수 있게. try { $dump = ($info.GetEnumerator() | ForEach-Object { "{0}={1}" -f $_.Key, $_.Value }) -join "`n" [IO.File]::WriteAllText((Join-Path $env:ProgramData 'ingstory-fleet-stamp.txt'), $dump + "`n", (New-Object Text.UTF8Encoding($false))) } catch { } Set-Service -Name 'salt-minion' -StartupType Automatic # 🔴 이 스크립트가 salt 로 원격 실행 중이라면(= 부모가 salt-minion), 여기서 Restart-Service 를 # 부르면 **자기를 실행한 서비스를 죽여 스크립트도 같이 죽는다.** 그러면 stop 까지만 되고 # start 를 못 해 미니언이 영영 꺼진 채로 남는다(2026-08-26 카나리아에서 실제로 발생). # 그래서 salt 경유일 때는 작업 스케줄러에 일회성 작업을 만들어 프로세스 트리 밖에서 재기동한다. $viaSalt = $false try { $pid_ = $PID for ($h = 0; $h -lt 5 -and $pid_; $h++) { $pp = Get-CimInstance Win32_Process -Filter "ProcessId=$pid_" -ErrorAction Stop if (-not $pp) { break } if ($pp.Name -match '^salt-minion') { $viaSalt = $true; break } $pid_ = $pp.ParentProcessId } } catch { } function Restart-Detached([bool]$dropMasterPub = $false) { # 🔴 `&` 가 들어간 명령을 schtasks /tr 에 직접 넘기면 인용이 깨진다("잘못된 구문", 실측). # 작은 .cmd 파일로 빼고, 프로세스 트리 밖에서 띄운다. # 1순위 = WMI Win32_Process.Create — 새 프로세스의 부모가 WmiPrvSE 라 우리 트리가 # 통째로 죽어도 살아남는다. 실패 시 작업 스케줄러로 폴백한다. $bat = Join-Path $env:ProgramData 'ingstory-salt-restart.cmd' $del = '' if ($dropMasterPub) { # 서비스가 멈춘 사이에 지워야 파일 잠금에 걸리지 않는다. 자기 키는 건드리지 않는다. $del = 'del /f /q "' + (Join-Path $PKIDIR 'minion_master.pub') + '"' + "`r`n" } $body = "@echo off`r`nping 127.0.0.1 -n 6 >nul`r`nnet stop salt-minion`r`nping 127.0.0.1 -n 3 >nul`r`n" + $del + "net start salt-minion`r`n" [IO.File]::WriteAllText($bat, $body, (New-Object Text.ASCIIEncoding)) $line = 'cmd.exe /c "' + $bat + '"' try { $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $line } -ErrorAction Stop if ($r.ReturnValue -eq 0) { return $true } } catch { } try { $r = ([wmiclass]'Win32_Process').Create($line) if ($r.ReturnValue -eq 0) { return $true } } catch { } try { $tn = 'IngstoryFleetSaltRestart' & schtasks.exe /create /tn $tn /tr ('"' + $bat + '"') /sc once /st 23:59 /ru SYSTEM /rl HIGHEST /f | Out-Null & schtasks.exe /run /tn $tn | Out-Null return $true } catch { } return $false } $ok = $false if ($viaSalt) { Say '[6/6] salt 경유 실행 감지 → 재기동을 작업 스케줄러에 넘깁니다(자기 자신을 죽이지 않기 위해)' $null = Restart-Detached $stale Say ' 재기동 예약 완료. 마스터에서 test.ping 으로 확인하세요.' Yellow } else { # stop → (필요시) 마스터 공개키 삭제 → start. 돌고 있는 동안엔 그 파일이 잠겨 삭제가 실패한다. if ((Get-Service salt-minion).Status -eq 'Running') { Stop-Service -Name 'salt-minion' -Force -ErrorAction SilentlyContinue Start-Sleep 3 } if ($stale) { try { Remove-Item (Join-Path $PKIDIR 'minion_master.pub') -Force -ErrorAction Stop Say ' 캐시된 마스터 공개키 삭제 완료' Green } catch { Say (' 마스터 공개키 삭제 실패: ' + $_.Exception.Message) Yellow } } Start-Service salt-minion -ErrorAction SilentlyContinue if ((Get-Service salt-minion).Status -ne 'Running') { $null = Restart-Detached $stale; Start-Sleep 12 } # ── 검증 — "설치했다"가 아니라 "마스터에 붙었다"까지 본다 ───────────── $waitN = 24 if ($pending) { # 운영자가 수락하는 동안 기다려야 이 창에서 '성공'까지 볼 수 있다. $waitN = 120 Say '' # 지문 = salt 의 pem_finger 와 같은 계산(머리·꼬리 줄 뺀 본문 줄을 LF 로 이어 sha256). # 운영자는 accept-minion.sh 에 이 앞 8마디를 넣어, 엉뚱한 기기의 키를 수락하지 않게 한다. $fp = '(키 생성 대기 중 — 잠시 뒤 다시 실행)' $pub = Join-Path $PKIDIR 'minion.pub' foreach ($j in 1..12) { if (Test-Path $pub) { break }; Start-Sleep 5 } try { $ls = @([IO.File]::ReadAllLines($pub) | Where-Object { $_.Trim() }) $body = ($ls[1..($ls.Count - 2)] | ForEach-Object { $_ + "`n" }) -join '' $h = [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($body)) $fp = (($h[0..7] | ForEach-Object { $_.ToString('x2') }) -join ':') } catch { } Say ' 승인 대기 — 담당자에게 아래 두 줄과 매장번호를 알려 주세요.' Yellow Say (' ID : ' + $mid) Yellow Say (' 지문 : ' + $fp) Yellow try { Set-Clipboard -Value ("$mid $fp") -ErrorAction SilentlyContinue } catch { } Say '' } Say ('[6/6] 등록 확인 (최대 {0}초)' -f ($waitN * 5)) foreach ($i in 1..$waitN) { Start-Sleep 5 $conn = Get-NetTCPConnection -RemotePort 4505 -State Established -ErrorAction SilentlyContinue if ($conn) { $ok = $true; break } if ((Get-Service salt-minion).Status -ne 'Running') { Start-Service salt-minion -ErrorAction SilentlyContinue } } } $oneline = ("{0} | sp_no={1} | {2} | host={3} | lan={4} | gw={5} | pub={6} | COM={7} | prt={8}@{9} | cat={10} | tv={11} | apps={12}" -f ` $mid, $info.sp_no, $dtype, $info.host, $info.lan_ip, $info.gw_ip, $info.pub_ip, $info.com_ports, $info.nvcat_prtport, $info.nvcat_prtbaud, $info.nvcat_catid, $info.tv_id, $info.apps) try { Set-Clipboard -Value $oneline -ErrorAction SilentlyContinue } catch { } Say '' Say ('=' * 74) Cyan $verdict = '설치는 됐으나 미연결' if ($ok) { $verdict = '성공 — 마스터 연결 확인됨' } elseif ($viaSalt) { $verdict = '설정·각인 완료 (재기동 예약됨 — 마스터에서 확인)' } elseif ($pending) { $verdict = '승인 대기 — 담당자가 승인하면 자동으로 연결됩니다(다시 실행할 필요 없음)' } Say (" 결과: {0}" -f $verdict) Cyan Say (" $oneline") Cyan Say ' 위 한 줄이 클립보드에도 복사돼 있습니다. 그대로 붙여넣어 전달해 주세요.' Cyan # 미연결로 끝나면 미니언 로그의 인증 관련 줄을 같이 보여준다. 여기서 마스터측 키 불일치인지 # (→ 마스터에서 그 키만 salt-key -d 로 지우고 재실행) 아웃바운드 문제인지가 갈린다. if (-not $ok -and -not $viaSalt) { $log = Join-Path $SALTROOT 'var\log\salt\minion' if (Test-Path $log) { Say ' 미니언 로그(인증 관련 마지막 줄):' Yellow try { Get-Content $log -Tail 200 -ErrorAction SilentlyContinue | Select-String -Pattern 'auth|master|key|token|refus|denied' | Select-Object -Last 6 | ForEach-Object { Say (' ' + $_.Line) } } catch { } } } if ($info.sac -eq '1' -or $info.sac -eq '2') { Say (' [조치 필요] 스마트 앱 컨트롤(SAC)이 켜져 있습니다 (상태값 {0}). 서명 없는 픽코 프로그램이 부팅 때 차단됩니다.' -f $info.sac) Yellow Say ' Windows 보안 > 앱 및 브라우저 컨트롤 > 스마트 앱 컨트롤 설정 > 끄기. 끈 뒤 상태값 0 을 다시 확인하세요.' Yellow } elseif ($info.sac -eq '0') { Say ' SAC 꺼짐 확인 (상태값 0)' Green } Say ('=' * 74) Cyan Fin $(if ($ok) { 0 } elseif ($pending) { 7 } else { 5 }) return