mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
feat(blahd): impl socket activation integration
This also allows arbitrary listening fd include UNIX domain sockets, via environment variables as sd_listen_fds(3).
This commit is contained in:
parent
87c8acd5b2
commit
98b2df2fdf
8 changed files with 206 additions and 13 deletions
34
Cargo.lock
generated
34
Cargo.lock
generated
|
@ -306,13 +306,16 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"humantime",
|
"humantime",
|
||||||
|
"nix",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"scopeguard",
|
||||||
"sd-notify",
|
"sd-notify",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-constant",
|
||||||
"serde-inline-default",
|
"serde-inline-default",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
@ -367,6 +370,12 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.38"
|
version = "0.4.38"
|
||||||
|
@ -1159,6 +1168,18 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
@ -1558,9 +1579,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.34"
|
version = "0.38.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
|
checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
|
@ -1686,6 +1707,15 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde-constant"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "301d0de95fe56444b1ea1ee62363452b756fe715fb4eab505be16b906599b47e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde-inline-default"
|
name = "serde-inline-default"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
|
@ -16,6 +16,7 @@ parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning.
|
||||||
rusqlite = "0.32"
|
rusqlite = "0.32"
|
||||||
sd-notify = "0.4"
|
sd-notify = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde-constant = "0.1"
|
||||||
serde-inline-default = "0.2.0"
|
serde-inline-default = "0.2.0"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
|
@ -30,9 +31,11 @@ url = { version = "2.5.2", features = ["serde"] }
|
||||||
blah-types = { path = "../blah-types", features = ["rusqlite"] }
|
blah-types = { path = "../blah-types", features = ["rusqlite"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
nix = { version = "0.29.0", features = ["fs", "process", "signal"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
reqwest = { version = "0.12.7", features = ["json"] }
|
reqwest = { version = "0.12.7", features = ["json"] }
|
||||||
rstest = { version = "0.22.0", default-features = false }
|
rstest = { version = "0.22.0", default-features = false }
|
||||||
|
scopeguard = "1.2.0"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# The example configuration file, required options are documented as
|
# The example configuration file, required options are documented as
|
||||||
# `(Required)`, other options are optional and the example value given here is
|
# `(Required)`, other options are optional and the example value given here is
|
||||||
# the default value.
|
# the default value, or example values for commented lines.
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# If enabled, a in-memory non-persistent database is used instead. Options
|
# If enabled, a in-memory non-persistent database is used instead. Options
|
||||||
|
@ -16,11 +16,16 @@ path = "/var/lib/blahd/db.sqlite"
|
||||||
# Note that parent directory will never be created and must already exist.
|
# Note that parent directory will never be created and must already exist.
|
||||||
create = true
|
create = true
|
||||||
|
|
||||||
|
# Listener socket configuration. (Required)
|
||||||
|
# There must be exact one option under this section being set.
|
||||||
[listen]
|
[listen]
|
||||||
# (Required)
|
# Listen on an address.
|
||||||
# The local address to listen on.
|
|
||||||
address = "localhost:8080"
|
address = "localhost:8080"
|
||||||
|
|
||||||
|
# Use systemd socket activation mechanism to get listener fd from envvars.
|
||||||
|
# See also sd_listen_fds(3) and systemd.socket(5).
|
||||||
|
#systemd = true
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
# (Required)
|
# (Required)
|
||||||
# The global absolute URL prefix where this service is hosted.
|
# The global absolute URL prefix where this service is hosted.
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::os::fd::FromRawFd;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use blahd::config::{Config, ListenConfig};
|
use blahd::config::{Config, ListenConfig};
|
||||||
use blahd::{AppState, Database};
|
use blahd::{AppState, Database};
|
||||||
|
|
||||||
|
@ -53,14 +55,32 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn main_serve(db: Database, config: Config) -> Result<()> {
|
async fn main_serve(db: Database, config: Config) -> Result<()> {
|
||||||
let listener = match &config.listen {
|
|
||||||
ListenConfig::Address(addr) => tokio::net::TcpListener::bind(addr)
|
|
||||||
.await
|
|
||||||
.context("failed to listen on socket")?,
|
|
||||||
};
|
|
||||||
let st = AppState::new(db, config.server);
|
let st = AppState::new(db, config.server);
|
||||||
|
|
||||||
tracing::info!("listening on {:?}", config.listen);
|
let listener = match &config.listen {
|
||||||
|
ListenConfig::Address(addr) => {
|
||||||
|
tracing::info!("listening on {addr:?}");
|
||||||
|
tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.context("failed to listen on socket")?
|
||||||
|
}
|
||||||
|
ListenConfig::Systemd(_) => {
|
||||||
|
tracing::info!("listening on fd from environment");
|
||||||
|
let [fd] = sd_notify::listen_fds()
|
||||||
|
.context("failed to get fds from sd_listen_fds(3)")?
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("more than one fds available from sd_listen_fds(3)"))?;
|
||||||
|
// SAFETY: `fd` is valid by sd_listen_fds(3) protocol.
|
||||||
|
let listener = unsafe { TcpListener::from_raw_fd(fd) };
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.context("failed to set socket non-blocking")?;
|
||||||
|
tokio::net::TcpListener::from_std(listener)
|
||||||
|
.context("failed to register async socket")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let router = blahd::router(Arc::new(st));
|
let router = blahd::router(Arc::new(st));
|
||||||
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);
|
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{ensure, Result};
|
use anyhow::{ensure, Result};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use serde_constant::ConstBool;
|
||||||
use serde_inline_default::serde_inline_default;
|
use serde_inline_default::serde_inline_default;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -31,7 +32,7 @@ pub struct DatabaseConfig {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ListenConfig {
|
pub enum ListenConfig {
|
||||||
Address(String),
|
Address(String),
|
||||||
// TODO: Unix socket.
|
Systemd(ConstBool<true>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_inline_default]
|
#[serde_inline_default]
|
||||||
|
|
122
blahd/tests/socket_activate.rs
Normal file
122
blahd/tests/socket_activate.rs
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Seek, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
|
use std::process::abort;
|
||||||
|
use std::ptr::null;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use nix::fcntl::{fcntl, FcntlArg, FdFlag};
|
||||||
|
use nix::libc::execve;
|
||||||
|
use nix::sys::memfd::{memfd_create, MemFdCreateFlag};
|
||||||
|
use nix::sys::signal::{kill, Signal};
|
||||||
|
use nix::sys::wait::{waitpid, WaitStatus};
|
||||||
|
use nix::unistd::{alarm, dup2, fork, getpid, ForkResult};
|
||||||
|
use tokio::io::stderr;
|
||||||
|
|
||||||
|
const TIMEOUT_SEC: u32 = 1;
|
||||||
|
|
||||||
|
const SERVER_EXE_PATH: &str = env!("CARGO_BIN_EXE_blahd");
|
||||||
|
|
||||||
|
const CONFIG: &str = r#"
|
||||||
|
[database]
|
||||||
|
in_memory = true
|
||||||
|
[listen]
|
||||||
|
systemd = true
|
||||||
|
[server]
|
||||||
|
base_url = "http://example.com"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_activate() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let local_port = listener.local_addr().unwrap().port();
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Remove `FD_CLOEXEC` since we want to send it to the child.
|
||||||
|
fn remove_cloexec(fd: &impl AsFd) {
|
||||||
|
let mut flags =
|
||||||
|
FdFlag::from_bits_retain(fcntl(fd.as_fd().as_raw_fd(), FcntlArg::F_GETFD).unwrap());
|
||||||
|
flags -= FdFlag::FD_CLOEXEC;
|
||||||
|
fcntl(fd.as_fd().as_raw_fd(), FcntlArg::F_SETFD(flags)).unwrap();
|
||||||
|
}
|
||||||
|
remove_cloexec(&listener);
|
||||||
|
remove_cloexec(&stderr());
|
||||||
|
|
||||||
|
let server_exe_c = CString::new(SERVER_EXE_PATH).unwrap();
|
||||||
|
|
||||||
|
// Intentionally no FD_CLOEXEC.
|
||||||
|
let mut memfd = File::from(memfd_create(c"test-config", MemFdCreateFlag::empty()).unwrap());
|
||||||
|
memfd.write_all(CONFIG.as_bytes()).unwrap();
|
||||||
|
memfd.rewind().unwrap();
|
||||||
|
|
||||||
|
// Unfortunately we have to deal with raw `fork(2)` here, because no library supports passing
|
||||||
|
// child PID in environment variables for child.
|
||||||
|
// SAFETY: Between `fork()` and `execve()`, all syscalls are async-signal-safe:
|
||||||
|
// no memory allocation, no panic unwinding (always abort).
|
||||||
|
match unsafe { fork().unwrap() } {
|
||||||
|
ForkResult::Child => {
|
||||||
|
// Ideally, we want `std::panic::always_abort()`, which is unstable yet.
|
||||||
|
// WAIT: https://github.com/rust-lang/rust/issues/84438
|
||||||
|
scopeguard::defer!(abort());
|
||||||
|
|
||||||
|
// Don't leave an orphan process if something does wrong.
|
||||||
|
alarm::set(TIMEOUT_SEC);
|
||||||
|
|
||||||
|
// Ignore all errors here to stay safe, and lazy.
|
||||||
|
let _ = dup2(2, 1); // stdout <- stderr
|
||||||
|
let _ = dup2(memfd.as_raw_fd(), 0); // stdin <- config memfd
|
||||||
|
let _ = dup2(listener.as_raw_fd(), 3); // listener fd
|
||||||
|
let args = [
|
||||||
|
c"blahd".as_ptr(),
|
||||||
|
c"serve".as_ptr(),
|
||||||
|
c"-c".as_ptr(),
|
||||||
|
c"/proc/self/fd/0".as_ptr(),
|
||||||
|
null(),
|
||||||
|
];
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let _ = write!(&mut buf[..], "LISTEN_PID={}\0", getpid().as_raw());
|
||||||
|
let envs = [c"LISTEN_FDS=1".as_ptr(), buf.as_ptr().cast(), null()];
|
||||||
|
// NB. Do raw libc call, not the wrapper fn that does allocation inside.
|
||||||
|
// SAFETY: Valid NULL-terminated array of NULL-terminated strings.
|
||||||
|
unsafe {
|
||||||
|
execve(server_exe_c.as_ptr(), args.as_ptr(), envs.as_ptr());
|
||||||
|
// If exec fail, the drop guard will abort the process anyway. Do nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForkResult::Parent { child } => {
|
||||||
|
{
|
||||||
|
scopeguard::defer! {
|
||||||
|
let _ = kill(child, Signal::SIGKILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = rt.block_on(async {
|
||||||
|
let fut = async {
|
||||||
|
reqwest::get(format!("http://127.0.0.1:{local_port}/room?filter=public"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
tokio::time::timeout(Duration::from_secs(TIMEOUT_SEC.into()), fut)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(resp, r#"{"rooms":[]}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
let st = waitpid(child, None).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(st, WaitStatus::Signaled(_, Signal::SIGKILL, _)),
|
||||||
|
"unexpected exit status {st:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
contrib/blahd.example.socket
Normal file
9
contrib/blahd.example.socket
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Blah Chat Server Socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=[::]:8080
|
||||||
|
BindIPv6Only=both
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
|
@ -65,6 +65,9 @@ rec {
|
||||||
"--package=blahctl"
|
"--package=blahctl"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Intentionally omit the socket unit. It is trivial but
|
||||||
|
# highly configuration-specific. Users who want to use it almost
|
||||||
|
# always need customization.
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
mkdir -p $out/etc/systemd/system
|
mkdir -p $out/etc/systemd/system
|
||||||
substitute ./contrib/blahd.example.service $out/etc/systemd/system/blahd.service \
|
substitute ./contrib/blahd.example.service $out/etc/systemd/system/blahd.service \
|
||||||
|
|
Loading…
Add table
Reference in a new issue