1076 lines
56 KiB
Plaintext
1076 lines
56 KiB
Plaintext
from stdint import *
|
|
import viperlib
|
|
import sys
|
|
import memhub
|
|
import lib.VLog as VLog
|
|
import lib.VCli as VCli
|
|
import lib.VCheck as VCheck
|
|
import lib.VPe as VPe
|
|
import win32.win32file as wf
|
|
import win32.win32base as wb
|
|
import win32.win32sync as wsync
|
|
import win32.win32process as wp
|
|
import win32.win32console as wc
|
|
|
|
|
|
# ============================================================
|
|
# VGuard - heartbeat confirmation watchdog (--need-confirm <sec>; scan mode only)
|
|
#
|
|
# Design:
|
|
# * The parent (VET.exe scan --need-confirm x --need-confirm-timeout t) spawns itself as an
|
|
# independent watchdog child via CreateProcessA in App/main.vp:
|
|
# VET.exe confirm-watch --interval x --timeout t --parent-pid <pid>
|
|
# The parent never blocks on it. The child loops:
|
|
# sleep(interval) -> prompt on stderr -> wait up to timeout for one stdin line {"ok": 1}
|
|
# on success renew and loop; on failure (deadline reached without a valid {"ok": 1} /
|
|
# valid shape but bad value / read error / invalid stdin) it prints
|
|
# {"exit":1, "msg": "timeout/ok!=1"}\n to stdout, calls TerminateProcess(parent) and
|
|
# exits with code 1. "Deadline reached" is the only timeout failure exit; suspect lines
|
|
# are ignored and the loop keeps waiting (see "Quick-fix").
|
|
# * The scan side does not participate in confirmation; stdout stays "JSON only".
|
|
#
|
|
# Non-blocking input, driven by a single deadline (both paths share the same deadline, FIFO, line
|
|
# accumulation, classification and value rule; ParseOkOne is unchanged):
|
|
# * Console (GetFileType=FILE_TYPE_CHAR and GetConsoleMode succeeds): clear
|
|
# ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT on entry (mask GUARD_RAW_CLEAR_MASK; see "Console mode
|
|
# tradeoff"). Primary path: ReadConsoleInputExA + CONSOLE_READ_NOWAIT (see "Hardening 1");
|
|
# fallback (export absent): bounded wait -> GetNumberOfConsoleInputEvents -> ReadConsoleInputA.
|
|
# Only KEY_EVENT(0x0001) with bKeyDown is accepted; chars are pushed into the FIFO as a batch,
|
|
# fed byte-by-byte into the line buffer until Enter ('\r' normalized to '\n'), then the mode
|
|
# is restored.
|
|
# * Pipe / redirect / other: for pipes peek the truly available byte count and read only
|
|
# min(available, buffer); otherwise read one bounded block (<= GUARD_CHUNK_CAP), 0 bytes = EOF.
|
|
# The batch is pushed into the FIFO, then fed into the line buffer; when idle, wait on the
|
|
# handle for min(remaining, GUARD_POLL_MS).
|
|
# * Every wait/read is bounded by the remaining milliseconds (GuardRemainMs); reads happen only
|
|
# after data is known available, so no read can block indefinitely.
|
|
#
|
|
# Console mode tradeoff (why clear ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT):
|
|
# * Clearing ENABLE_LINE_INPUT is required to detect a full line ourselves: with line input on,
|
|
# Enter is consumed by the system and never reaches the input buffer as a KEY_EVENT.
|
|
# * Clearing ENABLE_ECHO_INPUT removes a flag that only affects ReadFile/ReadConsole echo; this
|
|
# module never calls those, so the bit had no effect. Cost: no keystroke echo (the prompt
|
|
# already states the expected input).
|
|
# * All other bits stay, notably ENABLE_PROCESSED_INPUT (Ctrl+C semantics unchanged).
|
|
# * Restore: the original mode is saved via GetConsoleMode and Guard_RawActive is set only when
|
|
# the switch happened; GuardLeaveRaw writes it back on success / failure / parent-exit paths.
|
|
# Ctrl+C / Ctrl+Break / close is handled by SetConsoleCtrlHandler (see "Hardening 2"); only
|
|
# TerminateProcess cannot restore the mode.
|
|
#
|
|
# Hardening 1: read via ReadConsoleInputExA + CONSOLE_READ_NOWAIT (removes a race).
|
|
# * GetNumberOfConsoleInputEvents -> ReadConsoleInputA has a race: if another reader consumes the
|
|
# record between the two calls, ReadConsoleInputA blocks for at least one record (no timeout)
|
|
# and breaks the deadline. ReadConsoleInputExA(..., CONSOLE_READ_NOWAIT) returns immediately on
|
|
# an empty buffer, decoupling the read from "is data present now".
|
|
# * The export has no import library, so it is resolved dynamically via GetModuleHandleA /
|
|
# GetProcAddress (no new link-time symbol, loadable on any system). A static extern def would
|
|
# fail at load time on systems lacking the export, taking the whole exe down.
|
|
# * If resolution fails (older system), fall back to bounded wait -> GetNumberOfConsoleInputEvents
|
|
# -> ReadConsoleInputA, with the pre-wait capped at min(remaining, GUARD_LEGACY_WAIT_MS) and no
|
|
# read when remaining is 0.
|
|
# * Resolution is done once and cached in Guard_ReadExState.
|
|
#
|
|
# Hardening 2: SetConsoleCtrlHandler covers console-mode residue.
|
|
# * Ctrl+C / Ctrl+Break / close would otherwise terminate the process before the mode is written
|
|
# back. The registered handler GuardCtrlHandler restores the saved mode (idempotent).
|
|
# * Registered in GuardEnterRaw only when the mode actually changed (Guard_RawActive=1), removed
|
|
# by GuardLeaveRaw right after the write-back; both share one lifetime, so Guard_RawActive==0
|
|
# <=> not registered (no leak).
|
|
# * The handler always returns FALSE (default handling preserved: Ctrl+C/close still terminate
|
|
# the process). Returning TRUE would require ExitProcess to avoid turning a user Ctrl+C into a
|
|
# watchdog timeout that kills the parent.
|
|
# * The handler runs on a new system thread and only reads module scalars and calls SetConsoleMode
|
|
# once: no pool / VLog buffer access, so no concurrent write.
|
|
#
|
|
# check request channel (execution side: VCheck.vp):
|
|
# * The parent creates a pipe pair (GuardIpcCreate): the read end stays in the parent (inherit
|
|
# bit cleared); the write end is inheritable and its numeric handle value is passed via
|
|
# `--ipc-write <value>`; the child restores it with rawptr(value).
|
|
# * The child classifies each stdin line first (VCheck.ParseCheckLine): a line with a string
|
|
# "check" and an integer "id" is a check request, forwarded byte-for-byte (plus '\n') to the
|
|
# IPC write end, not counted as a heartbeat and never a failure; everything else goes to
|
|
# GuardClassifyLine.
|
|
# * The parent polls the read end without blocking at VScan poll points (PollIpc: PeekNamedPipe
|
|
# available bytes -> read only those -> split on '\n' -> VCheck.HandleCheckLine runs
|
|
# synchronously and replies).
|
|
#
|
|
# Quick-fix: batch input and suspect lines.
|
|
# * Fix 1 (dropped bytes in a batch): the old pump returned as soon as one line completed, losing
|
|
# the rest of the batch already read from the pipe. Now an unconsumed-input FIFO
|
|
# (Guard_Pend/Guard_PendLen/Guard_PendPos, capacity GUARD_PEND_CAP=128) is shared by both
|
|
# paths, three steps per round: (1) feed the FIFO first (it may already hold a full line);
|
|
# (2) read a new batch only when needed (bounded by GUARD_CHUNK_CAP/GUARD_REC_MAX/GUARD_PEND_CAP);
|
|
# (3) push the whole batch into the FIFO, then feed it. Return codes: 1=line complete,
|
|
# 0=no data / not yet a line, -1=read error, GUARD_PUMP_EOF=no more input possible.
|
|
# Invariant: GUARD_PEND_CAP >= GUARD_CHUNK_CAP and >= GUARD_REC_MAX.
|
|
# * Fix 2 (over-strict verdict): GuardClassifyLine(Buf, Len) returns SUSPECT(0) = ignore the line
|
|
# and keep waiting within the deadline (stderr echo, truncated to GUARD_SUSPECT_ECHO_MAX),
|
|
# OK(1) = ParseOkOne passed, BADOK(2) = valid shape but bad value (fatal failure, existing
|
|
# stderr text and GuardWatchFail(hParent, "ok != 1") path kept).
|
|
# A line is suspect if any of: (1) first char after leading space/tab is not '{'; (2) it contains
|
|
# no '}'; (3) it is longer than GUARD_OK_LINE_MAX(256). check lines are classified first, so (3)
|
|
# does not affect them.
|
|
# * The only timeout failure exit is "deadline reached without a valid {"ok": 1}"; ignored lines
|
|
# (check forwards / suspect lines) never reset the deadline, so a stream of them still ends at
|
|
# the deadline.
|
|
# * Accepted behavior change: an empty line is now ignored (suspect) instead of failing at once;
|
|
# the failure then occurs at the deadline with reason "timeout, no input".
|
|
#
|
|
# Why a subprocess instead of a thread:
|
|
# * Threads share the address space: VLog/VScan module buffers and the MemBuddy pool are
|
|
# process-wide singletons and not thread-safe; TerminateProcess cannot kill one thread.
|
|
# * A subprocess is memory-isolated (handle table, MemBuddy pool, module buffers); failure means
|
|
# TerminateProcess(parent) with no cleanup, matching the old sys.exit(1) "terminate now".
|
|
# * Cost: one extra process and one CreateProcessA; reclaimed precisely via the parent handle.
|
|
#
|
|
# Lifecycle / no orphans:
|
|
# * Normal parent exit: StopWatchdog() -> TerminateProcess(child) + CloseHandle.
|
|
# * Parent killed (Ctrl+C / TerminateProcess): the child holds an OpenProcess handle to the
|
|
# parent, so its wait returns immediately and the child exits on its own.
|
|
# * Child crash: the parent's Watch_Process handle is still closed by StopWatchdog.
|
|
#
|
|
# Dependencies: existing declarations in this repo (win32 file/sync/process/console/base,
|
|
# VPe.ReadU16/ReadU32, memhub.MemBuddy.alloc, VCheck.ParseCheckLine/HandleCheckLine, VLog,
|
|
# VCli.ParseUint/ParseUint64/ParseWatchArgs, sys.exit) plus the self-declared items below:
|
|
# SYNCHRONIZE (0x00100000), FILE_TYPE_DISK/CHAR/PIPE (0x0001/0x0002/0x0003),
|
|
# GUARD_RAW_CLEAR_MASK (~0x0006 = 0xFFFFFFF9), CONSOLE_READ_NOWAIT (0x0002),
|
|
# CTRL_C/BREAK/CLOSE/LOGOFF/SHUTDOWN_EVENT (0/1/2/5/6, not branched on),
|
|
# kernel32!PeekNamedPipe and kernel32!SetConsoleCtrlHandler (prototypes in the extern defs),
|
|
# kernel32!ReadConsoleInputExA (no import library; resolved dynamically, see "Hardening 1").
|
|
# ============================================================
|
|
|
|
define SYNCHRONIZE = 0x00100000 # winnt.h
|
|
# Access rights for OpenProcess: PROCESS_TERMINATE(0x0001) | SYNCHRONIZE(0x00100000)
|
|
define WATCH_PARENT_ACCESS = 0x00100001
|
|
define CONFIRM_WATCH_CMD = "confirm-watch" # internal hidden subcommand name
|
|
# stdin line buffer: holds both {"ok": 1} and a check request line ({"check":"<exe path>","id":N}),
|
|
# path up to MAX_PATH. 4096 (same as VCheck.CK_LINE_MAX); a longer line is treated as one line end.
|
|
define GUARD_LINE_CAP = 4096
|
|
define GUARD_STAMP_CAP = 64 # timestamp text buffer
|
|
define SELF_PATH_CAP = 1024 # GetModuleFileNameA output buffer
|
|
# Extra command-line room (subcommand + interval/timeout/parent-pid/ipc-write + --verbose + NUL;
|
|
# handles counted as up to 20 decimal digits). Enough slack to avoid a truncated command line.
|
|
define WATCH_CMD_EXTRA_CAP = 256
|
|
define WATCH_HANDLE_COUNT = 2 # number of handles passed to WaitForMultipleObjects
|
|
# Liveness-probe wait window after spawn (ms). Cost: each scan start waits this long.
|
|
# 400 balances "enough for the child to parse args/OpenProcess/first wait" and startup speed.
|
|
define WATCH_ALIVE_PROBE_MS = 400
|
|
|
|
# ---- non-blocking confirmation input (console / pipe / redirect) ----
|
|
# GetFileType return values (fileapi.h; not declared in this repo's includes)
|
|
define FILE_TYPE_DISK = 0x0001 # disk file (incl. '< file' redirect)
|
|
define FILE_TYPE_CHAR = 0x0002 # character device (console/LPT etc.)
|
|
define FILE_TYPE_PIPE = 0x0003 # pipe/socket (incl. '|' redirect)
|
|
# input kind (GuardInputKind return values)
|
|
define GUARD_IN_CONSOLE = 1 # console (CHAR + GetConsoleMode succeeds)
|
|
define GUARD_IN_PIPE = 2 # pipe (PeekNamedPipe can pre-query readable bytes)
|
|
define GUARD_IN_STREAM = 3 # other stream (disk file etc.: bounded read only)
|
|
# clear mask: complement of ENABLE_LINE_INPUT(0x0002)|ENABLE_ECHO_INPUT(0x0004) (wincon.h)
|
|
define GUARD_RAW_CLEAR_MASK = 0xFFFFFFF9
|
|
# KEY_EVENT_RECORD field offsets in INPUT_RECORD (x64 layout; see VET/README.md)
|
|
define GUARD_KEY_DOWN_OFF = 4 # KeyEvent.bKeyDown (BOOL)
|
|
define GUARD_KEY_CHAR_OFF = 14 # KeyEvent.uChar (first 2 bytes of the WCHAR/CHAR union)
|
|
define GUARD_REC_SIZE = 20 # sizeof(INPUT_RECORD)=EventType(2)+pad(2)+union(16)
|
|
define GUARD_REC_MAX = 16 # max records per ReadConsoleInputA call
|
|
define GUARD_REC_CAP = 320 # record buffer size in bytes (20*16)
|
|
define GUARD_CHUNK_CAP = 64 # max bytes per ReadFile for pipe/file
|
|
define GUARD_POLL_MS = 20 # idle tick with no readable data (same as VScan.PACE_SLEEP_MS)
|
|
# pump result codes (shared; -1 means read error)
|
|
define GUARD_PUMP_EOF = -2 # no more input possible (ReadFile returned 0 bytes)
|
|
|
|
# ---- unconsumed-input FIFO (shared by both paths; see module head "Quick-fix") ----
|
|
# Capacity 128 = max(GUARD_CHUNK_CAP=64, GUARD_REC_MAX=16) x 2. Invariant: keep GUARD_PEND_CAP
|
|
# >= GUARD_CHUNK_CAP and >= GUARD_REC_MAX so one batch fits (clamps in the pumps enforce it).
|
|
define GUARD_PEND_CAP = 128 # unconsumed-input FIFO capacity (bytes)
|
|
# suspect-line echo truncation length (stderr echoes only the prefix)
|
|
define GUARD_SUSPECT_ECHO_MAX = 120
|
|
# line classification codes (GuardClassifyLine; see module head "Quick-fix")
|
|
define GUARD_LINE_SUSPECT = 0 # suspect shape => ignore the line and keep waiting
|
|
define GUARD_LINE_OK = 1 # valid {"ok": 1} => this line confirms success
|
|
define GUARD_LINE_BADOK = 2 # valid shape but bad value => fatal failure (rule not relaxed)
|
|
# Shape-check length limit: a valid {"ok": 1} is normally <= 16 bytes; 256 leaves room for
|
|
# whitespace expansion like "{ \"ok\" : 1 }". Longer lines are treated as torn/noise.
|
|
define GUARD_OK_LINE_MAX = 256
|
|
|
|
# ---- check request channel (watchdog -> parent IPC pipe; attached to --need-confirm) ----
|
|
# line buffer cap, same as the watchdog's GUARD_LINE_CAP (lines are forwarded byte-for-byte)
|
|
define IPC_LINE_CAP = 4096
|
|
define IPC_READ_CHUNK = 1024 # max bytes per ReadFile (only the confirmed-available part)
|
|
define IPC_PIPE_SIZE = 65536 # CreatePipe nSize hint (default too small; keep the write from blocking)
|
|
|
|
# ---- Hardening 1: dynamic resolution of ReadConsoleInputExA (no import library) ----
|
|
define CONSOLE_READ_NOWAIT = 0x0002 # returns immediately on an empty input buffer (documented wFlags)
|
|
# resolution state for ReadConsoleInputExA (Guard_ReadExState; 0 = not tried)
|
|
define GUARD_EX_OK = 1 # resolved to a function address (primary path)
|
|
define GUARD_EX_MISSING = 2 # export absent (fallback path, no retry)
|
|
# Single wait cap (ms) for the fallback "wait readable": keep this step from eating the whole
|
|
# remaining budget. 5 balances "enough for one input to arrive" and promptness.
|
|
define GUARD_LEGACY_WAIT_MS = 5
|
|
# Add parameter for SetConsoleCtrlHandler (literal to avoid confusion with TRUE/FALSE)
|
|
define GUARD_CTRL_ADD = 1
|
|
define GUARD_CTRL_REMOVE = 0
|
|
|
|
# kernel32!PeekNamedPipe (not declared in this repo's includes): BOOL PeekNamedPipe(HANDLE,
|
|
# LPVOID, DWORD, LPDWORD, LPDWORD, LPDWORD). Handle must be a pipe read end; peeks without
|
|
# consuming and returns immediately; lpTotalBytesAvail = bytes currently readable.
|
|
extern def PeekNamedPipe(hNamedPipe: wb.HANDLE, lpBuffer: rawptr, nBufferSize: ULONG,
|
|
lpBytesRead: rawptr, lpTotalBytesAvail: ULONG*,
|
|
lpBytesLeftThisMessage: rawptr) -> BOOL
|
|
|
|
# kernel32!SetConsoleCtrlHandler (not declared in this repo's includes): BOOL SetConsoleCtrlHandler(
|
|
# PHANDLER_ROUTINE, BOOL Add), where PHANDLER_ROUTINE is BOOL (WINAPI*)(DWORD dwCtrlType).
|
|
# The handler name is passed directly and VPC bitcasts the function reference to a pointer.
|
|
# Returns 0 on failure (GetLastError); Add=FALSE removes the handler.
|
|
extern def SetConsoleCtrlHandler(HandlerRoutine: rawptr, Add: BOOL) -> BOOL
|
|
|
|
# injected by App/main.vp (same MemBuddy instance as other modules)
|
|
_mbuddy: memhub.MemBuddy*
|
|
|
|
# watchdog handles (parent side; StopWatchdog closes them)
|
|
Watch_Process: wb.HANDLE = None # CreateProcessA -> PROCESS_INFORMATION.hProcess
|
|
Watch_Thread: wb.HANDLE = None # PROCESS_INFORMATION.hThread
|
|
|
|
# watchdog-side buffers (allocated once inside confirm-watch)
|
|
Guard_Line: bytes = None # stdin line buffer
|
|
Guard_Stamp: bytes = None # "current time" text buffer
|
|
Guard_Dl: bytes = None # "round deadline" text buffer
|
|
Guard_Recs: bytes = None # console INPUT_RECORD array buffer (raw bytes, read by offset)
|
|
Guard_Chunk: bytes = None # pipe/file read chunk buffer
|
|
# Unconsumed-input FIFO (shared by both paths): a read batch is pushed here, then fed to Guard_Line.
|
|
# [Guard_PendPos, Guard_PendLen) is the range not yet fed; Pos==Len means empty.
|
|
Guard_Pend: bytes = None # unconsumed-input FIFO (capacity GUARD_PEND_CAP)
|
|
Guard_PendLen: int = 0 # bytes written into the FIFO (write cursor)
|
|
Guard_PendPos: int = 0 # bytes fed out of the FIFO (consume cursor)
|
|
Guard_Fill: int = 0 # current accumulated line length (in Guard_Line, excluding NUL)
|
|
Guard_SavedMode: ULONG = 0 # console mode before entering raw-key mode
|
|
Guard_RawActive: int = 0 # 1 = currently in switched mode (must be written back before exit)
|
|
|
|
# Watchdog-side write end of the check channel (passed via `--ipc-write <value>`; None = no channel).
|
|
# Only forwards check request lines verbatim; a write failure is logged to stderr only.
|
|
Guard_IpcWrite: wb.HANDLE = None
|
|
|
|
# ---- check request channel (parent side; non-blocking poll) ----
|
|
# The pipe is created by the parent: the read end stays here (inherit bit cleared); the write end
|
|
# is inheritable and its numeric handle is passed to the watchdog via `--ipc-write <value>`.
|
|
Ipc_Read: wb.HANDLE = None # pipe read end (parent-held; None = not created/closed)
|
|
Ipc_Line: bytes = None # line accumulation buffer (IPC_LINE_CAP)
|
|
Ipc_Chunk: bytes = None # single read chunk (IPC_READ_CHUNK)
|
|
Ipc_Fill: int = 0 # accumulated byte count (excluding NUL)
|
|
|
|
# Hardening 1: function pointer slot for ReadConsoleInputExA (t.Callable -> i8** slot, indirect call).
|
|
# ABI: HANDLE/PINPUT_RECORD/LPDWORD are i8*, DWORD=i32, wFlags=USHORT=i16. Filled once by
|
|
# GuardResolveReadEx; Guard_ReadExState is 0 / GUARD_EX_OK / GUARD_EX_MISSING.
|
|
Guard_ReadEx: callable[[rawptr, rawptr, ULONG, rawptr, USHORT], BOOL] = None
|
|
Guard_ReadExState: int = 0
|
|
|
|
# Hardening 2: control-handler registration state and the console handle whose mode was switched.
|
|
# Both are read on the system thread running GuardCtrlHandler, so they are written once before registration.
|
|
Guard_ConHandle: wb.HANDLE = None # console handle whose mode GuardEnterRaw switched
|
|
Guard_CtrlHandlerOn: int = 0 # 1 = registered (GuardLeaveRaw removes it)
|
|
|
|
|
|
# WaitForMultipleObjects needs a handle array (HANDLE is VOIDPTR, 8 bytes on x64). This typed
|
|
# struct of two HANDLE fields is stored contiguously (16 bytes), exactly as Win32 expects.
|
|
class WATCH_HANDLES():
|
|
H0: wb.HANDLE
|
|
H1: wb.HANDLE
|
|
|
|
|
|
# ============================================================
|
|
# check request channel: IPC pipe (parent side: create / close / non-blocking poll)
|
|
#
|
|
# Dedicated pipe (stdin/stdout/stderr are already taken; stdout's contract is single-line JSON).
|
|
# Created by the parent: read end stays here (inherit bit cleared), write end stays inheritable and
|
|
# its numeric handle is passed by value via `--ipc-write <value>` (u64 bit pattern, so high-bit
|
|
# handles survive; the child restores it with rawptr). The parent closes its write-end copy right
|
|
# after CreateProcessA so later --version probes do not inherit it.
|
|
#
|
|
# Reads never block: peek the currently available byte count, return if 0, else read only
|
|
# min(available, IPC_READ_CHUNK); the per-poll bound is what was already in the pipe, so the poll
|
|
# can sit on the scan hot path. '\n' ends a line (content excludes it); an overlong line is dropped.
|
|
# ============================================================
|
|
|
|
# Create the IPC pipe. Returns the write-end handle on success (caller passes it to the watchdog and
|
|
# closes it later); None on failure (treated as fatal). The read end is stored in Ipc_Read.
|
|
def GuardIpcCreate() -> wb.HANDLE:
|
|
global Ipc_Read, Ipc_Line, Ipc_Chunk, Ipc_Fill
|
|
GuardIpcClose() # idempotent: close any old pipe first
|
|
if Ipc_Line is None:
|
|
Ipc_Line = _mbuddy.alloc(IPC_LINE_CAP)
|
|
if Ipc_Chunk is None:
|
|
Ipc_Chunk = _mbuddy.alloc(IPC_READ_CHUNK)
|
|
if Ipc_Line is None or Ipc_Chunk is None:
|
|
return None
|
|
Sa: wb.SECURITY_ATTRIBUTES = wb.SECURITY_ATTRIBUTES()
|
|
Sa.nLength = uint32(wb.SECURITY_ATTRIBUTES.__sizeof__())
|
|
Sa.lpSecurityDescriptor = None
|
|
Sa.bInheritHandle = 1 # create both inheritable first, then clear only the read end
|
|
hRead: wb.HANDLE = None
|
|
hWrite: wb.HANDLE = None
|
|
if wf.CreatePipe(&hRead, &hWrite,
|
|
&Sa, ULONG(IPC_PIPE_SIZE)) == 0:
|
|
VLog.LogFmt1("[confirm] Warning: check channel CreatePipe failed (GetLastError=%u)\n",
|
|
sizet(wb.GetLastError()))
|
|
return None
|
|
# read end is parent-only: clear inherit bit so no child (watchdog, --version probe) inherits it
|
|
wf.SetHandleInformation(hRead, wf.HANDLE_FLAG_INHERIT, 0)
|
|
wf.SetHandleInformation(hWrite, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
|
|
Ipc_Read = hRead
|
|
Ipc_Fill = 0
|
|
return hWrite
|
|
|
|
|
|
# Close the IPC read end (idempotent; called by StopWatchdog and SpawnWatchdog failure paths).
|
|
def GuardIpcClose():
|
|
global Ipc_Read, Ipc_Fill
|
|
if Ipc_Read is not None:
|
|
wb.CloseHandle(Ipc_Read)
|
|
Ipc_Read = None
|
|
Ipc_Fill = 0
|
|
|
|
|
|
# Non-blocking poll of the IPC read end: drain currently available bytes, split on '\n', hand each
|
|
# line to VCheck (synchronously: PRVERSN read -> --version -> reply all inside this call).
|
|
# Returns the number of check request lines handled (0 = no data / non-check line / no channel).
|
|
def PollIpc() -> int:
|
|
global Ipc_Fill
|
|
if Ipc_Read is None or Ipc_Read == wb.INVALID_HANDLE_VALUE:
|
|
return 0
|
|
Done: int = 0
|
|
while True:
|
|
Avail: ULONG = 0
|
|
if PeekNamedPipe(Ipc_Read, None, 0, None, &Avail, None) == 0:
|
|
return Done # pipe error: stop reading this round (no block, no retry)
|
|
if Avail == 0:
|
|
return Done # no data => return immediately (frequent poll path)
|
|
N: ULONG = ULONG(IPC_READ_CHUNK)
|
|
if Avail < N:
|
|
N = Avail # read only the confirmed-available part => ReadFile will not block
|
|
Br: ULONG = 0
|
|
if wf.ReadFile(Ipc_Read, Ipc_Chunk, N, &Br, None) == 0:
|
|
return Done
|
|
if Br == 0:
|
|
return Done
|
|
ub: BYTEPTR = BYTEPTR(Ipc_Chunk)
|
|
i: int = 0
|
|
while i < int(Br):
|
|
Ch: int = int(ub[i])
|
|
i += 1
|
|
if Ch == 10: # '\n': end of a line
|
|
Ipc_Line[Ipc_Fill] = 0
|
|
if Ipc_Fill > 0:
|
|
if VCheck.HandleCheckLine(str(Ipc_Line)) != 0:
|
|
Done += 1
|
|
Ipc_Fill = 0
|
|
else:
|
|
if Ipc_Fill >= IPC_LINE_CAP - 1:
|
|
Ipc_Fill = 0 # overlong line: drop it (no overflow, no merging)
|
|
else:
|
|
Ipc_Line[Ipc_Fill] = char(Ch)
|
|
Ipc_Fill += 1
|
|
|
|
|
|
# ============================================================
|
|
# parent side: spawn / stop the watchdog
|
|
# ============================================================
|
|
|
|
# Get own exe full path (GetModuleFileNameA(NULL,...)). Returns 1 = success (Out NUL-terminated), 0 = failure.
|
|
def SelfExePath(Out: bytes, Cap: int) -> int:
|
|
if Out is None or Cap < 2:
|
|
return 0
|
|
n: ULONG = wf.GetModuleFileNameA(None, Out, ULONG(Cap))
|
|
if n == 0:
|
|
return 0
|
|
if sizet(n) >= sizet(Cap):
|
|
return 0 # buffer too small (path truncated) => treat as failure
|
|
Out[int(n)] = 0
|
|
return 1
|
|
|
|
|
|
# Spawn the watchdog child. Returns 1 = spawned, 0 = failure. IntervalSec > 0 (caller guarantees);
|
|
# TimeoutMs = confirmation timeout (ms). Shares the parent's standard handles (STARTF_USESTDHANDLES)
|
|
# and console (dwCreationFlags=0). A liveness probe after spawn treats an immediate child exit
|
|
# (WAIT_OBJECT_0) as fatal (exit code + failure JSON, return 0); other wait results count as alive.
|
|
def SpawnWatchdog(IntervalSec: int, TimeoutMs: int) -> int:
|
|
global Watch_Process, Watch_Thread
|
|
if IntervalSec <= 0:
|
|
return 1 # disabled: do not spawn (caller only calls when >0)
|
|
StopWatchdog() # idempotent: reclaim any old watchdog first
|
|
|
|
Self: bytes = _mbuddy.alloc(SELF_PATH_CAP)
|
|
Cmd: bytes = _mbuddy.alloc(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP)
|
|
if Self is None or Cmd is None:
|
|
if Self is not None:
|
|
_mbuddy.free(rawptr(Self))
|
|
if Cmd is not None:
|
|
_mbuddy.free(rawptr(Cmd))
|
|
VLog.LogStr("[confirm] Warning: watchdog buffer allocation failed, not started\n")
|
|
return 0
|
|
if SelfExePath(Self, SELF_PATH_CAP) == 0:
|
|
_mbuddy.free(rawptr(Self))
|
|
_mbuddy.free(rawptr(Cmd))
|
|
VLog.LogStr("[confirm] Warning: GetModuleFileNameA failed to get own path, watchdog not started\n")
|
|
return 0
|
|
|
|
# Build the pipe first (the write-end value must be in the command line before CreateProcessA).
|
|
# Failure is fatal: silently lacking the requested check channel drops an explicit capability.
|
|
IpcW: wb.HANDLE = GuardIpcCreate()
|
|
if IpcW is None:
|
|
_mbuddy.free(rawptr(Self))
|
|
_mbuddy.free(rawptr(Cmd))
|
|
VLog.LogStr("[confirm] Warning: check channel pipe creation failed, watchdog not started\n")
|
|
return 0
|
|
|
|
# First token is the self path (quoted for spaces); --verbose is forwarded so the child uses the
|
|
# same verbosity. --ipc-write <value> is the pipe write-end handle value (u64), restored by the
|
|
# child with rawptr; a missing argument means "no channel".
|
|
Pid: ULONG = wp.GetCurrentProcessId()
|
|
Extra: str = ""
|
|
if VCli.Verbose() != 0:
|
|
Extra = " --verbose"
|
|
viperlib.snprintf(Cmd, sizet(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP),
|
|
"\"%s\" %s --interval %u --timeout %u --parent-pid %u --ipc-write %llu%s",
|
|
str(Self), CONFIRM_WATCH_CMD,
|
|
sizet(IntervalSec), sizet(TimeoutMs), sizet(Pid),
|
|
uint64(IpcW), Extra)
|
|
|
|
# Standard handles: same as the parent (inherited console/redirect); stdin is also handed over
|
|
hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE)
|
|
hOut: wb.HANDLE = wf.GetStdHandle(wf.STD_OUTPUT_HANDLE)
|
|
hErr: wb.HANDLE = wf.GetStdHandle(wf.STD_ERROR_HANDLE)
|
|
if hIn is None or hIn == wb.INVALID_HANDLE_VALUE:
|
|
wb.CloseHandle(IpcW)
|
|
GuardIpcClose()
|
|
_mbuddy.free(rawptr(Self))
|
|
_mbuddy.free(rawptr(Cmd))
|
|
VLog.LogStr("[confirm] Warning: stdin handle invalid, watchdog not started (heartbeat confirmation requires stdin)\n")
|
|
return 0
|
|
# Best-effort mark standard handles inheritable (console handles usually inherit anyway; ignore)
|
|
wf.SetHandleInformation(hIn, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
|
|
if hOut is not None and hOut != wb.INVALID_HANDLE_VALUE:
|
|
wf.SetHandleInformation(hOut, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
|
|
if hErr is not None and hErr != wb.INVALID_HANDLE_VALUE:
|
|
wf.SetHandleInformation(hErr, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
|
|
|
|
Si: wp.STARTUPINFOA = wp.STARTUPINFOA()
|
|
Si.cb = uint32(wp.STARTUPINFOA.__sizeof__())
|
|
Si.dwFlags = wp.STARTF_USESTDHANDLES
|
|
Si.hStdInput = hIn
|
|
Si.hStdOutput = hOut
|
|
Si.hStdError = hErr
|
|
Pi: wp.PROCESS_INFORMATION = wp.PROCESS_INFORMATION()
|
|
|
|
Ok: BOOL = wp.CreateProcessA(None, Cmd, None, None, 1, 0,
|
|
None, None, &Si, &Pi)
|
|
CreateErr: ULONG = wb.GetLastError() # read right after the call (free overwrites GetLastError)
|
|
_mbuddy.free(rawptr(Self))
|
|
_mbuddy.free(rawptr(Cmd))
|
|
if Ok == 0:
|
|
wb.CloseHandle(IpcW)
|
|
GuardIpcClose()
|
|
VLog.LogFmt1("[confirm] Warning: watchdog CreateProcessA failed (GetLastError=%u), not started\n",
|
|
sizet(CreateErr))
|
|
return 0
|
|
|
|
# The write end was inherited by the watchdog => close our copy now, so later CreateProcess calls
|
|
# (--version probe) do not inherit it again.
|
|
wb.CloseHandle(IpcW)
|
|
|
|
Watch_Process = Pi.hProcess
|
|
Watch_Thread = Pi.hThread
|
|
|
|
# Liveness probe (see function head): only WAIT_OBJECT_0 (child exited) is fatal.
|
|
Wp: ULONG = wsync.WaitForSingleObject(Watch_Process, WATCH_ALIVE_PROBE_MS)
|
|
if Wp == wb.WAIT_OBJECT_0:
|
|
Ec: ULONG = 0
|
|
wp.GetExitCodeProcess(Watch_Process, &Ec)
|
|
VLog.LogFmt1("[confirm] watchdog exited immediately after spawn (exit=%u), heartbeat confirmation cannot work\n",
|
|
sizet(Ec))
|
|
# Like StopWatchdog, close each handle once and clear the module handles (avoid a later
|
|
# StopWatchdog / re-Spawn double-closing).
|
|
wb.CloseHandle(Watch_Process)
|
|
Watch_Process = None
|
|
wb.CloseHandle(Watch_Thread)
|
|
Watch_Thread = None
|
|
GuardIpcClose() # watchdog exited => check channel is void, close the read end too
|
|
# Byte-identical to the watchdog failure line (GuardWatchFail), still single-line JSON. Keep
|
|
# the same msg because consumers abort on "exit"==1; the precise reason goes to stderr above.
|
|
VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n")
|
|
return 0
|
|
VLog.LogFmt2("[confirm] heartbeat watchdog started (pid=%u, every %u seconds",
|
|
sizet(Pi.dwProcessId), sizet(IntervalSec))
|
|
VLog.LogFmt1(", confirmation timeout %u ms)\n", sizet(TimeoutMs))
|
|
return 1
|
|
|
|
|
|
# Stop the watchdog (idempotent; called on normal/early parent exit). TerminateProcess marks it
|
|
# terminated (no more user-mode code); a bounded wait then ensures it is gone before closing handles.
|
|
def StopWatchdog():
|
|
global Watch_Process, Watch_Thread
|
|
if Watch_Process is not None:
|
|
wp.TerminateProcess(Watch_Process, 0)
|
|
wsync.WaitForSingleObject(Watch_Process, 5000)
|
|
wb.CloseHandle(Watch_Process)
|
|
Watch_Process = None
|
|
if Watch_Thread is not None:
|
|
wb.CloseHandle(Watch_Thread)
|
|
Watch_Thread = None
|
|
GuardIpcClose()
|
|
|
|
|
|
# ============================================================
|
|
# input parsing
|
|
# ============================================================
|
|
|
|
# Strictly match "key ok with value 1" (tolerating space 32 / tab 9 around key and colon). The char
|
|
# before '"ok"' must be '{'(123), ','(44) or the buffer start; the value must be digit 1(49) not
|
|
# followed by a digit. Returns 1 only for ok==1, else 0.
|
|
def ParseOkOne(Buf: bytes, Len: int) -> int:
|
|
if Buf is None or Len <= 0:
|
|
return 0
|
|
ub: BYTEPTR = BYTEPTR(Buf)
|
|
for i in range(Len):
|
|
if int(ub[i]) == 34: # '"'
|
|
KeyPos: int = 0
|
|
if i == 0:
|
|
KeyPos = 1
|
|
else:
|
|
k: int = i - 1
|
|
while k >= 0 and (int(ub[k]) == 32 or int(ub[k]) == 9):
|
|
k -= 1
|
|
if k < 0:
|
|
KeyPos = 1
|
|
else:
|
|
pk: int = int(ub[k])
|
|
if pk == 123 or pk == 44: # '{' or ','
|
|
KeyPos = 1
|
|
if KeyPos != 0 and i + 3 < Len:
|
|
# then 'o'(111) 'k'(107) '"'(34)
|
|
if int(ub[i + 1]) == 111 and int(ub[i + 2]) == 107 and int(ub[i + 3]) == 34:
|
|
j: int = i + 4
|
|
while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9):
|
|
j += 1
|
|
if j < Len and int(ub[j]) == 58: # ':'
|
|
j += 1
|
|
while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9):
|
|
j += 1
|
|
if j < Len and int(ub[j]) == 49: # '1'
|
|
if j + 1 >= Len:
|
|
return 1
|
|
nx: int = int(ub[j + 1])
|
|
if nx < 48 or nx > 57: # next char not a digit => value is exactly 1
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# Line classification (see module head "Quick-fix"). Returns SUSPECT(0) = ignore and keep waiting,
|
|
# OK(1) = valid {"ok": 1} (success), BADOK(2) = valid shape but bad value (fatal failure). Shape is
|
|
# checked first; ParseOkOne (unchanged) decides the value only after shape passes.
|
|
# Suspect if, in byte terms: (1) first char after leading space/tab is not '{'(123); (2) no '}'(125)
|
|
# in the line; (3) length > GUARD_OK_LINE_MAX(256). check lines are classified first, so (3) is safe.
|
|
def GuardClassifyLine(Buf: bytes, Len: int) -> int:
|
|
if Buf is None or Len <= 0:
|
|
return GUARD_LINE_SUSPECT # (1) empty line / no content
|
|
if Len > GUARD_OK_LINE_MAX:
|
|
return GUARD_LINE_SUSPECT # (3)
|
|
ub: BYTEPTR = BYTEPTR(Buf)
|
|
i: int = 0
|
|
while i < Len and (int(ub[i]) == 32 or int(ub[i]) == 9):
|
|
i += 1
|
|
if i >= Len:
|
|
return GUARD_LINE_SUSPECT # (1) all-blank line
|
|
if int(ub[i]) != 123: # '{'
|
|
return GUARD_LINE_SUSPECT # (1)
|
|
j: int = i
|
|
while j < Len:
|
|
if int(ub[j]) == 125: # '}'
|
|
break
|
|
j += 1
|
|
if j >= Len:
|
|
return GUARD_LINE_SUSPECT # (2)
|
|
if ParseOkOne(Buf, Len) != 0:
|
|
return GUARD_LINE_OK
|
|
return GUARD_LINE_BADOK
|
|
|
|
|
|
# Forward the current line (Guard_Line / Guard_Fill) byte-for-byte to the IPC write end plus '\n'
|
|
# (the parent splits on '\n'). Content matches user input exactly. A write failure / no channel is
|
|
# stderr-only and does not change heartbeat semantics.
|
|
def GuardForwardLine():
|
|
if Guard_IpcWrite is None or Guard_IpcWrite == wb.INVALID_HANDLE_VALUE:
|
|
VLog.LogStr("[confirm] Warning: check request received but no check channel (--ipc-write not passed), line dropped\n")
|
|
return
|
|
bw: ULONG = 0
|
|
if Guard_Fill > 0:
|
|
if wf.WriteFile(Guard_IpcWrite, Guard_Line, ULONG(Guard_Fill),
|
|
&bw, None) == 0:
|
|
VLog.LogStr("[confirm] Warning: check request forward failed (pipe write failed), line dropped\n")
|
|
return
|
|
if wf.WriteFile(Guard_IpcWrite, "\n", 1, &bw, None) == 0:
|
|
VLog.LogStr("[confirm] Warning: check request forward failed (line separator write failed), the line may be incomplete\n")
|
|
|
|
|
|
# ============================================================
|
|
# non-blocking input pump (console / pipe / redirect share one deadline and line accumulation)
|
|
# ============================================================
|
|
|
|
# Remaining milliseconds: GetTickCount64 difference, saturated to 0 when expired (never negative).
|
|
# Since the --timeout cap (86400000) is < 2^32, the ULONG cast cannot truncate.
|
|
def GuardRemainMs(Deadline: ULONGLONG) -> ULONG:
|
|
Now: ULONGLONG = wb.GetTickCount64()
|
|
if Now >= Deadline:
|
|
return 0
|
|
return ULONG(Deadline - Now)
|
|
|
|
|
|
# Accumulate one byte (the only accumulation point). Returns 1 = a line completed, 0 = not yet.
|
|
# '\n'(10) ends a line without entering the buffer and drops a preceding '\r'(13); a full buffer
|
|
# (GUARD_LINE_CAP-1) also ends a line. A NUL is written after each append; Guard_Fill is the length.
|
|
def GuardFeedByte(Ch: int) -> int:
|
|
global Guard_Fill
|
|
ub: BYTEPTR = BYTEPTR(Guard_Line) # byte view for reading
|
|
if Ch == 10: # '\n'
|
|
if Guard_Fill > 0 and int(ub[Guard_Fill - 1]) == 13:
|
|
Guard_Fill -= 1
|
|
Guard_Line[Guard_Fill] = 0
|
|
return 1
|
|
if Guard_Fill >= GUARD_LINE_CAP - 1:
|
|
Guard_Line[Guard_Fill] = 0
|
|
return 1
|
|
Guard_Line[Guard_Fill] = char(Ch)
|
|
Guard_Fill += 1
|
|
Guard_Line[Guard_Fill] = 0
|
|
return 0
|
|
|
|
|
|
# ---- unconsumed-input FIFO helpers (shared; see module head "Quick-fix") ----
|
|
|
|
# Clear the FIFO (called at the start of a new round: no residue from the previous round).
|
|
def GuardPendReset():
|
|
global Guard_PendLen, Guard_PendPos
|
|
Guard_PendLen = 0
|
|
Guard_PendPos = 0
|
|
|
|
|
|
# Feed unconsumed FIFO bytes one by one into the line buffer. Returns 1 = a line completed mid-way
|
|
# (rest stays in the FIFO) or 0 = FIFO drained without a line (cursors reset, FIFO reused from head).
|
|
def GuardFeedPending() -> int:
|
|
global Guard_PendLen, Guard_PendPos
|
|
ub: BYTEPTR = BYTEPTR(Guard_Pend)
|
|
while Guard_PendPos < Guard_PendLen:
|
|
Ch: int = int(ub[Guard_PendPos])
|
|
Guard_PendPos += 1
|
|
if GuardFeedByte(Ch) != 0:
|
|
return 1
|
|
Guard_PendPos = 0
|
|
Guard_PendLen = 0
|
|
return 0
|
|
|
|
|
|
# Write one byte into the FIFO (write cursor Guard_PendLen). A full-FIFO guard drops the byte with a
|
|
# stderr warning instead of overflowing; by the GUARD_PEND_CAP invariant "full" is unreachable.
|
|
def GuardPendPut(Ch: int):
|
|
global Guard_PendLen
|
|
if Guard_PendLen >= GUARD_PEND_CAP:
|
|
VLog.LogFmt1("[confirm] Warning: unconsumed-input FIFO full (capacity %u), dropping this byte batch\n",
|
|
sizet(GUARD_PEND_CAP))
|
|
return
|
|
Guard_Pend[Guard_PendLen] = char(Ch)
|
|
Guard_PendLen += 1
|
|
|
|
|
|
# Classify stdin: console / pipe / other stream. FILE_TYPE_CHAR also covers LPT etc., so
|
|
# GetConsoleMode is used to confirm a real console input buffer (=> read by records).
|
|
def GuardInputKind(hIn: wb.HANDLE) -> int:
|
|
T: ULONG = wf.GetFileType(hIn)
|
|
if T == ULONG(FILE_TYPE_CHAR):
|
|
Mode: ULONG = 0
|
|
if wc.GetConsoleMode(hIn, &Mode) != 0:
|
|
return GUARD_IN_CONSOLE
|
|
return GUARD_IN_STREAM
|
|
if T == ULONG(FILE_TYPE_PIPE):
|
|
return GUARD_IN_PIPE
|
|
# FILE_TYPE_DISK ('< file' redirect) / FILE_TYPE_UNKNOWN (NUL etc.) are all treated as "other stream"
|
|
return GUARD_IN_STREAM
|
|
|
|
|
|
# ============================================================
|
|
# Hardening 1: dynamic resolution of ReadConsoleInputExA
|
|
# ============================================================
|
|
|
|
# Resolve kernel32!ReadConsoleInputExA into Guard_ReadEx (once per process). Returns 1 = available
|
|
# (incl. previously resolved), 0 = unavailable (export absent => fallback). A failure is remembered
|
|
# (GUARD_EX_MISSING) so GetProcAddress is not retried on every pump call.
|
|
def GuardResolveReadEx() -> int:
|
|
global Guard_ReadEx, Guard_ReadExState
|
|
if Guard_ReadExState == GUARD_EX_OK:
|
|
return 1
|
|
if Guard_ReadExState == GUARD_EX_MISSING:
|
|
return 0
|
|
Guard_ReadExState = GUARD_EX_MISSING # record "unavailable" first: any failed step stops retries
|
|
Hm: wb.HANDLE = wb.GetModuleHandleA("kernel32.dll")
|
|
if Hm is None or Hm == wb.INVALID_HANDLE_VALUE:
|
|
return 0
|
|
Fp: VOIDPTR = wb.GetProcAddress(Hm, "ReadConsoleInputExA")
|
|
if Fp is None:
|
|
return 0
|
|
Guard_ReadEx = Fp
|
|
Guard_ReadExState = GUARD_EX_OK
|
|
return 1
|
|
|
|
|
|
# ============================================================
|
|
# Hardening 2: control event handler (SetConsoleCtrlHandler)
|
|
# ============================================================
|
|
|
|
# Control event handler: restore the pre-raw console mode (idempotent) then always return FALSE
|
|
# (default handling preserved, so Ctrl+C/Ctrl+Break/close still terminate the process).
|
|
# Thread-safe by construction: runs on a system thread, only reads three module scalars and calls
|
|
# SetConsoleMode once (no allocation, no pool / VLog / scan buffer access).
|
|
# Not called when force-killed by TerminateProcess (StopWatchdog reclaim / heartbeat-fail kill).
|
|
# Return 0 (= FALSE; this module imports win32.win32base as wb and has no bare FALSE name)
|
|
def GuardCtrlHandler(CtrlType: ULONG) -> BOOL:
|
|
if Guard_RawActive == 0:
|
|
return 0
|
|
if Guard_ConHandle is not None:
|
|
wc.SetConsoleMode(Guard_ConHandle, Guard_SavedMode)
|
|
return 0
|
|
|
|
|
|
# Remove the control handler (idempotent; no API call when not registered).
|
|
def GuardRemoveCtrlHandler():
|
|
global Guard_CtrlHandlerOn
|
|
if Guard_CtrlHandlerOn == 0:
|
|
return
|
|
SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_REMOVE)
|
|
Guard_CtrlHandlerOn = 0
|
|
|
|
|
|
# Enter raw-key console mode: clear ENABLE_LINE_INPUT and ENABLE_ECHO_INPUT (see module head).
|
|
# Returns 1 = usable (switched, or both bits were already clear), 0 = Get/SetConsoleMode failed.
|
|
# Registration order (Hardening 2): set Guard_RawActive=1 and record the handle, register the
|
|
# handler (best-effort), then SetConsoleMode, so a Ctrl+C between the last two steps restores the
|
|
# still-unchanged mode (idempotent no-op).
|
|
def GuardEnterRaw(hIn: wb.HANDLE) -> int:
|
|
global Guard_SavedMode, Guard_RawActive, Guard_ConHandle, Guard_CtrlHandlerOn
|
|
Mode: ULONG = 0
|
|
if wc.GetConsoleMode(hIn, &Mode) == 0:
|
|
return 0
|
|
Guard_SavedMode = Mode
|
|
Guard_RawActive = 0
|
|
New: ULONG = Mode & ULONG(GUARD_RAW_CLEAR_MASK)
|
|
if New == Mode:
|
|
return 1 # mode unchanged => no handler needed
|
|
Guard_ConHandle = hIn
|
|
Guard_RawActive = 1
|
|
if SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_ADD) != 0:
|
|
Guard_CtrlHandlerOn = 1
|
|
else:
|
|
VLog.LogStr("[confirm] Warning: SetConsoleCtrlHandler registration failed; on Ctrl+C this console may keep the mode with line input cleared\n")
|
|
if wc.SetConsoleMode(hIn, New) == 0:
|
|
# Clear Guard_RawActive before Guard_SavedMode: the handler writes back only when
|
|
# RawActive != 0, so it never pushes the zeroed SavedMode to the console.
|
|
Guard_RawActive = 0
|
|
Guard_SavedMode = 0
|
|
GuardRemoveCtrlHandler()
|
|
return 0
|
|
return 1
|
|
|
|
|
|
# Write back the pre-entry console mode (idempotent; no API call if never switched). Order: write
|
|
# back, clear Guard_RawActive, remove the handler; this order leaves no "line input cleared but no
|
|
# handler" window. Registration and RawActive share one lifetime (RawActive==0 <=> not registered).
|
|
def GuardLeaveRaw(hIn: wb.HANDLE):
|
|
global Guard_RawActive
|
|
if Guard_RawActive == 0:
|
|
return
|
|
wc.SetConsoleMode(hIn, Guard_SavedMode)
|
|
Guard_RawActive = 0
|
|
GuardRemoveCtrlHandler()
|
|
|
|
|
|
# Console path: one non-blocking pump. Returns 1 = line complete, 0 = no pending record / not yet,
|
|
# -1 = read error. Accepts only KEY_EVENT(0x0001) with bKeyDown: printable ASCII(32..126) and tab(9),
|
|
# Enter '\r'(13) normalized to '\n'; all else ignored. Steps: feed the FIFO, else read one batch
|
|
# (primary: ReadConsoleInputExA+NOWAIT; fallback: bounded wait -> query -> ReadConsoleInputA), push
|
|
# filtered chars, then feed again.
|
|
def GuardPumpConsole(hIn: wb.HANDLE, Deadline: ULONGLONG) -> int:
|
|
if GuardFeedPending() != 0: # (1) FIFO already has a full line => return it, no read
|
|
return 1
|
|
Read: ULONG = 0
|
|
if GuardResolveReadEx() != 0:
|
|
# Guard_ReadEx is a module-level t.Callable whose return signature was registered with the
|
|
# module-level annotation, so it can be called indirectly.
|
|
if Guard_ReadEx(hIn, rawptr(Guard_Recs), ULONG(GUARD_REC_MAX),
|
|
&Read, USHORT(CONSOLE_READ_NOWAIT)) == 0:
|
|
return -1
|
|
else:
|
|
Rem: ULONG = GuardRemainMs(Deadline)
|
|
if Rem == 0:
|
|
return 0
|
|
if Rem > ULONG(GUARD_LEGACY_WAIT_MS):
|
|
Rem = ULONG(GUARD_LEGACY_WAIT_MS)
|
|
if wsync.WaitForSingleObject(hIn, Rem) == ULONG(wb.WAIT_FAILED):
|
|
return -1
|
|
Cnt: ULONG = 0
|
|
if wc.GetNumberOfConsoleInputEvents(hIn, &Cnt) == 0:
|
|
return -1
|
|
if Cnt == 0:
|
|
return 0
|
|
N: ULONG = Cnt
|
|
if N > ULONG(GUARD_REC_MAX):
|
|
N = ULONG(GUARD_REC_MAX)
|
|
if wc.ReadConsoleInputA(hIn, rawptr(Guard_Recs), N, &Read) == 0:
|
|
return -1
|
|
ub: BYTEPTR = BYTEPTR(Guard_Recs)
|
|
i: int = 0
|
|
while i < int(Read):
|
|
Base: int = i * GUARD_REC_SIZE
|
|
i += 1
|
|
if VPe.ReadU16(ub, Base) != uint32(wc.KEY_EVENT):
|
|
continue
|
|
if VPe.ReadU32(ub, Base + GUARD_KEY_DOWN_OFF) == 0:
|
|
continue
|
|
Ch: int = int(VPe.ReadU16(ub, Base + GUARD_KEY_CHAR_OFF))
|
|
if Ch == 13:
|
|
Ch = 10 # Enter -> end of line (same as '\n')
|
|
elif Ch != 9 and (Ch < 32 or Ch > 126):
|
|
continue # non-printable / non-ASCII: ignore
|
|
GuardPendPut(Ch) # (3) push filtered chars into the FIFO (at most 16 bytes per call)
|
|
if GuardFeedPending() != 0: # (3) feed the FIFO again; remaining bytes stay for the next call
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# Pipe/redirect path: one non-blocking pump. Returns 1 = line complete, 0 = no readable bytes,
|
|
# -1 = read error, GUARD_PUMP_EOF = no more input (0-byte ReadFile). Pipes peek the available count
|
|
# and read only that; other streams read one bounded block (<= GUARD_CHUNK_CAP). Steps: feed FIFO,
|
|
# read a batch, push the whole batch, feed again. A peek of 0 means "no data", not EOF (the deadline
|
|
# decides the timeout).
|
|
def GuardPumpStream(hIn: wb.HANDLE, Kind: int) -> int:
|
|
if GuardFeedPending() != 0: # (1) FIFO already has a full line => return it, no read
|
|
return 1
|
|
N: ULONG = ULONG(GUARD_CHUNK_CAP)
|
|
if Kind == GUARD_IN_PIPE:
|
|
Avail: ULONG = 0
|
|
if PeekNamedPipe(hIn, None, 0, None, &Avail, None) == 0:
|
|
return -1
|
|
if Avail == 0:
|
|
return 0
|
|
if Avail < N:
|
|
N = Avail
|
|
# (2) Clamp the read size by both bounds (order-independent): <= GUARD_CHUNK_CAP (the actual chunk
|
|
# buffer size; a larger N would overflow it) and <= GUARD_PEND_CAP (the whole batch must fit the FIFO).
|
|
if N > ULONG(GUARD_CHUNK_CAP):
|
|
N = ULONG(GUARD_CHUNK_CAP)
|
|
if N > ULONG(GUARD_PEND_CAP):
|
|
N = ULONG(GUARD_PEND_CAP)
|
|
Br: ULONG = 0
|
|
if wf.ReadFile(hIn, Guard_Chunk, N, &Br, None) == 0:
|
|
return -1
|
|
if Br == 0:
|
|
return GUARD_PUMP_EOF
|
|
ub: BYTEPTR = BYTEPTR(Guard_Chunk)
|
|
for i in range(int(Br)):
|
|
GuardPendPut(int(ub[i])) # (3) enqueue the whole batch first (Br <= N <= GUARD_PEND_CAP)
|
|
if GuardFeedPending() != 0: # (3) feed the FIFO again; remaining bytes stay for the next call
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ============================================================
|
|
# watchdog side: failure handling + main loop
|
|
# ============================================================
|
|
|
|
# Failure path: reason to stderr -> the single stdout line {"exit":1, "msg": "timeout/ok!=1"}\n ->
|
|
# TerminateProcess(parent) -> sys.exit(1). The parent is killed with no cleanup (no cache, no JSON).
|
|
def GuardWatchFail(hParent: wb.HANDLE, Reason: str):
|
|
if Reason is not None:
|
|
VLog.LogFmtS1("[confirm] Failure: %s\n", Reason)
|
|
VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n")
|
|
if hParent is not None and hParent != wb.INVALID_HANDLE_VALUE:
|
|
wp.TerminateProcess(hParent, 1)
|
|
wb.CloseHandle(hParent)
|
|
sys.exit(1)
|
|
|
|
|
|
# Allocate watchdog-side buffers once; returns 1 = usable, 0 = failure
|
|
def GuardWatchInit() -> int:
|
|
global Guard_Line, Guard_Stamp, Guard_Dl, Guard_Recs, Guard_Chunk, Guard_Pend
|
|
if Guard_Line is None:
|
|
Guard_Line = _mbuddy.alloc(GUARD_LINE_CAP)
|
|
Guard_Stamp = _mbuddy.alloc(GUARD_STAMP_CAP)
|
|
Guard_Dl = _mbuddy.alloc(GUARD_STAMP_CAP)
|
|
Guard_Recs = _mbuddy.alloc(GUARD_REC_CAP)
|
|
Guard_Chunk = _mbuddy.alloc(GUARD_CHUNK_CAP)
|
|
Guard_Pend = _mbuddy.alloc(GUARD_PEND_CAP)
|
|
if Guard_Line is None or Guard_Stamp is None or Guard_Dl is None:
|
|
return 0
|
|
if Guard_Recs is None or Guard_Chunk is None or Guard_Pend is None:
|
|
return 0
|
|
return 1
|
|
|
|
|
|
# Wait from the prompt on, within TimeoutMs, for a complete classified line (all paths). Returns
|
|
# 1 = confirmed (Guard_Line/Guard_Fill hold the input), 0 = parent exited. Failures are finalized
|
|
# here (restore mode, then GuardWatchFail: failure JSON + kill parent + exit 1). Non-blocking and
|
|
# deadline-driven: clear FIFO/line buffer, pump first (feeding the FIFO), classify, wait only when
|
|
# nothing was read; ignored lines never reset the deadline.
|
|
def GuardWaitConfirm(hIn: wb.HANDLE, hParent: wb.HANDLE, Kind: int, TimeoutMs: int) -> int:
|
|
global Guard_Fill
|
|
GuardPendReset() # (1) new round: FIFO and line buffer carry no residue
|
|
Guard_Fill = 0
|
|
Guard_Line[0] = 0
|
|
if Kind == GUARD_IN_CONSOLE:
|
|
# Clear ENABLE_LINE_INPUT so Enter arrives as a KEY_EVENT we can judge ourselves (see module
|
|
# head "Console mode tradeoff"). If the mode cannot be read/switched, fail as usual.
|
|
if GuardEnterRaw(hIn) == 0:
|
|
GuardWatchFail(hParent, "console mode read/switch failed")
|
|
return 0
|
|
Deadline: ULONGLONG = wb.GetTickCount64() + ULONGLONG(uint32(TimeoutMs))
|
|
while True:
|
|
# (2) pump first (GuardPump* feeds the FIFO first): no wasted 20ms wait when a line is buffered
|
|
R: int = 0
|
|
if Kind == GUARD_IN_CONSOLE:
|
|
R = GuardPumpConsole(hIn, Deadline)
|
|
else:
|
|
R = GuardPumpStream(hIn, Kind)
|
|
if R == 1:
|
|
# A completed line is classified first (via the repo json library): a check request is
|
|
# forwarded verbatim and does not count as a heartbeat; anything else goes to
|
|
# GuardClassifyLine (ParseOkOne unchanged).
|
|
if VCheck.ParseCheckLine(str(Guard_Line)) != 0:
|
|
GuardForwardLine()
|
|
else:
|
|
Cls: int = GuardClassifyLine(Guard_Line, Guard_Fill)
|
|
if Cls == GUARD_LINE_OK:
|
|
break # valid {"ok": 1} => break; success finalized after the loop
|
|
if Cls == GUARD_LINE_BADOK:
|
|
# valid shape but bad value => fatal failure with the same stderr text
|
|
GuardLeaveRaw(hIn)
|
|
VLog.LogFmtS1("[confirm] received input that is not {\"ok\": 1}: %s\n", str(Guard_Line))
|
|
GuardWatchFail(hParent, "ok != 1")
|
|
return 0
|
|
# suspect shape (first char not '{' / no '}' / bad length, incl. empty) => echo (truncated) and ignore
|
|
if Guard_Fill > GUARD_SUSPECT_ECHO_MAX:
|
|
Guard_Line[GUARD_SUSPECT_ECHO_MAX] = 0 # echo only the prefix (the line is then dropped)
|
|
VLog.LogFmtS1("[confirm] ignoring suspect input line: %s\n", str(Guard_Line))
|
|
Guard_Fill = 0
|
|
Guard_Line[0] = 0
|
|
# Both ignore paths fall through to the deadline check + wait below. Ignoring does not
|
|
# reset the deadline, so a stream of suspect/check lines still ends at the deadline
|
|
# (R==1 here, so the "R<0" branch is skipped and control goes straight to the wait).
|
|
if R < 0:
|
|
GuardLeaveRaw(hIn)
|
|
if R == GUARD_PUMP_EOF:
|
|
GuardWatchFail(hParent, "stdin reached EOF, no more input possible")
|
|
else:
|
|
GuardWatchFail(hParent, "reading stdin failed")
|
|
return 0
|
|
# (3) nothing read: wait for stdin readable / parent exit / deadline, cap min(remaining, GUARD_POLL_MS)
|
|
Rem2: ULONG = GuardRemainMs(Deadline)
|
|
if Rem2 == 0:
|
|
GuardLeaveRaw(hIn)
|
|
GuardWatchFail(hParent, "timeout with no input")
|
|
return 0
|
|
Sl: ULONG = Rem2
|
|
if Sl > ULONG(GUARD_POLL_MS):
|
|
Sl = ULONG(GUARD_POLL_MS)
|
|
Hs: WATCH_HANDLES = WATCH_HANDLES()
|
|
Hs.H0 = hIn
|
|
Hs.H1 = hParent
|
|
Wm: ULONG = wsync.WaitForMultipleObjects(ULONG(WATCH_HANDLE_COUNT),
|
|
&Hs, 0, Sl)
|
|
if Wm == ULONG(wb.WAIT_OBJECT_0) + ULONG(1): # parent handle (index 1) signaled
|
|
GuardLeaveRaw(hIn)
|
|
return 0
|
|
if Wm == ULONG(wb.WAIT_FAILED):
|
|
GuardLeaveRaw(hIn)
|
|
GuardWatchFail(hParent, "waiting for input failed")
|
|
return 0
|
|
# other results (stdin signaled / WAIT_TIMEOUT) => loop back and pump again
|
|
GuardLeaveRaw(hIn)
|
|
return 1
|
|
|
|
|
|
# confirm-watch entry (dispatched by App/main.vp). Returns the process exit code.
|
|
# Only does heartbeat confirmation; never touches the scan / directory table / cache.
|
|
def RunConfirmWatch() -> int:
|
|
global Guard_IpcWrite
|
|
if VCli.ParseWatchArgs() != 0:
|
|
return 1
|
|
# Verbosity is forwarded by the parent via --verbose (SpawnWatchdog appends it)
|
|
VLog.SetVerbose(VCli.WatchVerbose())
|
|
IntervalSec: int = VCli.WatchInterval()
|
|
TimeoutMs: int = VCli.WatchTimeout()
|
|
ParentPid: LONGLONG = VCli.WatchParentPid()
|
|
IntervalMs: ULONG = ULONG(IntervalSec) * ULONG(1000)
|
|
if GuardWatchInit() == 0:
|
|
VLog.LogStr("[confirm] watchdog: line/record/timestamp/unconsumed-input buffer allocation failed\n")
|
|
return 1
|
|
|
|
VLog.LogFmt3("[confirm] watchdog started: requires {\"ok\": 1} every %u seconds, timeout %u ms, parent pid=%u\n",
|
|
sizet(IntervalSec), sizet(TimeoutMs), sizet(ParentPid))
|
|
|
|
# Parent handle: used both for parent-exit detection (SYNCHRONIZE) and to terminate the parent
|
|
hParent: wb.HANDLE = wp.OpenProcess(ULONG(WATCH_PARENT_ACCESS), 0, ULONG(ParentPid))
|
|
if hParent is None or hParent == wb.INVALID_HANDLE_VALUE:
|
|
# No parent handle => cannot monitor or terminate; log to stderr and exit 1
|
|
VLog.LogFmt1("[confirm] watchdog: OpenProcess on parent failed (GetLastError=%u), exiting\n",
|
|
sizet(wb.GetLastError()))
|
|
return 1
|
|
|
|
hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE)
|
|
if hIn is None or hIn == wb.INVALID_HANDLE_VALUE:
|
|
GuardWatchFail(hParent, "stdin handle invalid")
|
|
return 1
|
|
|
|
# check channel write end: the handle value passed by the parent via `--ipc-write <value>`,
|
|
# restored here (u64 bit pattern; 0 / missing => no channel, check lines only get a stderr note).
|
|
if VCli.WatchIpcWrite() != 0:
|
|
Guard_IpcWrite = rawptr(VCli.WatchIpcWrite())
|
|
else:
|
|
Guard_IpcWrite = None
|
|
Ch0: int = 0
|
|
if Guard_IpcWrite is not None:
|
|
Ch0 = 1
|
|
VLog.VerboseFmt1("[confirm] watchdog: check channel=%u (1=configured 0=none)\n", sizet(Ch0))
|
|
|
|
# Input kind is classified once (the handle never changes); console and pipe/redirect pump differently.
|
|
Kind: int = GuardInputKind(hIn)
|
|
VLog.VerboseFmt1("[confirm] watchdog: stdin kind=%u (1=console 2=pipe 3=other stream)\n",
|
|
sizet(Kind))
|
|
# Console pump path (Hardening 1): primary ReadConsoleInputExA+NOWAIT, fallback query+ReadConsoleInputA.
|
|
# Resolve once here (later rounds reuse the cached state) and log the chosen path under --verbose.
|
|
if Kind == GUARD_IN_CONSOLE:
|
|
GuardResolveReadEx()
|
|
VLog.VerboseFmt1("[confirm] watchdog: console read path=%u (1=ReadConsoleInputExA+NOWAIT 2=fallback query+ReadConsoleInputA)\n",
|
|
sizet(Guard_ReadExState))
|
|
|
|
while True:
|
|
# Phase 1: wait interval; if the parent exited first, exit immediately (no orphans)
|
|
Ws: ULONG = wsync.WaitForSingleObject(hParent, IntervalMs)
|
|
if Ws == wb.WAIT_OBJECT_0:
|
|
VLog.LogStr("[confirm] watchdog: parent exited, exiting\n")
|
|
wb.CloseHandle(hParent)
|
|
return 0
|
|
if Ws != wb.WAIT_TIMEOUT:
|
|
VLog.LogFmt1("[confirm] watchdog: waiting for parent failed (wait=%u)\n", sizet(Ws))
|
|
GuardWatchFail(hParent, "waiting for parent failed")
|
|
return 1
|
|
|
|
# Phase 2: stderr prompt (deadline starts here) + non-blocking wait for a complete classified line
|
|
VLog.LogFmt2("[confirm] please enter {\"ok\": 1} within %u ms (every %u seconds)\n",
|
|
sizet(TimeoutMs), sizet(IntervalSec))
|
|
VLog.TimeStampNow(Guard_Stamp, GUARD_STAMP_CAP)
|
|
VLog.TimeStampAfter(Guard_Dl, GUARD_STAMP_CAP, sizet(TimeoutMs))
|
|
VLog.LogFmtS2("[confirm] now %s, this-round deadline %s\n", str(Guard_Stamp), str(Guard_Dl))
|
|
|
|
# Failure paths (deadline / bad value / read error / EOF) already finalized the process inside
|
|
# GuardWaitConfirm; a 0 return only means "parent exited", so the watchdog exits itself.
|
|
if GuardWaitConfirm(hIn, hParent, Kind, TimeoutMs) == 0:
|
|
VLog.LogStr("[confirm] watchdog: parent exited, exiting\n")
|
|
wb.CloseHandle(hParent)
|
|
return 0
|
|
VLog.LogFmt1("[confirm] confirmed (ok=1), next in about %u seconds\n", sizet(IntervalSec))
|