שירות ומנהל השירותים¶
עד עכשיו התחלנו תוכנית מתוך טרמינל. לתוכנית היה משתמש ברור, תיקיית עבודה שאנחנו בחרנו, ולעיתים גם חלון שנשאר פתוח. עכשיו נרצה שהמערכת שלנו תתחיל כשווינדוס עולה, תעבוד בלי משתמש מחובר ותישאר ניתנת לניהול מרחוק. אם נוסיף לולאה אינסופית לmain, עדיין לא קיבלנו שירות של ווינדוס.
שירות הוא גם תוכנית וגם קשר עם מנהל השירותים, הSCM. במאגר של המנהל יש רשומה עם שם השירות, פקודת ההפעלה, זהות הריצה, סוג ההפעלה ותלויות. בזמן ריצה התהליך מדווח מצבים ומקבל בקשות ניהול. הרשומה יכולה להתקיים כשאין תהליך, ותהליך אחד יכול לארח יותר משירות אחד.
בואו נפריד שלושה דברים שקל לערבב: רשומה בשם AmitTechLab, קובץ בשם service.exe ותהליך עם PID מסוים. אפשר לשנות את קובץ ההרצה בין שתי הפעלות בלי לשנות את שם השירות. אפשר גם לראות PID חדש אחרי restart. לכן מזהה השירות בלוגים ומזהה ההרצה הנוכחית צריכים להיות שני שדות שונים.
נתחיל מתצפית¶
לפני שנכתוב שירות, נכתוב לקוח ניהול שקורא מידע. הוא לא מפעיל ולא עוצר דבר. שמרו בקובץ query_service.cpp.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#pragma comment(lib, "advapi32.lib")
struct service_handle {
SC_HANDLE value{};
explicit service_handle(SC_HANDLE h) : value(h) {}
service_handle(const service_handle&) = delete;
service_handle& operator=(const service_handle&) = delete;
~service_handle() { if (value) CloseServiceHandle(value); }
};
[[noreturn]] void fail(const char* call, DWORD error = GetLastError()) {
throw std::runtime_error(std::string(call) + ": " + std::to_string(error));
}
int wmain(int argc, wchar_t** argv) {
try {
if (argc != 2) throw std::runtime_error("pass a service name");
service_handle manager(OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT));
if (!manager.value) fail("OpenSCManager");
service_handle service(OpenServiceW(manager.value, argv[1],
SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS));
if (!service.value) fail("OpenService");
DWORD required = 0;
if (QueryServiceConfigW(service.value, nullptr, 0, &required))
throw std::runtime_error("unexpected empty configuration");
DWORD error = GetLastError();
if (error != ERROR_INSUFFICIENT_BUFFER) fail("QueryServiceConfig size", error);
std::vector<BYTE> storage(required);
auto config = reinterpret_cast<QUERY_SERVICE_CONFIGW*>(storage.data());
if (!QueryServiceConfigW(service.value, config,
static_cast<DWORD>(storage.size()), &required))
fail("QueryServiceConfig data");
std::wcout << L"binary: " << config->lpBinaryPathName << L'\n'
<< L"account: " << config->lpServiceStartName << L'\n'
<< L"type: " << config->dwServiceType << L'\n'
<< L"start: " << config->dwStartType << L'\n';
SERVICE_STATUS_PROCESS status{};
if (!QueryServiceStatusEx(service.value, SC_STATUS_PROCESS_INFO,
reinterpret_cast<BYTE*>(&status), sizeof(status), &required))
fail("QueryServiceStatusEx");
std::wcout << L"state: " << status.dwCurrentState << L'\n'
<< L"pid: " << status.dwProcessId << L'\n'
<< L"exit: " << status.dwWin32ExitCode << L'\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
בנו את התוכנית, מצאו שם שירות קיים, והשוו לכלי המערכת.
cl /std:c++20 /EHsc /W4 query_service.cpp
Get-Service | Select-Object -First 10 Name, Status
.\query_service.exe EventLog
sc.exe queryex EventLog
sc.exe qc EventLog
השם לתוכנית הוא Name, לא בהכרח הטקסט הידידותי שמופיע בחלון השירותים. שימו לב גם לsc.exe עם הסיומת; בסביבות PowerShell מסוימות השם הקצר sc עשוי להצביע על alias אחר.
הקוד פותח שני אובייקטים עם הרשאות שונות. אל מנהל השירותים אנחנו מבקשים להתחבר; אל השירות אנחנו מבקשים לקרוא תצורה ומצב. אין צורך לבקש ALL_ACCESS רק מפני שהוא חוסך לבחור bits. דווקא כלי קריאה טוב צריך להמשיך לעבוד כשאין למשתמש הרשאת עצירה.
הפונקציה הראשונה מחזירה גודל, והשנייה ממלאת את הbuffer. אם התצורה השתנתה באמצע וגודלה גדל, הקריאה השנייה יכולה להיכשל. בדוגמה אנחנו מדווחים את השגיאה; כלי ניהול יכול לנסות שוב מספר מוגבל של פעמים. אין כאן snapshot משותף לתצורה ולמצב: בין שתי השאילתות השירות יכול להיעצר.
מי מפעיל את מי¶
כאשר מפעילים שירות, הSCM דואג להפעלת התהליך לפי התצורה. בתוכנית השירות הthread הראשי קורא לStartServiceCtrlDispatcherW ומתחבר למנגנון הניהול. הקריאה מקבלת טבלה של שמות שירותים ופונקציות כניסה, ולא מחליפה את נקודת הכניסה הרגילה של קובץ הexe.
לאחר מכן המנהל יכול לגרום להפעלת ServiceMain בthread מתאים. הפונקציה הזאת רושמת control handler, מדווחת שהאתחול מתבצע ומאתחלת את השירות. כשהאתחול הושלם, היא מדווחת שהשירות רץ. אפשר לעקוב אחר תפקיד הdispatcher ומבנה ServiceMain בתיעוד.
אם נריץ exe שמנסה להתחבר לSCM ישירות מהטרמינל, נוכל לקבל ERROR_FAILED_SERVICE_CONTROLLER_CONNECT. זאת לא הוכחה שהקוד הפנימי של השירות נכשל. התוכנית פשוט לא הופעלה בתוך הקשר הניהול שהוא מצפה לו. במוצר נוח להפריד מנוע עבודה שאפשר להריץ בconsole ממעטפת השירות, כדי לבדוק כל אחד מהם בנפרד.
זהות ותלויות¶
התהליך מקבל token בהתאם לחשבון השירות שהוגדר. מכאן כל פעולת קובץ, registry או IPC כפופה לזהות הזאת. אם משהו עובד בהרצה ידנית ונכשל כשירות, לא נשנה מיד את החשבון לLocalSystem. נבדוק איזה אובייקט נפתח, באיזו זהות ועם אילו הרשאות. אפשר לקרוא את מודל חשבונות השירות ולחזור לניסוי הtoken מההרצאות הקודמות.
בתכנון נפריד צורך מקומי מצורך ברשת. שירות שקורא תיקייה מקומית וכותב לוג לא בהכרח צריך זהות בעלת זכויות רחבות בדומיין. שירות שצריך שרת מרוחק צריך גם הסבר איך זהותו מוצגת שם. השם של החשבון לבדו אינו רשימת ההרשאות המדויקת; נבדוק groups, privileges וDACLים במשאבים הרלוונטיים.
גם dependency במאגר השירותים אינה הבטחה שכל פעולה עסקית כבר אפשרית. נניח שהשירות שלנו תלוי בשירות אחסון שהגיע למצב running, אבל מסד הנתונים עדיין מבצע recovery. נצטרך חוזה readiness של הרכיב עצמו, ניסיון חוזר מוגבל, והודעה שמסבירה מה עדיין חסר. אחרת נציג למנהל מצב תקין בזמן שהלקוחות מקבלים כשל.
שלושה כשלי התחלה שונים¶
בכשל הראשון קובץ ההרצה לא נמצא או לא ניתן לטעון אותו. בשלב הזה קוד האתחול שלנו בכלל לא רץ. בכשל השני התהליך התחיל, אבל לא נוצר חיבור תקין למנהל השירותים. בכשל השלישי הגענו לServiceMain, ובדיקת התצורה או פתיחת האחסון נכשלו.
כדי לאבחן אותם נצטרך נתונים שונים: נתיב הפעלה ותלויות בינאריות במקרה הראשון, כניסה לdispatcher במקרה השני, וקוד שגיאה משלב האתחול במקרה השלישי. רישום אחד של "השירות לא עולה" מסתיר את ההבחנה שאנחנו צריכים. בהרצאה הבאה נבנה שירות קטן שמדווח את המצבים האלה, ונבדוק מה קורה כשמבקשים ממנו לעצור בזמן עבודה.