Added kick duration for web_ui kick

This commit is contained in:
euzu
2025-12-12 20:32:46 +01:00
parent a789f1386d
commit e6db1a3c66
21 changed files with 98 additions and 38 deletions
+2
View File
@@ -35,6 +35,8 @@
- Added extended debug logging for client requests and ID chain (request/action/virtual) to trace stream resolution.
- Fixed xtream series/catchup lookups using the series-info virtual_id so episode requests now keep their own virtual_id/session.
- Made cache storage more robust. Incomplete downloads will be deleted from cache.
- `kick_secs` added to config.yaml `web_ui` config. Default 30 seconds, if a user is kicked from the webui, he can't connect for this duration.
# 3.2.0 (2025-11-14)
- Added `name` attribute to Staged Input.
Generated
+3 -3
View File
@@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "frontend"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"anyhow",
"base64",
@@ -3793,7 +3793,7 @@ dependencies = [
[[package]]
name = "shared"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"base64",
"bitflags 2.10.0",
@@ -4356,7 +4356,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tuliprox"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"arc-swap",
"async-compression",
+7 -5
View File
@@ -412,11 +412,13 @@ log:
```
### 1.10 `web_ui`
- enabled: default is true, if set to false the web_ui is disabled
- user_ui_enabled, true or false, for user bouquet editor
- content_security_policy: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`.
- path is for web_ui path like `/ui` for reverse proxy integration if necessary.
- auth for authentication settings
- `enabled`: default is true, if set to false the web_ui is disabled
- `user_ui_enabled`, true or false, for user group editor
- `content_security_policy`: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`.
- `path` is for web_ui path like `/ui` for reverse proxy integration if necessary.
- `player_server` optional, if set the server setting is used for the web-ui-player.
- `kick_secs` default 30 seconds, if a user is kicked from the webui, he can't connect for this duration.
- `auth` for authentication settings
- `enabled` can be deactivated if `enabled` is set to `false`. If not set default is `true`.
- `issuer`
- `secret` is used for jwt token generation.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuliprox"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+4
View File
@@ -61,6 +61,10 @@ pub(in crate::api) async fn handle_hls_stream_request(
req_headers: &HeaderMap,
connection_permission: UserConnectionPermission,
) -> impl IntoResponse + Send {
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let url = replace_url_extension(hls_url, HLS_EXT);
let server_info = app_state.app_config.get_user_server_info(user);
+5
View File
@@ -118,6 +118,11 @@ async fn m3u_api_stream(
format!("Failed to read m3u item for stream id {req_virtual_id}")
);
let virtual_id = pli.virtual_id;
if app_state.active_users.is_user_blocked_for_stream(stream_req.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let input = try_option_bad_request!(
app_state
.app_config
+3 -3
View File
@@ -136,7 +136,7 @@ async fn handle_protocol_message(
Ok(ProtocolMessage::UserAction(cmd)) => {
if let Some(token) = mem.token.as_ref() {
if !auth_required || verify_auth_admin_token(token, secret_key) {
Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd)))
Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd).await))
} else {
Some(ProtocolMessage::UserActionResponse(false))
}
@@ -314,8 +314,8 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc<AppState>, auth_req
}
}
fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool {
async fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool {
match cmd {
UserCommand::Kick(addr) => app_state.connection_manager.kick_connection(&addr),
UserCommand::Kick(addr, virtual_id, secs) => app_state.connection_manager.kick_connection(&addr, virtual_id, secs).await,
}
}
+7 -3
View File
@@ -229,7 +229,6 @@ async fn xtream_player_api_stream(
// debug!("{}", sanitize_sensitive_info(&message));
// }
let (user, target) = try_option_bad_request!(
get_user_target_by_credentials( stream_req.username, stream_req.password, api_req, app_state),
false,
@@ -256,6 +255,10 @@ async fn xtream_player_api_stream(
format!("Failed to read xtream item for stream id {req_virtual_id}")
);
let virtual_id = pli.virtual_id;
if app_state.active_users.is_user_blocked_for_stream(stream_req.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let input = try_option_bad_request!(
app_state.app_config.get_input_by_name(pli.input_name.as_str()),
true,
@@ -267,9 +270,10 @@ async fn xtream_player_api_stream(
} else {
(pli.xtream_cluster, pli.item_type)
};
debug_if_enabled!(
"ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}");
"ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}",
stream_req.stream_id);
let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id);
let user_session = app_state
.active_users
+18 -2
View File
@@ -6,7 +6,7 @@ use crate::utils::GeoIp;
use arc_swap::ArcSwapOption;
use jsonwebtoken::get_current_timestamp;
use log::{debug, info};
use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission};
use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission, VirtualId};
use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info, strip_port};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
@@ -75,6 +75,7 @@ impl UserConnectionData {
#[derive(Debug, Default)]
struct UserConnections {
kicked: HashMap<String, (u64, VirtualId)>,
by_key: HashMap<String, UserConnectionData>,
key_by_addr: HashMap<SocketAddr, String>,
}
@@ -124,7 +125,6 @@ impl ActiveUserManager {
self.last_logged_user_count.store(last_user_count, Ordering::Relaxed);
self.last_logged_user_connection_count.store(last_connection_count, Ordering::Relaxed);
info!("Active Users: {user_count}, Active User Connections: {user_connection_count}");
}
}
}
@@ -496,6 +496,22 @@ impl ActiveUserManager {
}
}
pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool {
let connections = self.connections.read().await;
let now = current_time_secs();
matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now)
}
pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) {
let mut connections = self.connections.write().await;
let now = current_time_secs();
connections.kicked.retain(|_, (expires_at, _)| *expires_at > now);
if let Some(username) = connections.key_by_addr.get(addr).cloned() {
let expires_at = now + blocked_secs;
connections.kicked.insert(username, (expires_at, virtual_id));
}
}
fn gc(&self) {
if let Some(gc_ts) = &self.gc_ts {
let ts = gc_ts.load(Ordering::Acquire);
+6 -3
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, ProviderHandle, SharedStreamManager};
use std::sync::Arc;
use log::{debug, warn};
use shared::model::{ActiveUserConnectionChange, StreamChannel};
use shared::model::{ActiveUserConnectionChange, StreamChannel, VirtualId};
use crate::auth::Fingerprint;
pub struct ConnectionManager {
@@ -36,7 +36,10 @@ impl ConnectionManager {
self.close_socket_signal_tx.subscribe()
}
pub fn kick_connection(&self, addr: &SocketAddr) -> bool {
pub async fn kick_connection(&self, addr: &SocketAddr, virtual_id: VirtualId, block_secs: u64) -> bool {
if block_secs > 0 {
self.user_manager.block_user_for_stream(addr, virtual_id, block_secs).await;
}
if let Err(e) = self.close_socket_signal_tx.send(*addr) {
debug!("No active receivers for close signal ({addr}): {e:?}");
return false;
@@ -74,7 +77,7 @@ impl ConnectionManager {
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
} else {
warn!("Failed to register connection for user {username} at {}; disconnecting client", fingerprint.addr);
let _ = self.kick_connection(&fingerprint.addr);
let _ = self.kick_connection(&fingerprint.addr,0, 0).await;
}
}
+7
View File
@@ -1,5 +1,6 @@
use shared::error::TuliproxError;
use shared::model::{ContentSecurityPolicyConfigDto, WebUiConfigDto};
use shared::utils::default_kick_secs;
use crate::model::{macros, WebAuthConfig};
#[derive(Debug, Clone)]
@@ -16,6 +17,7 @@ pub struct WebUiConfig {
pub path: Option<String>,
pub auth: Option<WebAuthConfig>,
pub player_server: Option<String>,
pub kick_secs: u64,
}
impl WebUiConfig {
@@ -27,6 +29,9 @@ impl WebUiConfig {
self.auth = None;
}
}
if self.kick_secs == 0 {
self.kick_secs = default_kick_secs();
}
Ok(())
}
}
@@ -61,6 +66,7 @@ impl From<&WebUiConfigDto> for WebUiConfig {
path: dto.path.clone(),
auth: dto.auth.as_ref().map(Into::into),
player_server: dto.player_server.clone(),
kick_secs: dto.kick_secs,
}
}
}
@@ -73,6 +79,7 @@ impl From<&WebUiConfig> for WebUiConfigDto {
path: instance.path.clone(),
auth: instance.auth.as_ref().map(Into::into),
player_server: instance.player_server.clone(),
kick_secs: instance.kick_secs,
}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "frontend"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
[dependencies]
shared = { version = "3.2.18", path = "../shared" }
shared = { version = "3.2.19", path = "../shared" }
chrono = "0"
yew = "0.21"
yew-router = "0.18"
+1
View File
@@ -127,6 +127,7 @@
"SECRET": "Secret",
"USERFILE": "Userfile",
"PLAYER_SERVER": "Player Server",
"KICK_DURATION": "Kick duration (secs)",
"VERSION": "Version",
"SERVER_TIME": "Server time",
"BUILD_TIME": "Build time",
@@ -2,7 +2,7 @@ use crate::app::components::{AppIcon, Card, Chip};
use crate::app::context::ConfigContext;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::{ConfigForm, LABEL_WEB_UI_CONFIG};
use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer, html_if};
use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer, html_if};
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{WebUiConfigDto, ContentSecurityPolicyConfigDto, WebAuthConfigDto};
@@ -15,6 +15,7 @@ const LABEL_SECRET: &str = "LABEL.SECRET";
const LABEL_TOKEN_TTL_MINS: &str = "LABEL.TOKEN_TTL_MINS";
const LABEL_USERFILE: &str = "LABEL.USERFILE";
const LABEL_PLAYER_SERVER: &str = "LABEL.PLAYER_SERVER";
const LABEL_KICK_DURATION: &str = "LABEL.KICK_DURATION";
const LABEL_USER_UI_ENABLED: &str = "LABEL.USER_UI_ENABLED";
const LABEL_CONTENT_SECURITY_POLICY: &str = "LABEL.CONTENT_SECURITY_POLICY";
const LABEL_CONTENT_SECURITY_POLICY_CUSTOM_ATTRIBUTES: &str = "LABEL.CUSTOM_ATTRIBUTES";
@@ -29,6 +30,7 @@ generate_form_reducer!(
UserUiEnabled => user_ui_enabled: bool,
Path => path: Option<String>,
PlayerServer => player_server: Option<String>,
KickSecs => kick_secs: u64,
}
);
@@ -126,6 +128,7 @@ pub fn WebUiConfigView() -> Html {
{ config_field_bool!(webui_state.form, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled) }
{ config_field_optional!(webui_state.form, translate.t(LABEL_PATH), path) }
{ config_field_optional!(webui_state.form, translate.t(LABEL_PLAYER_SERVER), player_server) }
{ config_field!(webui_state.form, translate.t(LABEL_KICK_DURATION), kick_secs) }
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_CONTENT_SECURITY_POLICY)}</h1>
{ config_field_bool!(csp_state.form, translate.t(LABEL_ENABLED), enabled) }
@@ -163,6 +166,7 @@ pub fn WebUiConfigView() -> Html {
{ edit_field_bool!(webui_state, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled, WebUiConfigFormAction::UserUiEnabled) }
{ edit_field_text_option!(webui_state, translate.t(LABEL_PATH), path, WebUiConfigFormAction::Path) }
{ edit_field_text_option!(webui_state, translate.t(LABEL_PLAYER_SERVER), player_server, WebUiConfigFormAction::PlayerServer) }
{ edit_field_number_u64!(webui_state, translate.t(LABEL_KICK_DURATION), kick_secs, WebUiConfigFormAction::KickSecs) }
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_CONTENT_SECURITY_POLICY)}</h1>
{ edit_field_bool!(csp_state, translate.t(LABEL_ENABLED), enabled, CspConfigFormAction::Enabled) }
@@ -8,7 +8,7 @@ use gloo_timers::callback::Interval;
use gloo_utils::window;
use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
use shared::model::{PlaylistItemType, ProtocolMessage, SortOrder, StreamChannel, StreamInfo, UserCommand};
use shared::utils::{current_time_secs, strip_port};
use shared::utils::{current_time_secs, default_kick_secs, strip_port};
use std::fmt::Display;
use std::rc::Rc;
use std::str::FromStr;
@@ -84,7 +84,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
let popup_is_open = use_state(|| false);
let selected_dto = use_state(|| None::<Rc<StreamInfo>>);
let headers = use_memo(config_ctx, |cfg| {
let headers = use_memo(config_ctx.clone(), |cfg| {
let include_country = if let Some(app_cfg) = &cfg.config {
app_cfg.config.is_geoip_enabled()
} else {
@@ -241,12 +241,15 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
let services = service_ctx.clone();
let selected_dto = selected_dto.clone();
let copy_to_clipboard = copy_to_clipboard.clone();
let kick_secs = config_ctx.config.as_ref().and_then(|app_cfg| app_cfg.config.web_ui.as_ref())
.map(|web_ui| web_ui.kick_secs)
.unwrap_or_else(default_kick_secs);
Callback::from(move |(name, _): (String, _)| {
if let Ok(action) = StreamsTableAction::from_str(&name) {
match action {
StreamsTableAction::Kick => {
if let Some(dto) = (*selected_dto).as_ref() {
if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr))) {
if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr, dto.channel.virtual_id, kick_secs))) {
services.toastr.error(translate.t("MESSAGES.FAILED_TO_KICK_USER_STREAM"));
}
}
@@ -172,7 +172,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
{ config_field_child!(translate.t(LABEL_FILTER), {
html! {
<FilterInput filter={target_form_state_2.form.filter.clone()} on_change={Callback::from(move |new_filter: Option<String>| {
target_form_state_2.dispatch(ConfigTargetFormAction::Filter(new_filter.unwrap_or_else(String::new)));
target_form_state_2.dispatch(ConfigTargetFormAction::Filter(new_filter.unwrap_or_default()));
})} />
}
})}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
[dependencies]
+6 -1
View File
@@ -1,6 +1,6 @@
use crate::error::{TuliproxError, TuliproxErrorKind};
use crate::model::WebAuthConfigDto;
use crate::utils::{default_as_true, is_blank_optional_string};
use crate::utils::{default_as_true, is_blank_optional_string, default_kick_secs};
const RESERVED_PATHS: &[&str] = &[
"cvs",
@@ -82,6 +82,8 @@ pub struct WebUiConfigDto {
pub auth: Option<WebAuthConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_server: Option<String>,
#[serde(default = "default_kick_secs")]
pub kick_secs: u64,
}
impl Default for WebUiConfigDto {
@@ -93,6 +95,7 @@ impl Default for WebUiConfigDto {
path: None,
auth: None,
player_server: None,
kick_secs: default_kick_secs(),
}
}
}
@@ -104,6 +107,7 @@ impl WebUiConfigDto {
&& self.user_ui_enabled == empty.user_ui_enabled
&& is_blank_optional_string(&self.path)
&& is_blank_optional_string(&self.player_server)
&& self.kick_secs == default_kick_secs()
&& (self.content_security_policy.is_none()
|| self
.content_security_policy
@@ -130,6 +134,7 @@ impl WebUiConfigDto {
if is_blank_optional_string(&self.player_server) {
self.player_server = None;
}
self.kick_secs = default_kick_secs();
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
+8 -7
View File
@@ -10,6 +10,7 @@ use std::str::FromStr;
// https://siptv.eu/howto/playlist.html
pub type UUIDType = [u8; 32];
pub type VirtualId = u32;
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)]
#[repr(u8)]
@@ -138,7 +139,7 @@ pub trait FieldSetAccessor {
}
pub trait PlaylistEntry: Send + Sync {
fn get_virtual_id(&self) -> u32;
fn get_virtual_id(&self) -> VirtualId;
fn get_provider_id(&self) -> Option<u32>;
fn get_category_id(&self) -> Option<u32>;
fn get_provider_url(&self) -> String;
@@ -151,7 +152,7 @@ pub struct PlaylistItemHeader {
#[serde(skip)]
pub uuid: UUIDType, // calculated
pub id: String, // provider id
pub virtual_id: u32, // virtual id
pub virtual_id: VirtualId, // virtual id
pub name: String,
pub chno: String,
pub logo: String,
@@ -290,7 +291,7 @@ generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name,
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct M3uPlaylistItem {
pub virtual_id: u32,
pub virtual_id: VirtualId,
pub provider_id: String,
pub name: String,
pub chno: String,
@@ -367,7 +368,7 @@ impl M3uPlaylistItem {
impl PlaylistEntry for M3uPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
@@ -421,7 +422,7 @@ impl From<M3uPlaylistItem> for CommonPlaylistItem {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XtreamPlaylistItem {
pub virtual_id: u32,
pub virtual_id: VirtualId,
pub provider_id: u32,
pub name: String,
pub logo: String,
@@ -477,7 +478,7 @@ impl XtreamPlaylistItem {
impl PlaylistEntry for XtreamPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
#[inline]
@@ -718,7 +719,7 @@ impl PlaylistItem {
impl PlaylistEntry for PlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.header.virtual_id
}
+2 -1
View File
@@ -1,7 +1,8 @@
use std::net::SocketAddr;
use serde::{Deserialize, Serialize};
use crate::model::VirtualId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UserCommand {
Kick(SocketAddr)
Kick(SocketAddr, VirtualId, u64),
}
+3 -1
View File
@@ -21,4 +21,6 @@ pub fn default_secret() -> String {
}
out.iter().map(|b| format!("{:02X}", b)).collect()
}
}
pub const fn default_kick_secs() -> u64 { 30 }