directory.toml's fetch_internal lets the refresher reach listed addresses (#329)

3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.

directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:33:30 +00:00
parent d35b8054b4
commit 46fe0dddbf
6 changed files with 20 additions and 7 deletions

View File

@@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351). - HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341). - The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect. It fetched them, and stored what answered as the feed's title and description (#329). - The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
## [1.3.0] - 2026-10-19 ## [1.3.0] - 2026-10-19

View File

@@ -23,6 +23,10 @@ pub struct Config {
/// left out, does neither. /// left out, does neither.
#[serde(default)] #[serde(default)]
pub apple_country: String, pub apple_country: String,
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
#[serde(default)]
pub fetch_internal: Vec<String>,
#[serde(default)] #[serde(default)]
pub tokens: Vec<TokenCfg>, pub tokens: Vec<TokenCfg>,
} }

View File

@@ -137,7 +137,11 @@ const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
pub type Shared = Arc<Directory>; pub type Shared = Arc<Directory>;
impl Directory { impl Directory {
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> { pub fn new(
store: Store,
tokens: Vec<Token>,
fetch_internal: ipx_feed::guard::Allowed,
) -> Result<Shared, reqwest::Error> {
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop. // No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
@@ -145,8 +149,8 @@ impl Directory {
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
// The feeds are named by whoever holds a write token, and redirect where their // The feeds are named by whoever holds a write token, and redirect where their
// publishers like; none is on this network, so nothing here is fetched (#329). // publishers like: nothing on this network is fetched unless listed (#329).
.connector_layer(ipx_feed::guard::Guard::new(Default::default())) .connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
.build()?; .build()?;
Ok(Arc::new(Self { Ok(Arc::new(Self {
store, store,
@@ -522,7 +526,7 @@ mod tests {
sha256: config::hash(&format!("secret-{n}")), sha256: config::hash(&format!("secret-{n}")),
}) })
.collect(); .collect();
let dir = Directory::new(Store::scratch().await, tokens).unwrap(); let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
(router(dir.clone()), dir) (router(dir.clone()), dir)
} }
@@ -685,7 +689,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_revoked_token_loses_its_access_and_its_counts() { async fn a_revoked_token_loses_its_access_and_its_counts() {
let dir = Directory::new(Store::scratch().await, vec![]).unwrap(); let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) }; let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap(); dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
let app = router(dir.clone()); let app = router(dir.clone());

View File

@@ -102,7 +102,7 @@ async fn serve(path: &std::path::Path) -> Result<()> {
.filter(|u| !u.trim().is_empty()) .filter(|u| !u.trim().is_empty())
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?; .with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
let store = Store::open(&url, true).await.context("opening the database")?; let store = Store::open(&url, true).await.context("opening the database")?;
let dir = Directory::new(store, vec![])?; let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
let dropped = dir.set_tokens(tokens).await?; let dropped = dir.set_tokens(tokens).await?;
let listener = let listener =

View File

@@ -83,6 +83,9 @@ bind = "0.0.0.0:8100"
metrics_bind = "0.0.0.0:9188" metrics_bind = "0.0.0.0:9188"
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none. # Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
apple_country = "ca" apple_country = "ca"
# Internal addresses and networks feeds may be fetched from, as ["10.0.0.5", "192.168.1.0/24"].
# Empty, or left out: nothing off the public internet is fetched (#329).
fetch_internal = []
# The environment variable holding the database URL: postgres://... or sqlite://... # The environment variable holding the database URL: postgres://... or sqlite://...
database_url_env = "DIRECTORY_DATABASE_URL" database_url_env = "DIRECTORY_DATABASE_URL"

View File

@@ -77,6 +77,8 @@ auto_download = false
fs.writeFileSync(path.join(root, 'directory.toml'), ` fs.writeFileSync(path.join(root, 'directory.toml'), `
bind = "127.0.0.1:8795" bind = "127.0.0.1:8795"
database_url_env = "DIRECTORY_DATABASE_URL" database_url_env = "DIRECTORY_DATABASE_URL"
# The fixture feeds are on loopback, which the refresher refuses otherwise (#329).
fetch_internal = ["127.0.0.1"]
[[tokens]] [[tokens]]
name = "test" name = "test"