resource policy trusted destinations (#875)

* **New Features**
  * Added a Resource Policy editor for each input, supporting trusted hostnames and private network ranges.
  * Resource URLs now retain their source input and use authenticated links where supported.
  * Resource caching is scoped to the policy authorizing access.

* **Bug Fixes**
  * Invalid or unsupported resource values are safely discarded.
  * Redirects and destination addresses are rechecked against the applicable policy.

* **Breaking Changes**
  * Private DNS destinations require approved hosts and networks; private IP literals require an approved network.
  * Input and alias names must be non-empty and globally unique.
This commit is contained in:
euzu
2026-09-22 15:02:22 +02:00
committed by GitHub
parent fbc1e669fe
commit 2adc18adbc
55 changed files with 3575 additions and 309 deletions
+10 -2
View File
@@ -9,6 +9,7 @@ use shared::{
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
};
use tuliprox_core::{
model::{ConfigInput, EpgSource, EpgSourceType, PersistedEpgSource, PersistedEpgSourceKind},
@@ -200,7 +201,7 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
match download_epg_file(epg_source, ctx, input, headers, storage_dir).await {
Ok(file_path) => {
stored_file_paths.push(file_path.clone());
match persisted_source_from_config(epg_source, file_path) {
match persisted_source_from_config(epg_source, file_path, input) {
Ok(persisted) => file_paths.push(persisted),
Err(err) => errors.push(err),
}
@@ -229,6 +230,7 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
fn persisted_source_from_config(
epg_source: &EpgSource,
file_path: PathBuf,
input: &ConfigInput,
) -> Result<PersistedEpgSource, TuliproxError> {
let kind = match epg_source.source_type {
EpgSourceType::Xmltv => PersistedEpgSourceKind::Xmltv,
@@ -246,7 +248,13 @@ fn persisted_source_from_config(
}
};
Ok(PersistedEpgSource { file_path, priority: epg_source.priority, logo_override: epg_source.logo_override, kind })
Ok(PersistedEpgSource {
file_path,
priority: epg_source.priority,
logo_override: epg_source.logo_override,
kind,
input_name: Some(Arc::clone(&input.name)),
})
}
#[cfg(test)]
+25 -5
View File
@@ -4,7 +4,7 @@ use quick_xml::events::{BytesStart, BytesText, Event};
use serde::{Deserialize, Serialize};
use shared::{
concat_string,
model::{EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme},
model::{has_resource_scheme, ingest_resource_value, EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme},
utils::{deunicode_string, Internable, CONSTANTS},
};
use std::{
@@ -358,6 +358,7 @@ impl TVGuide {
if add_channel {
with_folded_epg_id(&tag_epg_id, |folded| source_processed.insert(folded.intern()));
id_cache.insert_processed_epg_id(&tag_epg_id);
let icon_source = ingest_epg_icon(epg_source, Self::channel_icon(&tag));
accumulator.upsert_channel(
epg_source.priority,
source_order,
@@ -365,7 +366,7 @@ impl TVGuide {
EpgChannel {
id: Arc::clone(&tag_epg_id),
title: Self::channel_display_name(&tag),
icon: Self::channel_icon(&tag),
icon: icon_source,
programmes: vec![],
},
);
@@ -375,13 +376,14 @@ impl TVGuide {
EPG_TAG_PROGRAMME => {
if let Some(epg_id) = tag.get_attribute_value(&epg_attrib_channel) {
if with_folded_epg_id(epg_id, |folded| source_processed.contains(folded)) {
if let Some(programme) = Self::extract_programme(
if let Some(mut programme) = Self::extract_programme(
&tag,
epg_id,
&start_attrib,
&stop_attrib,
&catchup_id_attrib,
) {
programme.icon = ingest_epg_icon(epg_source, programme.icon);
accumulator.push_programme(epg_source.priority, source_order, programme);
}
}
@@ -445,8 +447,9 @@ impl TVGuide {
)
.await
{
Ok(channel) => {
Ok(mut channel) => {
id_cache.insert_processed_epg_id(channel_id);
channel.icon = ingest_epg_icon(epg_source, channel.icon);
accumulator.add_channel_with_programmes(
epg_source.priority,
source_order,
@@ -1131,6 +1134,22 @@ fn apply_dummy_policies(channels: &mut [ChannelMergeAcc], dummy_policies: &HashM
}
}
/// Origin of an icon belonging to `source`.
///
/// An icon without a value has no origin, and a source without an origin keeps `None`, which the
/// request path treats as public-only.
fn ingest_epg_icon(source: &PersistedEpgSource, mut icon: Option<Arc<str>>) -> Option<Arc<str>> {
let value = icon.as_mut()?;
if let Some(input_name) = &source.input_name {
if ingest_resource_value(value, input_name).is_err() {
return None;
}
} else if has_resource_scheme(value) {
return None;
}
icon
}
fn backfill_programme_metadata(existing: &mut EpgProgramme, incoming: EpgProgramme) {
if existing.title.is_none() {
existing.title = incoming.title;
@@ -1308,7 +1327,7 @@ mod tests {
}
fn xmltv_source(file_path: PathBuf, priority: i16, logo_override: bool) -> PersistedEpgSource {
PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv }
PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv, input_name: None }
}
fn dummy_policy_source(priority: i16, source_order: usize, title: &str) -> EpgDummyPolicySource {
@@ -1915,6 +1934,7 @@ mod tests {
..IcsEpgSourceConfig::default()
}),
},
input_name: None,
},
]);
let mut id_cache = EpgIdCache::new(None);
+12 -2
View File
@@ -952,6 +952,7 @@ mod tests {
match_names,
config: Box::new(IcsEpgSourceConfig::default()),
},
input_name: None,
}])
}
@@ -1040,6 +1041,7 @@ mod tests {
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
@@ -1254,6 +1256,7 @@ mod tests {
priority: 0,
logo_override: true,
kind: PersistedEpgSourceKind::Xmltv,
input_name: Some("epg-input".into()),
}]);
let mut playlist = FetchedPlaylist {
input: &input,
@@ -1266,8 +1269,12 @@ mod tests {
let updated = playlist.items_mut().next().unwrap();
assert_eq!(updated.header.epg_channel_id.as_deref(), Some("demo.channel"));
assert_eq!(updated.header.logo.as_ref(), "http://guide/icon.png");
assert_eq!(updated.header.logo_small.as_ref(), "http://guide/icon.png");
let logo = shared::model::ResourceLocator::decode(&updated.header.logo).expect("EPG logo locator");
let logo_small =
shared::model::ResourceLocator::decode(&updated.header.logo_small).expect("EPG small logo locator");
assert_eq!(logo.input_name.as_ref(), "epg-input");
assert_eq!(logo.url.as_ref(), "http://guide/icon.png");
assert_eq!(logo_small, logo);
assert_eq!(epg[0].children[0].id.as_ref(), "Demo.Channel");
});
}
@@ -1327,6 +1334,7 @@ mod tests {
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
@@ -1434,6 +1442,7 @@ mod tests {
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
@@ -1468,6 +1477,7 @@ mod tests {
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
+9 -2
View File
@@ -139,14 +139,21 @@ fn to_playlist_item(
) {
match &entry.metadata {
MediaMetadata::Movie(_) => {
channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path));
let mut item = build_movie_playlist_item(entry, input_name, group_name, api_base_path);
item.header.ingest_resource_values(input_name);
channels.push(item);
}
MediaMetadata::Series(_) => {
if let Some((series_info, episodes)) =
build_series_playlist_items(entry, input_name, group_name, api_base_path)
{
let mut series_info = series_info;
series_info.header.ingest_resource_values(input_name);
channels.push(series_info);
channels.extend(episodes);
channels.extend(episodes.into_iter().map(|mut episode| {
episode.header.ingest_resource_values(input_name);
episode
}));
}
}
}
@@ -1563,6 +1563,7 @@ mod mapping_stage {
match_names: vec!["BBC One".intern()],
config: Box::new(IcsEpgSourceConfig::default()),
},
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
@@ -1658,6 +1659,7 @@ match {
match_names: vec![],
config: Box::new(IcsEpgSourceConfig::default()),
},
input_name: None,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
@@ -164,6 +164,12 @@ impl MappingStageOutcome {
}
pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome {
let mut trusted_locators = HashSet::new();
channel.header.visit_resource_values(&mut |value| {
if shared::model::resolve_resource_value(value).is_ok_and(|locator| locator.is_some()) {
trusted_locators.insert(Arc::clone(value));
}
});
let mut matched_rules = 0;
let mut virtual_items = vec![];
let mut changed_fields = HashSet::new();
@@ -198,6 +204,16 @@ pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping)
}
}
}
let normalize_mapped_item = |item: &mut PlaylistItem| {
item.header.visit_resource_values_mut(&mut |value| {
if shared::model::has_resource_scheme(value) && !trusted_locators.contains(value) {
*value = Arc::from("");
}
});
item.header.normalize_internal_resource_values();
};
normalize_mapped_item(&mut channel);
virtual_items.iter_mut().for_each(normalize_mapped_item);
ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics }
}
+1 -1
View File
@@ -202,7 +202,7 @@ pub async fn download_stalker_playlist(
app_config,
api_client.as_ref(),
&handshake,
refresh_plan,
refresh_plan.clone(),
&storage_path,
identity_fingerprint,
refresh_mode.budget(),
@@ -128,11 +128,12 @@ impl StalkerClusterSelection {
}
/// Requested clusters and the publication policy applied once their generation is complete.
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct StalkerRefreshPlan {
selection: StalkerClusterSelection,
update_quality: ConfigInputUpdateQuality,
quality_bypass_mask: u8,
input_name: Arc<str>,
}
impl StalkerRefreshPlan {
@@ -143,7 +144,7 @@ impl StalkerRefreshPlan {
let quality_bypass_mask = matches!(quality_policy, UpdateQualityPolicy::Bypass)
.then_some(selection.mask() & MEDIA_SELECTION)
.unwrap_or(0);
Self { selection, update_quality, quality_bypass_mask }
Self { selection, update_quality, quality_bypass_mask, input_name: Arc::clone(&input.name) }
}
}
@@ -559,13 +560,16 @@ fn map_items(
categories: &HashMap<u32, StalkerCategory>,
kind: StalkerStreamKind,
added_at: i64,
input_name: &Arc<str>,
) -> Vec<StalkerPlaylistItem> {
raw_items
.iter()
.map(|raw| {
let category =
raw.category_id().and_then(|value| value.parse::<u32>().ok()).and_then(|id| categories.get(&id));
parser::map_stalker_to_playlist_item(raw, category, kind, added_at)
let mut item = parser::map_stalker_to_playlist_item(raw, category, kind, added_at);
item.ingest_resource_values(input_name);
item
})
.collect()
}
@@ -636,7 +640,7 @@ pub async fn advance_stalker_refresh(
identity_fingerprint: u64,
mut budget: StalkerRefreshBudget,
) -> Result<StalkerRefreshOutcome, TuliproxError> {
let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask } = refresh_plan;
let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask, input_name } = refresh_plan;
let mut checkpoint =
load_or_start_checkpoint(storage_path, identity_fingerprint, selection, quality_bypass_mask).await?;
if checkpoint.phase == StalkerRefreshPhase::Terminal {
@@ -673,7 +677,7 @@ pub async fn advance_stalker_refresh(
checkpoint.retry_count = 0;
}
Ok(raw) => {
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at);
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at, &input_name);
let path =
generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
snapshot_stalker_items_at(app_config, path.clone(), &items).await?;
@@ -707,7 +711,7 @@ pub async fn advance_stalker_refresh(
{
return yield_after_error(storage_path, checkpoint, err).await;
}
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at);
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at, &input_name);
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
upsert_stalker_items_at(app_config, &path, &items).await?;
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
@@ -739,7 +743,7 @@ pub async fn advance_stalker_refresh(
{
return yield_after_error(storage_path, checkpoint, err).await;
}
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at);
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at, &input_name);
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Vod);
upsert_stalker_items_at(app_config, &path, &items).await?;
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
@@ -786,6 +790,7 @@ pub async fn advance_stalker_refresh(
root.flussonic_tmp_link = capabilities.flussonic_temporary_link;
root.wowza_tmp_link = capabilities.wowza_temporary_link;
root.use_http_tmp_link = capabilities.use_http_temporary_link;
root.ingest_resource_values(&input_name);
root
})
.collect();
@@ -830,7 +835,10 @@ pub async fn advance_stalker_refresh(
used_episode_ids
.insert(prepare_stalker_episode_series_at(app_config, &path, series_id).await?)
};
let episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
let mut episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
for episode in &mut episodes {
episode.ingest_resource_values(&input_name);
}
upsert_stalker_items_at(app_config, &path, &episodes).await?;
checkpoint.processed = checkpoint.processed.saturating_add(episodes.len() as u64);
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
@@ -1521,7 +1529,7 @@ mod tests {
StalkerCategory { id: "10".to_string(), title: "News".to_string(), alias: None, number: 1 },
)]);
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0);
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0, &Arc::from("stalker-input"));
assert_eq!(items.len(), 1);
assert_eq!(items[0].category_id, 10);