use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf};
use tokio::net::TcpStream;
use crate::acl::RuleDecision;
pub use async_trait::async_trait;
pub use crate::acl::Verdict;
pub use crate::config::Protocol;
pub use crate::socks5::Command;
pub use crate::relay::{ClientDatagrams, DatagramAuthorizer, UpstreamOriginator, UpstreamTarget};
#[derive(Debug, Clone, Default)]
pub struct TagSet {
tags: std::collections::BTreeSet<String>,
}
impl TagSet {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, tag: impl Into<String>) -> bool {
self.tags.insert(tag.into())
}
pub fn contains(&self, tag: &str) -> bool {
self.tags.contains(tag)
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.tags.iter().map(String::as_str)
}
pub fn len(&self) -> usize {
self.tags.len()
}
pub fn is_empty(&self) -> bool {
self.tags.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct RuleInfo {
verdict: Verdict,
source_line: Option<usize>,
rule_name: Option<Arc<str>>,
}
impl RuleInfo {
pub fn new(verdict: Verdict, source_line: Option<usize>, rule_name: Option<Arc<str>>) -> Self {
RuleInfo {
verdict,
source_line,
rule_name,
}
}
pub fn verdict(&self) -> Verdict {
self.verdict
}
pub fn source_line(&self) -> Option<usize> {
self.source_line
}
pub fn rule_name(&self) -> Option<&str> {
self.rule_name.as_deref()
}
}
impl RuleInfo {
pub(crate) fn from_decision(d: &RuleDecision) -> Self {
RuleInfo {
verdict: d.verdict,
source_line: d.source_line,
rule_name: d.rule_name.clone(),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct FlowCtx<'a> {
pub client: SocketAddr,
pub proxy: SocketAddr,
pub command: Command,
pub protocol: Protocol,
pub dest_host: Option<&'a str>,
pub dest: SocketAddr,
pub rule: RuleInfo,
pub tags: TagSet,
}
impl<'a> FlowCtx<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
client: SocketAddr,
proxy: SocketAddr,
command: Command,
protocol: Protocol,
dest_host: Option<&'a str>,
dest: SocketAddr,
rule: RuleInfo,
tags: TagSet,
) -> Self {
FlowCtx {
client,
proxy,
command,
protocol,
dest_host,
dest,
rule,
tags,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FlowDecision {
Continue,
Deny(&'static str),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct FlowStats {
pub to_target: u64,
pub to_client: u64,
}
impl FlowStats {
pub fn new(to_target: u64, to_client: u64) -> Self {
FlowStats {
to_target,
to_client,
}
}
pub fn total(&self) -> u64 {
self.to_target.saturating_add(self.to_client)
}
}
pub struct ClientStream {
inner: crate::client_stream::ClientStream,
}
impl ClientStream {
pub fn from_tcp(stream: TcpStream) -> Self {
Self {
inner: crate::client_stream::ClientStream::Tcp(stream),
}
}
pub(crate) fn from_engine(inner: crate::client_stream::ClientStream) -> Self {
Self { inner }
}
}
impl AsyncRead for ClientStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl AsyncWrite for ClientStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
#[derive(Clone, Default)]
pub struct Throttle {
inner: crate::throttle::Throttle,
}
impl Throttle {
pub fn unlimited() -> Self {
Self::default()
}
pub fn is_unlimited(&self) -> bool {
self.inner.is_empty()
}
pub(crate) fn from_engine(inner: crate::throttle::Throttle) -> Self {
Self { inner }
}
fn into_engine(self) -> crate::throttle::Throttle {
self.inner
}
}
#[async_trait]
pub trait StreamInterceptor: Send {
async fn run(self: Box<Self>, args: StreamArgs) -> io::Result<FlowStats>;
}
#[non_exhaustive]
pub struct StreamArgs {
pub client: PeekableClientStream,
pub target: TcpStream,
pub dst: SocketAddr,
pub io_timeout: Duration,
pub throttle: Option<Throttle>,
}
impl StreamArgs {
pub fn new(
client: PeekableClientStream,
target: TcpStream,
dst: SocketAddr,
io_timeout: Duration,
throttle: Option<Throttle>,
) -> Self {
StreamArgs {
client,
target,
dst,
io_timeout,
throttle,
}
}
pub(crate) fn from_engine(
client: crate::client_stream::ClientStream,
target: TcpStream,
dst: SocketAddr,
io_timeout: Duration,
throttle: Option<crate::throttle::Throttle>,
) -> Self {
Self::new(
PeekableClientStream::new(ClientStream::from_engine(client)),
target,
dst,
io_timeout,
throttle.map(Throttle::from_engine),
)
}
}
pub type PeekableClientStream = Peekable<ClientStream>;
pub struct Peekable<S> {
inner: S,
buf: Vec<u8>,
pos: usize,
}
impl<S> Peekable<S> {
pub fn new(inner: S) -> Self {
Peekable {
inner,
buf: Vec::new(),
pos: 0,
}
}
}
pub const MAX_PEEK: usize = 16 * 1024;
impl<S: AsyncRead + Unpin> Peekable<S> {
pub async fn peek(&mut self, want: usize) -> io::Result<&[u8]> {
let want = want.min(MAX_PEEK);
if self.pos > 0 {
self.buf.drain(..self.pos);
self.pos = 0;
}
let mut chunk = [0u8; 4096];
while self.buf.len() < want {
let cap = (want - self.buf.len()).min(chunk.len());
let n = self.inner.read(&mut chunk[..cap]).await?;
if n == 0 {
break; }
self.buf.extend_from_slice(&chunk[..n]);
}
let end = want.min(self.buf.len());
Ok(&self.buf[..end])
}
}
impl<S: AsyncRead + Unpin> AsyncRead for Peekable<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
out: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if out.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let this = &mut *self;
if this.pos < this.buf.len() {
let pending = &this.buf[this.pos..];
let n = pending.len().min(out.remaining());
out.put_slice(&pending[..n]);
this.pos += n;
if this.pos == this.buf.len() {
this.buf.clear();
this.pos = 0;
}
return Poll::Ready(Ok(()));
}
Pin::new(&mut this.inner).poll_read(cx, out)
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for Peekable<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
pub async fn relay<C, R>(
client: C,
target: R,
io_timeout: Duration,
throttle: Option<Throttle>,
) -> io::Result<FlowStats>
where
C: AsyncRead + AsyncWrite + Unpin,
R: AsyncRead + AsyncWrite + Unpin,
{
let throttle = throttle.map(Throttle::into_engine);
let (up, down) = crate::relay::relay_generic(client, target, io_timeout, throttle).await?;
Ok(FlowStats::new(up, down))
}
pub async fn splice(args: StreamArgs) -> io::Result<FlowStats> {
let StreamArgs {
client,
target,
io_timeout,
throttle,
..
} = args;
relay(client, target, io_timeout, throttle).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Direction {
ClientToTarget,
TargetToClient,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DatagramVerdict {
#[default]
Forward,
Drop,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct DatagramCtx<'a> {
pub dir: Direction,
pub dst: SocketAddr,
pub payload: &'a [u8],
pub tags: &'a TagSet,
}
impl<'a> DatagramCtx<'a> {
pub fn new(dir: Direction, dst: SocketAddr, payload: &'a [u8], tags: &'a TagSet) -> Self {
DatagramCtx {
dir,
dst,
payload,
tags,
}
}
}
#[non_exhaustive]
pub struct AssociateCtx<'a> {
pub client: SocketAddr,
pub proxy: SocketAddr,
pub command: Command,
pub protocol: Protocol,
pub relay_addr: SocketAddr,
pub requested_host: Option<&'a str>,
pub requested_endpoint: Option<SocketAddr>,
pub tags: TagSet,
}
impl<'a> AssociateCtx<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
client: SocketAddr,
proxy: SocketAddr,
command: Command,
protocol: Protocol,
relay_addr: SocketAddr,
requested_host: Option<&'a str>,
requested_endpoint: Option<SocketAddr>,
tags: TagSet,
) -> Self {
AssociateCtx {
client,
proxy,
command,
protocol,
relay_addr,
requested_host,
requested_endpoint,
tags,
}
}
}
#[async_trait]
pub trait DatagramInterceptor: Send {
async fn run(self: Box<Self>, args: AssociationArgs) -> io::Result<FlowStats>;
}
#[non_exhaustive]
pub struct AssociationArgs {
pub client: ClientDatagrams,
pub upstream: UpstreamOriginator,
pub io_timeout: Duration,
}
impl AssociationArgs {
pub(crate) fn new(
client: ClientDatagrams,
upstream: UpstreamOriginator,
io_timeout: Duration,
) -> Self {
AssociationArgs {
client,
upstream,
io_timeout,
}
}
pub fn for_interceptor(
client: ClientDatagrams,
upstream: UpstreamOriginator,
io_timeout: Duration,
) -> Self {
AssociationArgs::new(client, upstream, io_timeout)
}
}
const ASSOCIATION_BUF: usize = 65_535;
pub async fn splice_association(args: AssociationArgs) -> io::Result<FlowStats> {
let AssociationArgs {
client, upstream, ..
} = args;
let to_target = AtomicU64::new(0);
let to_client = AtomicU64::new(0);
let c2o = async {
let mut buf = vec![0u8; ASSOCIATION_BUF];
loop {
let (n, origin) = client.recv(&mut buf).await?;
if let Some(target) = upstream.authorize(None, origin) {
upstream.send_to(&target, &buf[..n]).await?;
to_target.fetch_add(n as u64, Ordering::Relaxed);
}
}
#[allow(unreachable_code)]
Ok::<(), io::Error>(())
};
let o2c = async {
let mut buf = vec![0u8; ASSOCIATION_BUF];
loop {
let (n, origin) = upstream.recv(&mut buf).await?;
client.send(origin, &buf[..n]).await?;
to_client.fetch_add(n as u64, Ordering::Relaxed);
}
#[allow(unreachable_code)]
Ok::<(), io::Error>(())
};
tokio::pin!(c2o, o2c);
let outcome = tokio::select! {
r = &mut c2o => r,
r = &mut o2c => r,
};
let stats = FlowStats::new(
to_target.load(Ordering::Relaxed),
to_client.load(Ordering::Relaxed),
);
outcome.map(|()| stats)
}
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
async fn on_flow(&self, _ctx: &mut FlowCtx<'_>) -> FlowDecision {
FlowDecision::Continue
}
fn intercept(&self, _ctx: &FlowCtx<'_>) -> Option<Box<dyn StreamInterceptor>> {
None
}
fn intercept_association(
&self,
_ctx: &AssociateCtx<'_>,
) -> Option<Box<dyn DatagramInterceptor>> {
None
}
fn on_datagram(&self, _ctx: &DatagramCtx<'_>) -> DatagramVerdict {
DatagramVerdict::Forward
}
async fn on_flow_end(&self, _ctx: &FlowCtx<'_>, _stats: &FlowStats) {}
}
#[derive(Clone, Default)]
pub struct PluginHost {
plugins: Vec<Arc<dyn Plugin>>,
}
impl PluginHost {
pub fn new(plugins: Vec<Arc<dyn Plugin>>) -> Self {
PluginHost { plugins }
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
pub fn len(&self) -> usize {
self.plugins.len()
}
pub async fn on_flow(&self, ctx: &mut FlowCtx<'_>) -> FlowDecision {
for plugin in &self.plugins {
if let FlowDecision::Deny(reason) = plugin.on_flow(ctx).await {
return FlowDecision::Deny(reason);
}
}
FlowDecision::Continue
}
pub fn intercept(&self, ctx: &FlowCtx<'_>) -> Option<Box<dyn StreamInterceptor>> {
self.plugins.iter().find_map(|plugin| plugin.intercept(ctx))
}
pub fn intercept_association(
&self,
ctx: &AssociateCtx<'_>,
) -> Option<Box<dyn DatagramInterceptor>> {
self.plugins
.iter()
.find_map(|plugin| plugin.intercept_association(ctx))
}
pub fn on_datagram(&self, ctx: &DatagramCtx<'_>) -> DatagramVerdict {
for plugin in &self.plugins {
if plugin.on_datagram(ctx) == DatagramVerdict::Drop {
return DatagramVerdict::Drop;
}
}
DatagramVerdict::Forward
}
pub async fn on_flow_end(&self, ctx: &FlowCtx<'_>, stats: &FlowStats) {
for plugin in &self.plugins {
plugin.on_flow_end(ctx, stats).await;
}
}
}
impl std::fmt::Debug for PluginHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginHost")
.field(
"plugins",
&self.plugins.iter().map(|p| p.name()).collect::<Vec<_>>(),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
async fn loopback_stream_args() -> StreamArgs {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let client = TcpStream::connect(addr).await.unwrap();
let (target, _) = listener.accept().await.unwrap();
StreamArgs::new(
PeekableClientStream::new(ClientStream::from_tcp(client)),
target,
addr,
Duration::from_secs(30),
None,
)
}
fn decision() -> RuleDecision {
RuleDecision {
verdict: Verdict::Pass,
source_line: Some(7),
rule_name: Some(Arc::from("test-rule")),
bandwidth: None,
}
}
fn flow_ctx(tags: TagSet) -> FlowCtx<'static> {
FlowCtx {
client: "10.0.0.1:5000".parse().unwrap(),
proxy: "10.0.0.2:1080".parse().unwrap(),
command: Command::Connect,
protocol: Protocol::Tcp,
dest_host: Some("example.com"),
dest: "93.184.216.34:443".parse().unwrap(),
rule: RuleInfo::from_decision(&decision()),
tags,
}
}
fn datagram_ctx<'a>(tags: &'a TagSet, payload: &'a [u8]) -> DatagramCtx<'a> {
DatagramCtx {
dir: Direction::ClientToTarget,
dst: "1.1.1.1:443".parse().unwrap(),
payload,
tags,
}
}
struct Tagger(&'static str);
#[async_trait]
impl Plugin for Tagger {
fn name(&self) -> &str {
"tagger"
}
async fn on_flow(&self, ctx: &mut FlowCtx<'_>) -> FlowDecision {
ctx.tags.insert(self.0);
FlowDecision::Continue
}
}
struct Denier(&'static str);
#[async_trait]
impl Plugin for Denier {
fn name(&self) -> &str {
"denier"
}
async fn on_flow(&self, _ctx: &mut FlowCtx<'_>) -> FlowDecision {
FlowDecision::Deny(self.0)
}
}
struct IdInterceptor(u64);
#[async_trait]
impl StreamInterceptor for IdInterceptor {
async fn run(self: Box<Self>, _args: StreamArgs) -> io::Result<FlowStats> {
Ok(FlowStats::new(self.0, 0))
}
}
struct Owner(u64);
#[async_trait]
impl Plugin for Owner {
fn name(&self) -> &str {
"owner"
}
fn intercept(&self, _ctx: &FlowCtx<'_>) -> Option<Box<dyn StreamInterceptor>> {
Some(Box::new(IdInterceptor(self.0)))
}
}
struct Passer;
#[async_trait]
impl Plugin for Passer {
fn name(&self) -> &str {
"passer"
}
}
struct DatagramPlugin(DatagramVerdict);
#[async_trait]
impl Plugin for DatagramPlugin {
fn name(&self) -> &str {
"datagram"
}
fn on_datagram(&self, _ctx: &DatagramCtx<'_>) -> DatagramVerdict {
self.0
}
}
struct EndCounter(Arc<AtomicUsize>);
#[async_trait]
impl Plugin for EndCounter {
fn name(&self) -> &str {
"end-counter"
}
async fn on_flow_end(&self, _ctx: &FlowCtx<'_>, _stats: &FlowStats) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
struct NoopDatagramInterceptor;
#[async_trait]
impl DatagramInterceptor for NoopDatagramInterceptor {
async fn run(self: Box<Self>, _args: AssociationArgs) -> io::Result<FlowStats> {
Ok(FlowStats::default())
}
}
struct AssocOwner(Arc<AtomicUsize>);
#[async_trait]
impl Plugin for AssocOwner {
fn name(&self) -> &str {
"assoc-owner"
}
fn intercept_association(
&self,
_ctx: &AssociateCtx<'_>,
) -> Option<Box<dyn DatagramInterceptor>> {
self.0.fetch_add(1, Ordering::SeqCst);
Some(Box::new(NoopDatagramInterceptor))
}
}
fn associate_ctx() -> AssociateCtx<'static> {
AssociateCtx::new(
"127.0.0.1:5000".parse().unwrap(),
"127.0.0.1:1080".parse().unwrap(),
Command::UdpAssociate,
Protocol::Udp,
"127.0.0.1:40000".parse().unwrap(),
None,
None,
TagSet::new(),
)
}
#[test]
fn rule_info_is_a_facade_over_the_decision() {
let info = RuleInfo::from_decision(&decision());
assert_eq!(info.verdict(), Verdict::Pass);
assert_eq!(info.source_line(), Some(7));
assert_eq!(info.rule_name(), Some("test-rule"));
}
#[test]
fn flow_stats_total_saturates() {
assert_eq!(FlowStats::new(3, 4).total(), 7);
assert_eq!(FlowStats::new(u64::MAX, 1).total(), u64::MAX);
}
#[tokio::test]
async fn peek_buffers_without_consuming() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut writer, reader) = tokio::io::duplex(64);
writer.write_all(b"hello world").await.unwrap();
let mut peekable = Peekable::new(reader);
assert_eq!(peekable.peek(5).await.unwrap(), b"hello");
assert_eq!(peekable.peek(5).await.unwrap(), b"hello");
let mut out = vec![0u8; 11];
peekable.read_exact(&mut out).await.unwrap();
assert_eq!(&out, b"hello world");
}
#[tokio::test]
async fn peek_is_capped_at_max_peek() {
use tokio::io::AsyncWriteExt;
let (mut writer, reader) = tokio::io::duplex(MAX_PEEK * 2);
writer.write_all(&vec![0u8; MAX_PEEK + 100]).await.unwrap();
let mut peekable = Peekable::new(reader);
assert_eq!(peekable.peek(usize::MAX).await.unwrap().len(), MAX_PEEK);
}
#[tokio::test]
async fn empty_host_is_the_zero_cost_default() {
let host = PluginHost::default();
assert!(host.is_empty());
assert_eq!(host.len(), 0);
let mut ctx = flow_ctx(TagSet::new());
assert_eq!(host.on_flow(&mut ctx).await, FlowDecision::Continue);
assert!(host.intercept(&ctx).is_none());
let tags = TagSet::new();
let dctx = datagram_ctx(&tags, b"quic");
assert_eq!(host.on_datagram(&dctx), DatagramVerdict::Forward);
host.on_flow_end(&ctx, &FlowStats::default()).await;
}
#[tokio::test]
async fn on_flow_first_deny_wins_and_short_circuits() {
let host = PluginHost::new(vec![
Arc::new(Tagger("before")),
Arc::new(Denier("blocked")),
Arc::new(Tagger("after")),
]);
let mut ctx = flow_ctx(TagSet::new());
assert_eq!(host.on_flow(&mut ctx).await, FlowDecision::Deny("blocked"));
assert!(
ctx.tags.contains("before"),
"a plugin before the denier still tags"
);
assert!(
!ctx.tags.contains("after"),
"the first Deny short-circuits, so later plugins do not run"
);
}
#[tokio::test]
async fn tags_accumulate_across_plugins() {
let host = PluginHost::new(vec![Arc::new(Tagger("a")), Arc::new(Tagger("b"))]);
let mut ctx = flow_ctx(TagSet::new());
assert_eq!(host.on_flow(&mut ctx).await, FlowDecision::Continue);
assert!(ctx.tags.contains("a") && ctx.tags.contains("b"));
assert_eq!(ctx.tags.len(), 2);
}
#[tokio::test]
async fn intercept_first_some_wins() {
let host = PluginHost::new(vec![
Arc::new(Passer),
Arc::new(Owner(1)),
Arc::new(Owner(2)),
]);
let ctx = flow_ctx(TagSet::new());
let interceptor = host
.intercept(&ctx)
.expect("an owner should claim the flow");
let stats = interceptor.run(loopback_stream_args().await).await.unwrap();
assert_eq!(stats.to_target, 1, "the first owner (id 1) wins the race");
let none = PluginHost::new(vec![Arc::new(Passer)]);
assert!(
none.intercept(&ctx).is_none(),
"no owner means the relay is left untouched"
);
}
#[test]
fn intercept_association_first_some_wins() {
let called = Arc::new(AtomicUsize::new(0));
let host = PluginHost::new(vec![
Arc::new(Passer),
Arc::new(AssocOwner(called.clone())),
Arc::new(AssocOwner(called.clone())),
]);
assert!(
host.intercept_association(&associate_ctx()).is_some(),
"an owner should claim the association"
);
assert_eq!(
called.load(Ordering::SeqCst),
1,
"find_map short-circuits at the first owner; later plugins are not consulted"
);
let none = PluginHost::new(vec![Arc::new(Passer)]);
assert!(
none.intercept_association(&associate_ctx()).is_none(),
"no owner leaves the association on the core relay"
);
}
#[test]
fn on_datagram_drop_by_any_plugin_wins() {
let tags = TagSet::new();
let dctx = datagram_ctx(&tags, b"payload");
let forward_only =
PluginHost::new(vec![Arc::new(DatagramPlugin(DatagramVerdict::Forward))]);
assert_eq!(forward_only.on_datagram(&dctx), DatagramVerdict::Forward);
let with_drop = PluginHost::new(vec![
Arc::new(DatagramPlugin(DatagramVerdict::Forward)),
Arc::new(DatagramPlugin(DatagramVerdict::Drop)),
]);
assert_eq!(with_drop.on_datagram(&dctx), DatagramVerdict::Drop);
}
#[tokio::test]
async fn on_flow_end_fans_out_to_all() {
let calls = Arc::new(AtomicUsize::new(0));
let host = PluginHost::new(vec![
Arc::new(EndCounter(calls.clone())),
Arc::new(EndCounter(calls.clone())),
]);
let ctx = flow_ctx(TagSet::new());
host.on_flow_end(&ctx, &FlowStats::new(10, 20)).await;
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
}