פתרון הפרויקט: שירות מוגבל ומפוקח¶
שמירת הגבולות שכבר בנינו¶
נשתמש בקוד שרת הפרויקט הקודם ובמודול rights.rs שלו. הפונקציות network, local, read_sealed, transform וקבועי הגודל נשארים כפי שהוגדרו. נחליף את בדיקת הזהות ואת הפונקציה הראשית, ונסיר את המבנה EndpointFiles, משום שהיישום אינו יוצר או מוחק עוד את נתיב הסוקט. כל הקוד החדש מופיע בהמשך, יחד עם יחידות ההפעלה.
נוסיף ליבוא הקיים את FromRawFd ואת OwnedFd. נשתמש באותה תלות של הפרויקט ב־libc וב־tokio, ונקשר גם ל־libsystemd שהכנו בפרק. הפונקציה הראשית החדשה בונה את סביבת התזמון רק אחרי קריאת משתני ההפעלה וניקוים, כדי שהשינוי בסביבה לא יתבצע לצד תהליכונים אחרים.
use std::os::fd::{FromRawFd, OwnedFd};
#[link(name = "systemd")]
unsafe extern "C" {
fn sd_listen_fds(unset_environment: libc::c_int) -> libc::c_int;
fn sd_is_socket(fd: libc::c_int, family: libc::c_int,
kind: libc::c_int, listening: libc::c_int) -> libc::c_int;
fn sd_notify(unset_environment: libc::c_int,
state: *const libc::c_char) -> libc::c_int;
fn sd_watchdog_enabled(unset_environment: libc::c_int,
usec: *mut u64) -> libc::c_int;
}
fn notice(text: &str) -> io::Result<()> {
let text = std::ffi::CString::new(text).map_err(io::Error::other)?;
let result = unsafe { sd_notify(0, text.as_ptr()) };
if result < 0 { return Err(io::Error::from_raw_os_error(-result)); }
if result == 0 { return Err(io::Error::other("missing notification endpoint")); }
Ok(())
}
fn activated() -> io::Result<(std::net::TcpListener,
std::os::unix::net::UnixListener, Duration)> {
let count = unsafe { sd_listen_fds(1) };
if count < 0 { return Err(io::Error::from_raw_os_error(-count)); }
let mut owned: Vec<OwnedFd> = (0..count).map(|offset|
unsafe { OwnedFd::from_raw_fd(3 + offset) }).collect();
if owned.len() != 2 { return Err(invalid("expected two activation descriptors")); }
let unix_fd = owned.pop().unwrap();
let tcp_fd = owned.pop().unwrap();
for (fd, family) in [(tcp_fd.as_raw_fd(), libc::AF_INET),
(unix_fd.as_raw_fd(), libc::AF_UNIX)] {
let valid = unsafe { sd_is_socket(fd, family, libc::SOCK_STREAM, 1) };
if valid != 1 { return Err(invalid("invalid activation socket type")); }
}
let tcp = std::net::TcpListener::from(tcp_fd);
let unix = std::os::unix::net::UnixListener::from(unix_fd);
tcp.set_nonblocking(true)?;
unix.set_nonblocking(true)?;
let mut usec = 0_u64;
let enabled = unsafe { sd_watchdog_enabled(0, &mut usec) };
if enabled < 0 { return Err(io::Error::from_raw_os_error(-enabled)); }
if enabled == 0 { return Err(invalid("watchdog must be configured")); }
Ok((tcp, unix, Duration::from_micros((usec / 2).max(1))))
}
fn check_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> {
let expected: u32 = std::env::var("AMIT_ALLOWED_UID")
.map_err(io::Error::other)?.parse().map_err(io::Error::other)?;
let mut value = std::mem::MaybeUninit::<libc::ucred>::uninit();
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let result = unsafe { libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET,
libc::SO_PEERCRED, value.as_mut_ptr().cast(), &mut length) };
if result == -1 { return Err(io::Error::last_os_error()); }
if length as usize != std::mem::size_of::<libc::ucred>() {
return Err(invalid("credential length"));
}
if unsafe { value.assume_init() }.uid != expected {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "peer UID"));
}
Ok(())
}
async fn serve(tcp: std::net::TcpListener,
unix: std::os::unix::net::UnixListener, tick: Duration) -> io::Result<()> {
let tcp = TcpListener::from_std(tcp)?;
let unix = UnixListener::from_std(unix)?;
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut heartbeat = tokio::time::interval(tick);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let permits = Arc::new(Semaphore::new(ACTIVE as usize));
let mut jobs = JoinSet::new();
notice("READY=1\nSTATUS=Serving bounded requests")?;
loop {
tokio::select! {
_ = term.recv() => break,
_ = heartbeat.tick() => notice("WATCHDOG=1")?,
result = jobs.join_next(), if !jobs.is_empty() => {
eprintln!("request result={result:?}");
}
accepted = tcp.accept() => {
let (stream, _) = accepted?;
match permits.clone().try_acquire_owned() {
Ok(permit) => { jobs.spawn(async move {
tokio::time::timeout(Duration::from_secs(3), network(stream, permit))
.await.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "request deadline"))?
}); }
Err(_) => eprintln!("network request rejected: capacity"),
}
}
accepted = unix.accept() => {
let (stream, _) = accepted?;
match permits.clone().try_acquire_owned() {
Ok(permit) => { jobs.spawn(async move {
tokio::time::timeout(Duration::from_secs(3), local(stream, permit))
.await.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "request deadline"))?
}); }
Err(_) => eprintln!("local request rejected: capacity"),
}
}
}
}
notice("STOPPING=1\nSTATUS=Draining accepted work")?;
drop(tcp);
drop(unix);
let drain = async {
while let Some(result) = jobs.join_next().await { eprintln!("drain={result:?}"); }
};
if tokio::time::timeout(Duration::from_secs(5), drain).await.is_err() {
jobs.abort_all();
while let Some(result) = jobs.join_next().await { eprintln!("aborted={result:?}"); }
}
let all = permits.acquire_many_owned(ACTIVE).await.map_err(io::Error::other)?;
drop(all);
println!("all request resources returned");
Ok(())
}
fn main() -> io::Result<()> {
let _: u32 = std::env::var("AMIT_ALLOWED_UID")
.map_err(io::Error::other)?.parse().map_err(io::Error::other)?;
let (tcp, unix, tick) = activated()?;
tokio::runtime::Builder::new_multi_thread().worker_threads(2)
.max_blocking_threads(ACTIVE as usize).enable_all().build()?
.block_on(serve(tcp, unix, tick))
}
תיאור ההפעלה¶
ניצור את יחידת הסוקט amit-bounded-lab.socket. סדר המתארים הוא סדר ההגדרות בתוך אותה יחידה, והקוד בודק גם את סוגם. הרשאת הגישה לסוקט המקומי מאפשרת להגיע לממשק, אך הרשאת הפעולה נקבעת גם בבדיקת זהות העמית שבתוכנית.
[Unit]
Description=Amit bounded endpoints
[Socket]
ListenStream=127.0.0.1:43000
ListenStream=/run/amit-bounded-lab.sock
SocketMode=0666
Accept=no
Backlog=32
RemoveOnStop=yes
ניצור את היחידה amit-bounded-lab.service ונעתיק את הקובץ הבנוי לנתיב המופיע בה. נחליף את המספר בדוגמה בזהות המשתמש המקומי שהורשה להפעיל את לקוח העברת המתארים. המספר הוא החלטת מנהל השירות ולא מידע שהלקוח רשאי לשלוח בתוך הבקשה.
[Unit]
Description=Amit bounded service
Requires=amit-bounded-lab.socket
After=amit-bounded-lab.socket
StartLimitIntervalSec=30
StartLimitBurst=3
[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/libexec/amit-bounded-lab
Environment=AMIT_ALLOWED_UID=1000
DynamicUser=yes
NoNewPrivileges=yes
PrivateNetwork=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=
UMask=0077
CPUQuota=50%
MemoryHigh=48M
MemoryMax=96M
TasksMax=32
WatchdogSec=2
TimeoutStartSec=5
TimeoutStopSec=8
Restart=on-failure
RestartSec=1
הרשימה הריקה של סמכויות מצמצמת את הסמכויות שהשירות יכול לקבל בהפעלה. מגבלת המשימות משאירה מקום לתהליכון הראשי, לשני עובדי סביבת התזמון ולעבודות החוסמות. המספר אינו ניחוש חסר בדיקה: נמדוד את מספר המשימות בפועל תחת עומס ונאמת שיצירת עובדים אינה נכשלת בשקט.
בדיקות שחייבות להבחין בסיבות¶
נפעיל את יחידת הסוקט ונריץ את שני לקוחות הפרויקט הקודם, כאשר הלקוח המקומי מקבל את הנתיב החדש. התוצאה המותרת נשארת אותיות גדולות עם זהות הבקשה המתאימה. נריץ את הלקוח המקומי גם מזהות מעבדה אחרת ונצפה לדחייה לפני קריאת תוכן הקובץ. נבדיל דחיית זהות מכשל חיבור שמקורו בהרשאת הנתיב.
נפתח חיבור ששולח רק חלק מכותרת ונבקש עצירה. השירות מפסיק לקבל עבודה, והבקשה הישנה מסתיימת לפי זמן הבקשה או לפי הניקוז. ההמתנה לכל ההיתרים מגלה אם עבודה חוסמת עדיין מחזיקה משאב. גבול העצירה של מנהל השירות הוא המנגנון האחרון לסיום תהליך שאינו מצליח להשלים, ולכן אין הבטחה שכל בקשה מקבלת תשובה במהלך סיום כפוי.
נריץ את תוכנית מכסות המשאבים מההרצאה המתאימה ביחידת ניסוי נפרדת עם אותם גבולות. נמדוד הפרשי זמן מעבד, אירועי לחץ זיכרון ותוצאת חריגה. הבדיקה אינה מוכיחה את קצב השרת, אלא שהמדיניות שהגדרנו מסוגלת לאכוף את הגבולות. מדידת קצב וזמן תגובה של השרת תיעשה בניסוי נפרד עם לקוחות ועומס מתועד.
מפת הסמכויות בפתרון¶
מנהל השירותים פותח את נקודות הקצה ומחזיק אותן בין מופעים. התהליך מקבל עותקים, מאמת אותם ומעביר את הבעלות לעטיפות האסינכרוניות. לקוח מקומי מורשה מוסר קובץ חתום, והשרת מחזיק בו רק לאורך העבודה המוגבלת. העובד החוסם מחזיק גם בהיתר, כך שביטול הממתין אינו יוצר חריגה סמויה ממכסת המשאבים.
תצוגת הרשת הפרטית מונעת מהתהליך להסתמך על ממשקי המארח לצורך יצירת תקשורת חדשה, אך ההפניות שהועברו נשארות פעילות לפי חוזה האובייקט שלהן. תצוגת הקבצים המוגבלת אינה מבטלת את המתאר שהלקוח מסר. אלה ערוצים מכוונים במפת הסמכויות, ולכן האימות של סוג הקובץ, החתימות וגודלו נשאר חיוני גם לאחר הוספת הגבלות מערכת.
הפתרון אינו מבטיח עמידות עסקית אחרי קריסה ואינו טוען שמסנן משפחות סוקטים הוא מדיניות רשת מלאה. הוא מספק שירות קטן בעל גבולות שניתן לבדוק, ומפריד בין ההגנות על יצירת משאבים לבין חוזה השימוש במשאבים שהתקבלו מראש.