-
Notifications
You must be signed in to change notification settings - Fork 78
Unified payjoin service #1232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Unified payjoin service #1232
Conversation
Pull Request Test Coverage Report for Build 21080158958Details
💛 - Coveralls |
|
concept ACK. IMO we should also let go of all of the config/cli boilerplate coming from the directory (which in turn borrows from payjoin-cli), since that mainly grew with not breaking compatibility in mind. A clean slate approach would be simpler and cleaner, and we can take our time with it. I think main.rs can be removed entirely from this PR, focusing on making this a library crate that can be cleanly used in our test utils crate. Then for deployment, a minimal main.rs can be added in a followup PR, tailored for container usage only. The majority of the boilerplate in the existing code arises from interface between So i guess the next thing to figure out how to configure such a minimal main.rs. And we should probably look at how other projects handle this stuff. |
d6fe5ef to
dc01a20
Compare
|
Good points about the cli boilerplate. Instead of removing main.rs entirely I stripped it to a minimal binary which accepts a single optional argument for the config file path (inspired by cdk-mintd). Since your last review, the latest push contains the above change, moves all the core logic to lib.rs, adds manual TLS support for tests, and replaces the payjoin-directory + ohttp-relay direct dependencies in test utils with two instances of payjoin-service. |
|
are we opting to keep hyper @nothingmuch ? asking because the inital plan was to move away from hyper and do all of our http stuffs with axum |
@spacebear21 said that the challenges with hyper/tower service traits were easy enough to fix, so the original motivation for short cutting to axum might not be as strong as we thought (recall that originally i thought it would be easier to first use only the tower service trait and then integrate axum, but that was causing some headaches) IMO there's no need to keep hyper, it's more low level than we need and doesn't have a native concept of routers that forces us to have more boilerplate, but if still allowing the hyper.Service to be in use is easy and doesn't get in the way (which kinda makes sense since axum uses it under the hood anyway, and both in turn rely on tower's service trait IIRC) then we don't need to remove it |
|
@zealsham Indeed, instead of rewriting all the routing from scratch in axum I figured we could keep the existing logic in payjoin-directory/ohttp-relay mostly untouched while we introduce payjoin-service for the unified binary. In follow-ups we can rewrite individual components as tower services in chunks as we see fit, and softly deprecate |
spacebear21
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comments from mob programming session. Small fixes will go in immediately (including blocking self requests with HMAC of the body) and we will follow up in future PRs.
|
|
||
| #[derive(Clone)] | ||
| pub struct Service { | ||
| config: Arc<RelayConfig>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idiomatic approach: make RelayConfig public and make it a Builder pattern. Rename RelayConfig to InternalConfig, use RelayConfig for the Builder that builds that type.
payjoin-service/src/lib.rs
Outdated
| } | ||
| } | ||
|
|
||
| /// Routes incoming requests to ohttp-relay or payjoin-directory based on the path. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could have the gateway enforce that the relay isn't itself with a HMAC header.
|
my mob review conclusion: concept ACK, made some minor requests for changes in the call (posted by spacebear):
|
af351cd to
7eb51af
Compare
This is a preparatory refactor ahead of introducing the unified `payjoin-service`.
This makes ohttp relay modular as a tower Service, in preparation for the unified payjoin-service.
Ensure that if a GATEWAY_URI is set, it must point to the hardcoded default gateway. This ensures backwards-compatibility for existing payjoin implementations. Alternate gateways can still be specified in incoming requests via gateway opt-in.
Accept any Body implementation instead of only hyper::body::Incoming. This enables integration with axum and other frameworks that use different body types.
Accept any Body implementation instead of only hyper::body::Incoming. This enables integration with axum and other frameworks that use different body types. Replace hyper_tungstenite with manual WebSocket upgrade handling since hyper_tungstenite::upgrade() requires Request<Incoming>. The generic hyper::upgrade::on() combined with tokio_tungstenite provides equivalent functionality with generic body support.
This introduces the payjoin-service binary crate, which lives outside of the workspace for now to enable independent testing and Cargo.lock changes without causing conflicts.
To introduce payjoin-service, it can simply route requests to the ohttp-relay or payjoin-directory sub-services based on URL path discrimination. Individual components (e.g health checks, metrics...) can then be migrated to axum in follow-ups, and use `tower` middleware where appropriate to reduce boilerplate.
This replaces the direct dependencies on ohttp-relay and payjoin-directory with a dependency on payjoin-service. The test services still spin up two instances of the payjoin-service to simulate a relay and directory running on isolated infrastructure.
4ad90dc to
aeab555
Compare
OHTTP privacy guarantees rely on the assumption that the relay and gateway operate independently from each other. The payjoin service runs both OHTTP relay and the directory gateway in the same process, so we make a best-effort attempt to detect requests from the same instance via a sentinel header. This check eliminates a potential footgun for service operators and payjoin implementers.
aeab555 to
89351fc
Compare
nothingmuch
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
only reviewed the last commit for self loop detection, i think the API surface can be shrunk quite a bit without losing any desired behavior
| use hex::{DisplayHex, FromHex}; | ||
|
|
||
| /// HTTP header name for the sentinel tag. | ||
| pub const HEADER_NAME: &str = "x-pj-sentinel"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bikeshedding: maybe "x-ohttp-self-loop-tag" is more descriptive of what this is for?
|
|
||
| /// Generate random sentinel tag at startup. | ||
| /// The relay and directory share this tag in a best-effort attempt | ||
| /// at preventing collusion from the same instance. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this doesn't actually prevent collusion so i think just "at detecting self loops" is enough?
| db: D, | ||
| ohttp: ohttp::Server, | ||
| metrics: Metrics, | ||
| sentinel_tag: Option<SentinelTag>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i would prefer if this was unconditional, and either a tag of all 0s or preferably a random tag was generated instead, in order to reduce the conditional nesting a little in the handling
| db: D, | ||
| ohttp: ohttp::Server, | ||
| metrics: Metrics, | ||
| sentinel_tag: Option<SentinelTag>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the internal tag is non optional, this could still be optional to allow overriding with a specific tag for testing, but i think i would prefer if we just eliminated the argument entirely
a test that ensures self loops are detected and rejected can confirm the desired behavior without setting this value or saying anything about the loop detection mechanism, and a white-box test for the mechanism can just rewrite the request and ensure that that is still allowed, so this can be eliminated from the API
| let path_segments: Vec<&str> = path.split('/').collect(); | ||
| debug!("Service::serve_request: {:?}", &path_segments); | ||
|
|
||
| let sentinel_header = parts |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not check the header here?
i think any self loop can be rejected even if it isn't destined for the OHTTP gateway, there is no valid reason to handle a self looping request as far as i can tell
this way handle_ohttp_gateway can remain agnostic of this detail
| /// | ||
| /// Note that incoming requests should be **rejected** when this function returns `Ok(true)`, | ||
| /// as that would indicate the relay and gateway are the same instance. | ||
| pub fn verify(tag: &SentinelTag, header_value: &str) -> Result<bool, InvalidHeader> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is no longer verifying an HMAC so should probably be renamed to is_recognized or check_self_loop maybe?
| /// Note that incoming requests should be **rejected** when this function returns `Ok(true)`, | ||
| /// as that would indicate the relay and gateway are the same instance. | ||
| pub fn verify(tag: &SentinelTag, header_value: &str) -> Result<bool, InvalidHeader> { | ||
| let header_bytes = <[u8; 32]>::from_hex(header_value).map_err(|_| InvalidHeader)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if instead of parsing the header, the tag was just stored already encoded in its internal representation then it could be compared directly which can simplify the return type to just a bool
This PR introduces a unified
payjoin-servicebinary that combines the OHTTP relay and payjoin directory services in one binary, as discussed in https://github.com/orgs/payjoin/discussions/775 and tracked in #941.This approach refactors the relay & directory as tower
Services, and simply routes requests to those existing services based on URL path discrimination, to introducepayjoin-servicewith minimal code changes. The idea is to merge this PR ~as-is as a foundation, then fold individual components into payjoin-service one by one in follow ups.Much of this PR was written by Claude, with close supervision and scrutiny by me. I smoke-tested the binary with some basic
curlto ensure it was routing properly, and updated payjoin-test-utils directory and relay test services to instances of payjoin-service. This way, we'll have some regression testing already in place as we fold more components into payjoin-service.Pull Request Checklist
Please confirm the following before requesting review:
AI
in the body of this PR.