Skip to content
Code on the Road
Go back

【常駐化編】RDPを切ると自動ログインが死ぬ — tsconでセッションをconsoleに付け替えて無人運用する

自動ログイン編・後編で、証券アプリへの毎朝2FAログインを UI Automation(要素ベース) で自動化した。検証は通った。本番VPSにも載せた。これで無人運用の完成——のはずだった。

ところが、妙な症状が出た。Macから RDP で繋ぎっぱなしにしていた翌朝は成功するのに、RDP を切って寝た翌朝は失敗する。サーバーは動いている。バックグラウンドのプロセスも cron 相当のタスクも生きている。GUI 自動化だけが落ちる。

「無人で回す」ための自動化なのに、「人が RDP で繋いでいないと動かない」。本末転倒だ。今回はこれを潰した記録。

TL;DR

症状:RDP を切ると翌朝こける

毎朝 6:35 に証券アプリへ自動ログインするタスクスケジューラを組んでいる。中身は前回作った uiautomation のスクリプトだ。

前夜の状態翌朝6:35の結果
Mac から RDP 接続したまま成功
RDP を切断して寝た失敗
サーバー再起動直後(誰も繋いでいない)成功

3行目が混乱の元だった。「誰も繋いでいなくても成功する場合がある」ので、しばらく原因を RDP だと思えなかった。

原因:Disconnected セッションには「描画面」が無い

Windows のセッションには状態がある。query session で見えるやつだ。

セッション名  ユーザー名  ID  状態
 services                 0   Disc
 console                  1   Active
 rdp-tcp#3    user        2   Active

ここが核心。

UI Automation は「今そこに描かれている UI ツリー」を辿る仕組みなので、描画面が無いセッションでは要素を掴めない。バックグラウンド処理が平気なのは、そもそも描画を必要としないから。GUI 自動化だけが落ちるのはこのためだった。

つまり正確な条件は「Mac が繋がっていないと失敗」ではなく、

対話デスクトップが描画(active)されていないと失敗

だった。Mac の RDP 接続は、たまたまその条件を満たしていただけだ。

「再起動直後だけ成功する」の正体

AutoAdminLogon=1(自動ログオン)を有効にしていると、再起動直後はコンソールセッション(ID 1)が Active な状態で立ち上がる。だからこのときは成功する。

ところが、そこへ一度 RDP で入って切断すると、セッションは Disconnected に落ちたまま戻ってこない。**「再起動直後は動く/一度 RDP を触ると壊れる」**という、いちばん切り分けを難しくする挙動が生まれる。

解決策① セッションを分裂させない

まず土台。レジストリで単一セッション制限を入れる。

Set-ItemProperty `
  -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
  -Name 'fSingleSessionPerUser' -Value 1 -Type DWord

これで、同じユーザーが RDP で入ってきたとき、新しいセッションを作らずに既存のセッション(console)を引き取る。セッションが2つに分裂しないので、後で「どっちを console に戻すのか」で迷わなくて済む。

query session で見ると、RDP 接続中も ID は 1 のまま(名前だけ rdp-tcp#N になる)。

解決策② 切断イベントで console に自動で付け替える

本命はこっち。RDP を切断したら、そのセッションを物理コンソールに付け替える。コンソールは常に active なので、描画面が維持される。

使うのは tscon

tscon 1 /dest:console

tsconセッションの「端末」を付け替えるだけで、中で動いているプロセスは一切止めない。証券アプリも自作システムも起動したまま、描画先だけが console に移る。

これを手で叩くのではなく、切断イベントで自動発火させる。

トリガーにするイベント

項目
ログ名Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
EventID24(セッション切断)

参考までに 25 は再接続。今回は 24 だけ拾えばいい。

タスクスケジューラの XML サブスクリプションはこう:

<QueryList>
  <Query Id="0" Path="Microsoft-Windows-TerminalServices-LocalSessionManager/Operational">
    <Select Path="Microsoft-Windows-TerminalServices-LocalSessionManager/Operational">
      *[System[(EventID=24)]]
    </Select>
  </Query>
</QueryList>

タスク側の設定は 実行アカウント=SYSTEM / RunLevel=Highest / MultipleInstances=IgnoreNewtscon で他人のセッションを触るには SYSTEM 権限が要る。

実際に切断テストした結果

20:36:18  tscon 1 /dest:console  (state=Disc)
20:36:22  after:
 >services         0  Disc
  console  <user>  1  Active     ← 切断後も console が Active で維持

RDP を切った4秒後に console へ付け替わり、Active が維持されている。翌朝の自動ログインも通るようになった。

ここからが本番:スクリプトが SYSTEM で動かない

仕組み自体は上で完結している。実際に時間を溶かしたのは、**「手元では動くスクリプトが、SYSTEM 権限+日本語環境でだけ動かない」**という三重苦のほうだった。ここが今回いちばん共有する価値がある部分だと思う。

罠① PowerShell 5.1 が BOM 無し UTF-8 を Shift-JIS として読む

症状:タスクから powershell.exe -File tscon-console.ps1 を実行すると、こう落ちる。

Unexpected token '}' in expression or statement

該当行を見に行くと、そこには } が1文字あるだけ。文法的におかしいところが無い。

原因:スクリプトに日本語コメントを書いていて、ファイルが BOM 無し UTF-8 で保存されていた。Windows PowerShell 5.1 は、BOM の無いファイルをシステム ANSI(日本語環境なら Shift-JIS)として読む。全角文字のバイト列が壊れ、そこから先のパースが連鎖的に崩れて、無関係な行でエラーになる。

対策UTF-8 with BOM で保存する

# BOM 付きで書き出す
$utf8bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($path, $content, $utf8bom)

エディタでは正しく見えるのに実行だけ失敗する——そういうときは文字コードを疑う。PowerShell 7 なら既定 UTF-8 なので起きないが、Windows Server 標準の 5.1 では現役の罠。

罠② query session はヘッダだけ日本語・状態値は英語

症状:出力をパースする実装が、SYSTEM で実行したときだけ「ヘッダ解析失敗」で全滅。手で叩くと通る。

原因query session の出力ヘッダは実行アカウントのロケールで変わる。SYSTEM+日本語環境だと:

 セッション名      ユーザー名             ID  状態

対話ユーザーで手打ちしたときは英語ヘッダ(SESSIONNAME / USERNAME / STATE)だったので気づかない。$header.IndexOf('STATE') のような実装が -1 を返して死ぬ。

そして意地が悪いのが、ヘッダは日本語になるのに状態値は英語のままActive / Disc / Conn / Listen)という点。

対策:ヘッダ文字列に依存せず、固定列オフセットでスライスする。状態値は英語なので判定はそのまま使える。

# 値行のみ対象。USERNAME は 26 桁目〜、44 桁目以降を空白分割して [ID, STATE]
$user  = $line.Substring(26, 18).Trim()
$tail  = $line.Substring(44).Trim() -split '\s+'
$id    = [int]$tail[0]
$state = $tail[1]   # Active / Disc / Conn / Listen(英語)

native コマンドの出力は実行アカウントのロケールで変わる。手元で動いても SYSTEM で壊れる。

罠③ query.exe は成功しても exit code 1 を返す

症状(query session) 2>$null と書いていたら、SYSTEM 実行時に**出力が空(0行)**になる。

原因query.exe は正常時でも LASTEXITCODE=1 を返すことがある。これが $ErrorActionPreference の設定やリダイレクトと噛み合うと、出力そのものが欠落する

対策ProcessStartInfostdout を直接読む

function Get-QuerySession {
  $psi = New-Object System.Diagnostics.ProcessStartInfo
  $psi.FileName  = Join-Path $env:SystemRoot 'System32\query.exe'
  $psi.Arguments = 'session'
  $psi.RedirectStandardOutput = $true
  $psi.RedirectStandardError  = $true
  $psi.UseShellExecute = $false
  $psi.CreateNoWindow  = $true
  $p = [System.Diagnostics.Process]::Start($psi)
  $out = $p.StandardOutput.ReadToEnd()
  $p.StandardError.ReadToEnd() | Out-Null
  $p.WaitForExit()
  return $out
}

終了コードが当てにならない native コマンドから確実に出力を取りたいときは、ProcessStartInfo が堅い。

事故らない書き方

tscon はセッションを触るコマンドなので、対象の選び方を間違えると事故る。とくに tscon 0(services セッション)は絶対にやってはいけない

# 対象ユーザーの Disconnected セッションだけを選ぶ。
# services(ID 0) / console / Listen は除外。console が既に Active なら何もしない。
$cand = $sessions |
  Where-Object { $_.User  -eq $targetUser -and
                 $_.Id    -gt 0 -and
                 $_.Name  -ne 'console' -and
                 $_.Name  -ne 'services' -and
                 $_.State -match '^Disc' } |
  Sort-Object Id | Select-Object -First 1

if ($cand) { tscon $cand.Id /dest:console }

守っているルールは4つ。

  1. 対象は「対象ユーザー名を持つ」「STATE=Disc」のセッションだけ
  2. services(ID 0)を絶対に tscon しない
  3. console が既に Active なら何もしない(切断イベントで無限に走らせない)
  4. 全処理でプロセスを止めないtscon は端末の付け替えのみ)

保険:VNC を console 配信で常駐させておく

tscon で console に付け替えるということは、裏を返せば「RDP で入り直すとまた剥がれる」ということでもある。RDP が何らかの理由で使えないときの非常口として、VNC を1本用意しておくと安心だ。

ポイントは配信対象をコンソールセッションにすること。こうしておくと、VNC ビューアを閉じてもデスクトップは active のままになる(RDP と違って描画面が消えない)。

ただし VNC を生でインターネットに晒すのは論外。ここは HTTPS化編 と同じ思想で、

という構成にしている。「非常口を作る」と「穴を開ける」は別物なので、ここは手を抜かない。

学び

これでようやく、RDP を切っても、サーバーを再起動しても、人が触らずに毎朝の自動ログインが通る状態になった。アプリ運用編で「次の機会に」と書いた常駐化が、ここでひととおり片付いたことになる。


シリーズ:

  1. VPS構築編
  2. アプリ運用編
  3. HTTPS化編
  4. 自動ログイン編・検証VM
  5. 自動ログイン編・後編(uiautomationへ作り直し)
  6. 常駐化編(この記事)


Next Post
【自動ログイン編 後編】ARM版WindowsのWebView2アプリをUI Automationで自動操作する — pyautoguiからuiautomationへ