feat(qbzd): LAN-only access restriction, no token auth

Replaces token-based authentication with LAN-only IP restriction.
Requests from non-private IPs (not 10.x, 172.16.x, 192.168.x,
127.x, link-local, ULA) are rejected with 403.

Security model: LAN = trusted, WAN = blocked.
Remote control outside LAN is exclusively via QConnect (cloud).

cc
This commit is contained in:
vicrodh committed 2026-04-09 05:00:40 -06:00
1 parent aacdeabe07
commit 892560dff4
3 files changed
+57 -4

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
//! LAN-only access restriction.
//!
//! Rejects HTTP requests from non-private IP addresses.
//! If you're on the same network, you're in. If you're not, you're out.
//! Remote control from outside the LAN is exclusively via QConnect (cloud).
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
use std::net::IpAddr;
/// Middleware: reject requests from non-private IPs.
/// ConnectInfo is injected by axum::serve with into_make_service_with_connect_info.
pub async fn lan_only(
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Extract client IP from ConnectInfo extension (set by axum::serve)
if let Some(addr) = req.extensions().get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() {
if !is_private_ip(&addr.0.ip()) {
log::warn!(
"[qbzd] Rejected non-LAN request from {} to {}",
addr.0.ip(),
req.uri().path()
);
return Err(StatusCode::FORBIDDEN);
}
}
Ok(next.run(req).await)
}
fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local
|| (v6.segments()[0] & 0xff00) == 0xfd00 // unique local (LAN)
}
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod events;
pub mod favorites;
pub mod integrations;
pub mod library;
pub mod middleware;
pub mod playback;
pub mod playlists;
pub mod queue;
+6 -4
View File
@@ -26,9 +26,7 @@ pub struct DaemonCore {
/// Run the daemon main loop.
pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
// Resolve token
config.resolve_token();
log::info!("[qbzd] API token: {}...", &config.server.token[..8.min(config.server.token.len())]);
log::info!("[qbzd] Access: LAN-only (no authentication required on local network)");
// Create event bus (bounded, slow SSE clients get dropped)
let (event_tx, _) = broadcast::channel::<DaemonEvent>(256);
@@ -153,6 +151,7 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
ctrl_c.await.ok();
};
let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
@@ -338,7 +337,8 @@ macro_rules! with_daemon {
/// Build the Axum HTTP router.
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
use axum::routing::{get, post, patch, put, delete};
use crate::api::{audio, catalog, catalog_ext, discover, favorites, integrations, library, playback, playlists, queue, search, system};
use axum::middleware as axum_mw;
use crate::api::{audio, catalog, catalog_ext, discover, favorites, integrations, library, middleware, playback, playlists, queue, search, system};
axum::Router::new()
// System
@@ -422,6 +422,8 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
// System / Resources
.route("/api/system/resources", get(with_daemon!(daemon, system::get_resources)))
.route("/api/cache", delete(with_daemon!(daemon, system::clear_cache)))
// LAN-only: reject requests from non-private IPs
.layer(axum_mw::from_fn(middleware::lan_only))
}
async fn ping_handler() -> &'static str {