Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

## Unreleased

## [0.8.0-beta.2] - 2026-07-06

### Fixed

- pishoo now loads identity access rules from the identity profile access-rule database.

### Dependencies

- Release manifests now target `h3x` v0.6.0-beta.2, `dhttp`
v0.5.0-beta.2, and `dshell` v0.6.0-beta.2.

### Components

- `gateway` v0.8.0-beta.2
- `pishoo` v0.8.0-beta.2
- `pishoo-common` v0.5.1-1

## [0.8.0-beta.1] - 2026-07-02

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ tower = { version = "0.5", default-features = false, features = ["util"] }
tower-service = "0.3"

# --- QUIC Implementation ---
h3x = { version = "0.6.0-beta.1" }
h3x = { version = "0.6.0-beta.2" }

# --- IPC / RPC ---
remoc = { version = "0.18", default-features = false, features = [
Expand All @@ -60,7 +60,7 @@ remoc = { version = "0.18", default-features = false, features = [
] }

# --- DShell ---
dshell = { version = "0.6.0-beta.1" }
dshell = { version = "0.6.0-beta.2" }

# --- Security & TLS ---
getrandom = "0.4"
Expand Down Expand Up @@ -89,7 +89,7 @@ form_urlencoded = "1"
percent-encoding = "2"
socket2 = "0.6"
x509-parser = "0.18"
dhttp = { version = "0.5.0-beta.1" }
dhttp = { version = "0.5.0-beta.2" }

# --- System Interaction ---
libc = "0.2"
Expand Down
2 changes: 1 addition & 1 deletion gateway/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gateway"
version = "0.8.0-beta.1"
version = "0.8.0-beta.2"
edition = "2024"
license = "Apache-2.0"

Expand Down
2 changes: 1 addition & 1 deletion pishoo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
name = "pishoo"
description = "modern, secure, QUIC-powered web/proxy engine"
homepage = "https://www.dhttp.net"
version = "0.8.0-beta.1"
version = "0.8.0-beta.2"
edition = "2024"
license = "Apache-2.0"

Expand Down
273 changes: 248 additions & 25 deletions pishoo/src/service/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ use gateway::{
},
reverse::router::RouterState,
};
use snafu::{OptionExt, Report, ResultExt, Snafu};
use snafu::{OptionExt, ResultExt, Snafu};

use super::snapshot::ServerService;
use crate::config::load_identity_servers;
use crate::{config::load_identity_servers, worker::config::BuildConfigError};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenRequestFingerprint {
Expand Down Expand Up @@ -553,13 +553,47 @@ impl PrepareContext {
}
}

fn sqlite_access_rules_uri(path: &std::path::Path) -> String {
format!("sqlite://{}?mode=rw", path.display())
}

async fn load_identity_access_rules(
target_node: Option<&Arc<ConfigNode>>,
identity_profile: &IdentityProfile,
fallback: Arc<dhttp::access::matcher::LocationRulesMatcher>,
) -> Result<Arc<dhttp::access::matcher::LocationRulesMatcher>, BuildConfigError> {
if let Some(uri) =
target_node.and_then(|node| node.get::<AccessRulesUri>("access_rules").ok().flatten())
{
let uri_str = uri.0.to_string();
return crate::policy::load_policy_bundle(Some(&uri_str))
.await
.map(|bundle| bundle.location_rules)
.map_err(|source| BuildConfigError::Policy { source });
}

let default_access_db = identity_profile.access_db_path();
match std::fs::metadata(&default_access_db) {
Ok(_) => {
let uri = sqlite_access_rules_uri(&default_access_db);
crate::policy::load_policy_bundle(Some(&uri))
.await
.map(|bundle| bundle.location_rules)
.map_err(|source| BuildConfigError::Policy { source })
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(fallback),
Err(source) => Err(BuildConfigError::InspectDefaultAccessRules {
path: default_access_db,
source,
}),
}
}

impl IdentityServiceSource {
pub async fn prepare(
&self,
ctx: &PrepareContext,
) -> Result<PreparedServerUpdate, PrepareServerUpdateError> {
use crate::worker::config::BuildConfigError;

let identity = self
.identity_profile
.load_identity()
Expand Down Expand Up @@ -638,27 +672,13 @@ impl IdentityServiceSource {
.unwrap_or_default()
.as_secs();

// Worker access control: load rules from identity server.conf if
// access_rules is configured. Falls back to empty rules otherwise.
let access_rules = if let Some(uri) = target_node
.as_ref()
.and_then(|node| node.get::<AccessRulesUri>("access_rules").ok().flatten())
{
let uri_str = uri.0.to_string();
match crate::policy::load_policy_bundle(Some(&uri_str)).await {
Ok(bundle) => bundle.location_rules,
Err(error) => {
tracing::warn!(
error = %Report::from_error(&error),
uri = %uri_str,
"failed to load access rules from identity config"
);
ctx.access_rules.clone()
}
}
} else {
ctx.access_rules.clone()
};
let access_rules = load_identity_access_rules(
target_node.as_ref(),
&self.identity_profile,
ctx.access_rules.clone(),
)
.await
.context(prepare_server_update_error::IdentityServiceSnafu)?;

let server_node = target_node.clone().unwrap_or_else(|| {
let registry = gateway::parse::default_registry();
Expand Down Expand Up @@ -831,6 +851,209 @@ mod tests {
.expect("save identity");
}

async fn write_identity_server_conf(profile: &dhttp::home::identity::IdentityProfile) {
tokio::fs::write(
profile.server_conf_path(),
"server { listen all 0; location / { root .; } }",
)
.await
.expect("write identity server config");
}

async fn write_identity_server_conf_with_access_rules(
profile: &dhttp::home::identity::IdentityProfile,
access_rules: &str,
) {
tokio::fs::write(
profile.server_conf_path(),
format!(
"server {{ listen all 0; access_rules {access_rules}; location / {{ root .; }} }}"
),
)
.await
.expect("write identity server config");
}

async fn write_minimal_access_db(profile: &dhttp::home::identity::IdentityProfile) {
use sea_orm::{ConnectionTrait, Database, Statement};

let path = profile.access_db_path();
tokio::fs::create_dir_all(path.parent().expect("access db path has parent"))
.await
.expect("create access db directory");

let uri = format!("sqlite://{}?mode=rwc", path.display());
let db = Database::connect(&uri).await.expect("connect access db");
db.execute_unprepared(
"CREATE TABLE location_rule_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pattern JSON NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.await
.expect("create location rule sets");
db.execute_unprepared(
"CREATE TABLE location_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
action INTEGER NOT NULL,
exprs JSON NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.await
.expect("create location rules");
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"INSERT INTO location_rule_sets (id, pattern, created_at, updated_at)
VALUES (1, '\"/\"', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
.to_string(),
))
.await
.expect("insert location rule set");
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"INSERT INTO location_rules (location_id, action, exprs, created_at, updated_at)
VALUES (1, 0, '{\"infix\":\"*?\",\"polish\":\"*? \"}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
.to_string(),
))
.await
.expect("insert access rule");
}

fn empty_prepare_context() -> PrepareContext {
PrepareContext {
h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()),
access_rules: std::sync::Arc::new(
dhttp::access::matcher::LocationRulesMatcher::default(),
),
router_state: dummy_router_state(),
}
}

#[tokio::test]
async fn identity_service_prepare_loads_default_access_db_when_it_exists() {
let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-default-access-db"));
let name = fake_name("default-access.dhttp.net");
let profile = home.identity_profile(name.clone());
write_identity(&profile, &name).await;
write_identity_server_conf(&profile).await;
write_minimal_access_db(&profile).await;

let source = IdentityServiceSource {
name: name.clone(),
home,
identity_profile: profile,
};

let prepared = source
.prepare(&empty_prepare_context())
.await
.expect("default access db should load");

let (_location, rules) = prepared
.service
.access_rules
.match_rules("/")
.expect("identity default access db should provide root rules");
assert_eq!(rules.len(), 1);
}

#[tokio::test]
async fn identity_service_prepare_uses_empty_access_rules_when_default_db_is_missing() {
let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-missing-access-db"));
let name = fake_name("missing-access.dhttp.net");
let profile = home.identity_profile(name.clone());
write_identity(&profile, &name).await;
write_identity_server_conf(&profile).await;

let source = IdentityServiceSource {
name: name.clone(),
home,
identity_profile: profile,
};

let prepared = source
.prepare(&empty_prepare_context())
.await
.expect("missing default access db should fall back to empty rules");

assert!(
prepared.service.access_rules.match_rules("/").is_err(),
"missing default access db should not synthesize rules"
);
}

#[tokio::test]
async fn identity_service_prepare_errors_when_default_access_db_exists_but_is_invalid() {
let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-access-db"));
let name = fake_name("invalid-access.dhttp.net");
let profile = home.identity_profile(name.clone());
write_identity(&profile, &name).await;
write_identity_server_conf(&profile).await;
let db_path = profile.access_db_path();
tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent"))
.await
.expect("create access db directory");
tokio::fs::write(&db_path, b"not sqlite")
.await
.expect("write invalid access db");

let source = IdentityServiceSource {
name,
home,
identity_profile: profile,
};

let error = match source.prepare(&empty_prepare_context()).await {
Ok(_) => panic!("invalid existing default access db should be an error"),
Err(error) => error,
};
let report = snafu::Report::from_error(&error).to_string();

assert!(
report.contains("failed to load access rules"),
"unexpected error report: {report}"
);
}

#[tokio::test]
async fn identity_service_prepare_errors_when_explicit_access_rules_db_is_invalid() {
let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-explicit-access"));
let name = fake_name("invalid-explicit-access.dhttp.net");
let profile = home.identity_profile(name.clone());
write_identity(&profile, &name).await;
write_identity_server_conf_with_access_rules(&profile, "sqlite:./db/access.db?mode=ro")
.await;
let db_path = profile.access_db_path();
tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent"))
.await
.expect("create access db directory");
tokio::fs::write(&db_path, b"not sqlite")
.await
.expect("write invalid access db");

let source = IdentityServiceSource {
name,
home,
identity_profile: profile,
};

let error = match source.prepare(&empty_prepare_context()).await {
Ok(_) => panic!("invalid explicit access_rules db should be an error"),
Err(error) => error,
};
let report = snafu::Report::from_error(&error).to_string();

assert!(
report.contains("failed to load access rules"),
"unexpected error report: {report}"
);
}

#[tokio::test]
async fn pishoo_config_service_loads_identity_from_home_when_tls_is_omitted() {
let home = dhttp::home::DhttpHome::new(unique_test_dir("config-service-home"));
Expand Down
7 changes: 7 additions & 0 deletions pishoo/src/worker/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//! [`IdentityServiceSource`](crate::service::source::IdentityServiceSource) values.
//! Runtime preparation happens in [`crate::service::source`].

use std::path::PathBuf;

use dhttp::home::DhttpHome;
use futures::StreamExt;
use gateway::error::Whatever;
Expand All @@ -17,6 +19,11 @@ use crate::policy;
pub enum BuildConfigError {
#[snafu(transparent)]
Whatever { source: Whatever },
#[snafu(display("failed to inspect default access rules path `{}`", path.display()))]
InspectDefaultAccessRules {
path: PathBuf,
source: std::io::Error,
},
#[snafu(display("failed to load access rules"))]
Policy { source: policy::PolicyError },
}
Expand Down
Loading
Loading