Skip to content

Commit 2ca1116

Browse files
authored
feat: Implement IP-based access control (#157)
* feat: Implement IP-based access control Add IpAccessControl for whitelist/blacklist connection filtering: - Support CIDR notation for IP ranges (IPv4 and IPv6) - Whitelist mode: only allow specified IP ranges - Blacklist mode: block specific IP ranges - Blacklist takes priority over whitelist - Dynamic updates: block/unblock IPs at runtime - Thread-safe SharedIpAccessControl for shared access - Integration at connection level before handler creation Configuration: - allowed_ips: CIDR ranges for whitelist mode - blocked_ips: CIDR ranges always denied Features: - 14 comprehensive unit tests for access control - Rejected connections get minimal handler that rejects auth - Logging for blocked/allowed connections - Reloadable configuration support Closes #141 * fix: Use fail-closed behavior for IP access control lock contention * docs: Add IP access control documentation and apply code formatting - Document IpAccessControl feature in ARCHITECTURE.md - Add detailed IP access control section to server-configuration.md - Describe whitelist/blacklist modes and priority rules - Include CIDR notation examples - Document runtime update capability and security behavior - Apply rustfmt formatting to access.rs and mod.rs
1 parent 51dc329 commit 2ca1116

7 files changed

Lines changed: 827 additions & 1 deletion

File tree

ARCHITECTURE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ Security features for the SSH server (`src/server/security/`):
203203
- Automatic cleanup of expired records via background task
204204
- Thread-safe async implementation with `Arc<RwLock<>>`
205205

206+
- **IpAccessControl**: IP-based connection filtering
207+
- Whitelist mode: Only allow connections from specified CIDR ranges
208+
- Blacklist mode: Block connections from specified CIDR ranges
209+
- Blacklist takes priority over whitelist (blocked IPs are always denied)
210+
- Support for both IPv4 and IPv6 addresses and CIDR notation
211+
- Dynamic updates: Add/remove rules at runtime via `SharedIpAccessControl`
212+
- Early rejection at connection level before handler creation
213+
- Thread-safe with fail-closed behavior on lock contention
214+
- Configuration via `allowed_ips` and `blocked_ips` in server config
215+
206216
### Server CLI Binary
207217
**Binary**: `bssh-server`
208218

docs/architecture/server-configuration.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,46 @@ security:
193193
idle_timeout: 3600 # Default: 3600 (1 hour)
194194

195195
# IP allowlist (CIDR notation, empty = allow all)
196+
# When configured, only connections from these ranges are allowed
196197
allowed_ips:
197198
- "192.168.1.0/24"
198199
- "10.0.0.0/8"
199200

200201
# IP blocklist (CIDR notation)
202+
# Connections from these ranges are always denied
203+
# Blocked IPs take priority over allowed IPs
201204
blocked_ips:
202205
- "203.0.113.0/24"
203206
```
204207
208+
### IP Access Control
209+
210+
The server supports IP-based connection filtering through `allowed_ips` and `blocked_ips` configuration options:
211+
212+
**Modes of Operation:**
213+
214+
1. **Default Mode** (no `allowed_ips` configured): All IPs are allowed unless explicitly blocked
215+
2. **Whitelist Mode** (`allowed_ips` configured): Only IPs matching allowed ranges can connect
216+
217+
**Priority Rules:**
218+
- Blocked IPs always take priority over allowed IPs
219+
- If an IP matches both `allowed_ips` and `blocked_ips`, the connection is denied
220+
- Connections from blocked IPs are rejected before authentication
221+
222+
**CIDR Notation Examples:**
223+
- `10.0.0.0/8` - All 10.x.x.x addresses (Class A private network)
224+
- `192.168.1.0/24` - All 192.168.1.x addresses
225+
- `192.168.100.50/32` - Single IP address (192.168.100.50)
226+
- `2001:db8::/32` - IPv6 prefix
227+
228+
**Runtime Updates:**
229+
The IP access control supports dynamic updates at runtime through the `SharedIpAccessControl` API, allowing administrators to block or unblock IPs without restarting the server.
230+
231+
**Security Behavior:**
232+
- Connections from blocked IPs are rejected at the connection level before any authentication attempt
233+
- On lock contention (rare), the system defaults to DENY for fail-closed security
234+
- All access control decisions are logged for auditing
235+
205236
## Environment Variable Overrides
206237

207238
The following environment variables can override configuration file settings:

src/server/config/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ pub struct ServerConfig {
165165
/// IP addresses that are never banned (whitelist).
166166
#[serde(default)]
167167
pub whitelist_ips: Vec<String>,
168+
169+
/// Allowed IP ranges in CIDR notation for connection filtering.
170+
///
171+
/// If non-empty, only connections from these ranges are allowed.
172+
/// Empty list means all IPs are allowed (subject to blocked_ips).
173+
#[serde(default)]
174+
pub allowed_ips: Vec<String>,
175+
176+
/// Blocked IP ranges in CIDR notation for connection filtering.
177+
///
178+
/// Connections from these ranges are always denied.
179+
/// Blocked IPs take priority over allowed IPs.
180+
#[serde(default)]
181+
pub blocked_ips: Vec<String>,
168182
}
169183

170184
/// Serializable configuration for public key authentication.
@@ -260,6 +274,8 @@ impl Default for ServerConfig {
260274
auth_window_secs: default_auth_window_secs(),
261275
ban_time_secs: default_ban_time_secs(),
262276
whitelist_ips: Vec::new(),
277+
allowed_ips: Vec::new(),
278+
blocked_ips: Vec::new(),
263279
}
264280
}
265281
}
@@ -551,6 +567,8 @@ impl ServerFileConfig {
551567
auth_window_secs: self.security.auth_window,
552568
ban_time_secs: self.security.ban_time,
553569
whitelist_ips: self.security.whitelist_ips,
570+
allowed_ips: self.security.allowed_ips,
571+
blocked_ips: self.security.blocked_ips,
554572
}
555573
}
556574
}

src/server/handler.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ pub struct SshHandler {
6666

6767
/// Active channels for this connection.
6868
channels: HashMap<ChannelId, ChannelState>,
69+
70+
/// Whether this connection should be immediately rejected.
71+
/// Set when IP access control denies the connection.
72+
rejected: bool,
6973
}
7074

7175
impl SshHandler {
@@ -90,6 +94,7 @@ impl SshHandler {
9094
auth_rate_limiter: None,
9195
session_info: Some(SessionInfo::new(peer_addr)),
9296
channels: HashMap::new(),
97+
rejected: false,
9398
}
9499
}
95100

@@ -114,6 +119,7 @@ impl SshHandler {
114119
auth_rate_limiter: None,
115120
session_info: Some(SessionInfo::new(peer_addr)),
116121
channels: HashMap::new(),
122+
rejected: false,
117123
}
118124
}
119125

@@ -140,6 +146,7 @@ impl SshHandler {
140146
auth_rate_limiter: Some(auth_rate_limiter),
141147
session_info: Some(SessionInfo::new(peer_addr)),
142148
channels: HashMap::new(),
149+
rejected: false,
143150
}
144151
}
145152

@@ -163,6 +170,32 @@ impl SshHandler {
163170
auth_rate_limiter: None,
164171
session_info: Some(SessionInfo::new(peer_addr)),
165172
channels: HashMap::new(),
173+
rejected: false,
174+
}
175+
}
176+
177+
/// Create a handler for a rejected connection.
178+
///
179+
/// This handler will immediately reject all authentication attempts.
180+
/// Used when IP access control denies a connection.
181+
pub fn rejected(
182+
peer_addr: Option<SocketAddr>,
183+
config: Arc<ServerConfig>,
184+
sessions: Arc<RwLock<SessionManager>>,
185+
) -> Self {
186+
let auth_provider = config.create_auth_provider();
187+
let rate_limiter = RateLimiter::with_simple_config(1, 0.1);
188+
189+
Self {
190+
peer_addr,
191+
config,
192+
sessions,
193+
auth_provider,
194+
rate_limiter,
195+
auth_rate_limiter: None,
196+
session_info: None, // No session for rejected connections
197+
channels: HashMap::new(),
198+
rejected: true,
166199
}
167200
}
168201

@@ -246,6 +279,19 @@ impl russh::server::Handler for SshHandler {
246279
"Auth none attempt"
247280
);
248281

282+
// If connection was rejected by IP access control, immediately reject
283+
if self.rejected {
284+
tracing::debug!(
285+
peer = ?self.peer_addr,
286+
"Rejecting auth for IP-blocked connection"
287+
);
288+
return std::future::ready(Ok(Auth::Reject {
289+
proceed_with_methods: None,
290+
partial_success: false,
291+
}))
292+
.left_future();
293+
}
294+
249295
// Create session info if not already created
250296
let peer_addr = self.peer_addr;
251297
let sessions = Arc::clone(&self.sessions);
@@ -287,6 +333,7 @@ impl russh::server::Handler for SshHandler {
287333
partial_success: false,
288334
})
289335
}
336+
.right_future()
290337
}
291338

292339
/// Handle public key authentication.

src/server/mod.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ pub use self::config::{ServerConfig, ServerConfigBuilder};
7070
pub use self::exec::{CommandExecutor, ExecConfig};
7171
pub use self::handler::SshHandler;
7272
pub use self::pty::{PtyConfig as PtyMasterConfig, PtyMaster};
73-
pub use self::security::{AuthRateLimitConfig, AuthRateLimiter};
73+
pub use self::security::{
74+
AccessPolicy, AuthRateLimitConfig, AuthRateLimiter, IpAccessControl, SharedIpAccessControl,
75+
};
7476
pub use self::session::{
7577
ChannelMode, ChannelState, PtyConfig, SessionId, SessionInfo, SessionManager,
7678
};
@@ -247,6 +249,13 @@ impl BsshServer {
247249
"Auth rate limiter configured"
248250
);
249251

252+
// Create IP access control from configuration
253+
let ip_access_control =
254+
IpAccessControl::from_config(&self.config.allowed_ips, &self.config.blocked_ips)
255+
.context("Failed to configure IP access control")?;
256+
257+
let shared_ip_access = SharedIpAccessControl::new(ip_access_control);
258+
250259
// Start background cleanup task for auth rate limiter
251260
let cleanup_limiter = auth_rate_limiter.clone();
252261
tokio::spawn(async move {
@@ -262,6 +271,7 @@ impl BsshServer {
262271
sessions: Arc::clone(&self.sessions),
263272
rate_limiter,
264273
auth_rate_limiter,
274+
ip_access_control: shared_ip_access,
265275
};
266276

267277
// Use run_on_socket which handles the server loop
@@ -294,12 +304,53 @@ struct BsshServerRunner {
294304
rate_limiter: RateLimiter<String>,
295305
/// Auth rate limiter with ban support (fail2ban-like)
296306
auth_rate_limiter: AuthRateLimiter,
307+
/// IP-based access control
308+
ip_access_control: SharedIpAccessControl,
297309
}
298310

299311
impl russh::server::Server for BsshServerRunner {
300312
type Handler = SshHandler;
301313

302314
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
315+
// Check IP access control before creating handler
316+
if let Some(addr) = peer_addr {
317+
let ip = addr.ip();
318+
319+
// Check IP access control (synchronous to avoid blocking)
320+
if self.ip_access_control.check_sync(&ip) == AccessPolicy::Deny {
321+
tracing::info!(
322+
ip = %ip,
323+
"Connection rejected by IP access control"
324+
);
325+
// Return a handler that will immediately reject
326+
// We can't return None here due to trait constraints,
327+
// so we'll mark it for rejection in the handler
328+
return SshHandler::rejected(
329+
peer_addr,
330+
Arc::clone(&self.config),
331+
Arc::clone(&self.sessions),
332+
);
333+
}
334+
335+
// Check if banned by auth rate limiter
336+
// Use try_read to avoid blocking in sync context
337+
if let Ok(is_banned) = tokio::runtime::Handle::try_current()
338+
.map(|h| h.block_on(self.auth_rate_limiter.is_banned(&ip)))
339+
{
340+
if is_banned {
341+
tracing::info!(
342+
ip = %ip,
343+
"Connection rejected from banned IP"
344+
);
345+
return SshHandler::rejected(
346+
peer_addr,
347+
Arc::clone(&self.config),
348+
Arc::clone(&self.sessions),
349+
);
350+
}
351+
}
352+
}
353+
303354
tracing::info!(
304355
peer = ?peer_addr,
305356
"New client connection"

0 commit comments

Comments
 (0)