Implement nat46#74
Conversation
Update edition to 2024, this allows to use if let chains, and similar features, that got introduced with edition 2024.
Add a `nat46_prefix` to the Backend struct. This allows to configure a nat46 prefix to use when encoding the v4 address in a v6 destination address. Co-authored-by: Schrottkatze <git@schrottkatze.de>
Run cargo fmt to cleanup further commits when running fmt localy
Add a nat46 feature similar to the one implemented in snid[0]. [0]: https://github.com/AGWA/snid/tree/main?tab=readme-ov-file#nat46-mode
atenart
left a comment
There was a problem hiding this comment.
Thanks for the PR! I believe this will need some revisions but I also don't see a blocker about the feature itself.
A few global comments:
- All commits should have a proper Signed-off-by tag (some are missing and I'm not sure about the one in patch 2). Sorry, this should be in a contributing guide.
- What are the advantages in using NAT46 instead of using a public v4 address + v6 backend + proxy protocol header? Is it only about conveying the client IP at a lower level?
- No need to reference third party projects in commit logs, NAT46 is well defined in an RFC which can be referenced directly instead.
- Please update the documentation as well (README.md), this also helps while reviewing as it shows the intent.
- Please put the formatting patch before introducing new code.
| let mut ifname_c = [0u8; libc::IF_NAMESIZE]; | ||
| (&mut ifname_c[..if_name.len()]).copy_from_slice(if_name.as_bytes()); |
There was a problem hiding this comment.
I believe you can use a CString and its to_ptr helper instead here.
There was a problem hiding this comment.
CString is heap based and bit more annoying to work with. as there is a max name that is enforced by linux I found this approach much nicer to work with and it is stack based
There was a problem hiding this comment.
There's already a check on the name length so that should be fine. By regardless of that I'd prefer to stick to higher level interfaces for easier maintenance and potential issues.
| impl Nat46Address { | ||
| /// Convert ifname to scope_id | ||
| #[cfg(not(test))] | ||
| fn if_nametoindex(if_name: &str) -> std::io::Result<u32> { |
There was a problem hiding this comment.
Please use anyhow::Result instead and e.g. the bail! macro for returning early. There are other examples.
There was a problem hiding this comment.
Really not a fan of anyhow for the type erasure. as this is just a wrapper around libc using errno I would favour sticking with io::Result. but can change if you exists but might lose debugging info for the errno context things
There was a problem hiding this comment.
The errors are ultimately being converted to anyhow, so let's not split the project in this regards.
FYI I believe using the anyhow::Context trait the underlying error can be shown if needed.
| #[derive(Debug)] | ||
| pub(crate) struct Nat46Address { | ||
| /// V6 address prefix | ||
| pub(crate) address: std::net::Ipv6Addr, |
There was a problem hiding this comment.
There are many examples of fully qualified names in the PR. Please avoid them (except for the libc:: ones).
|
|
||
| /// IPv6 address prefix and optional scope ID for nat46 mapping. | ||
| #[derive(Debug)] | ||
| pub(crate) struct Nat46Address { |
There was a problem hiding this comment.
Instead of creating a new type and implementing by hand deserialization, why not using the following configuration form:
nat46:
prefix: ...
iface: ...
The prefix could be handled by Ip6Addr directly. The iface id might need a custom implementation, like what is done in deserialize_regex. But overall this would simplify things.
There was a problem hiding this comment.
I find the proper scope id way much easier to understand and write as user. This is what other tools use as well on linux, therefor opted for the %iface way of writing it
There was a problem hiding this comment.
YMMV. Not saying the syntax is bad in general, but the cost/benefit of having two fields here is nice.
There was a problem hiding this comment.
I might have understood what was bothering me. There's no "nat46" concept in sniproxy, data can already flow between v4/v6. The added value here is the ability to select a translation prefix and to bind to a device. Those two are not linked (they can be used independently and/or in combination) and should come as separate features in separate patches. With that my proposal nat46 block doesn't make sense either.
The way forward is probably something like:
routes:
- domains:
- "example.net"
backend:
address: "[1111::1]:8080"
translation_prefix: "64:ff9b:1::"
device: eth0
There was a problem hiding this comment.
There are kinda link. As it's part of the socketaddr to bind to a device for the backend used address
There was a problem hiding this comment.
It depends. You might want to bind to a device for various reasons (e.g. VRF) without doing address translation or even using IPv6 at all. You could also do address translation without binding to a device. Or do both in the example you have in mind, which is very valid too.
There was a problem hiding this comment.
But it's very much non trivial to implement. If we don't do this baring either way to already set a source address
There was a problem hiding this comment.
But it's very much non trivial to implement.
I think that would look like (untested). That seems doable, unless I'm missing something.
let sock = socket2::Socket(...)?;
if let Some(device) = &backend.device {
sock.bind_device(Some(device))?;
}
if let Some(prefix) = &backend.translation_prefix {
let prefix = compute_prefix(prefix, client);
sock.bind(prefix)?;
}
sock.connect_timeout(...)?;
let conn: TcpStream = sock.into();It's slightly different from using sin6_scope_id; but for the use case here I think that should do, or is there an issue I'm not seeing?
This has some benefits, e.g. in allowing to bind to a device regardless of using a translation prefix and in making parsing easier.
There was a problem hiding this comment.
I'm unsure if this will work with fe80 addresses. But will have to try once the time for it (hopefully Monday)
| if let Some(len_str) = len_str | ||
| && let len = len_str.parse::<u32>().map_err(|e| E::custom(e))? | ||
| && len > 96 | ||
| { | ||
| return Err(E::custom(format!( | ||
| "Prefix len {len} is to small as nat46 prefix" | ||
| ))); | ||
| } |
There was a problem hiding this comment.
Why allowing a CIDR as it's not used? I guess it should be removed.
There was a problem hiding this comment.
Idea was to allow pasting it in and as validation that we need a /96 or bigger CIDR. So intendet as a aid for the user without causing much harm
There was a problem hiding this comment.
Let's not add it if it's not used. We can still have checks in Config::from_str making sure the prefix looks good.
| let mut scope_id = 0; | ||
| if let Some(scope_str) = scope_str { | ||
| if let Ok(scope) = scope_str.parse::<u32>() { | ||
| scope_id = scope; |
There was a problem hiding this comment.
Not sure a plain id should be allowed, as it's not a stable interface. Plus converting the name to its id is only done at startup so that won't impact performances.
| time::Instant, | ||
| }; | ||
|
|
||
| pub(crate) struct Socket6(OwnedFd); |
There was a problem hiding this comment.
I don't think there's a need to re-implement this manually. We're already using socket2 which provides Socket. My understanding is the following should do:
let sock = socket2::Socket::new(...)?;
sock.bind(...)?;
sock.connect_timeout(...)?;
let conn: TcpStream = sock.into();| let peer_addr = match peer_addr { | ||
| SocketAddr::V4(v) => v, | ||
| SocketAddr::V6(v) if matches!(v.ip().to_ipv4_mapped(), Some(_ip)) => { | ||
| let ip = unsafe { v.ip().to_ipv4_mapped().unwrap_unchecked() }; |
There was a problem hiding this comment.
Why would we get an IPv4-mapped IPv6 address here?
| #[cfg(test)] | ||
| fn if_nametoindex(if_name: &str) -> std::io::Result<u32> { | ||
| match if_name { | ||
| "eth0" => Ok(2), | ||
| "eth1" => Ok(3), | ||
| _ => Err(std::io::Error::from_raw_os_error(libc::ENODEV)), | ||
| } | ||
| } |
There was a problem hiding this comment.
Could you instead not call the helper from the deserialization logic when testing and convert it there to a static known value ("0")?
There was a problem hiding this comment.
Not sure what you mean. also this is not only returning 0 but depending on the iface name?
There was a problem hiding this comment.
I was thinking something like the following to avoid a full function redefinition and still test most of it.
let scope_id = if !cfg!(test) {
unsafe { libc::if_nametoindex(ifname_c.as_ptr().cast()) }
} else {
1
};
Furthermore there should be something telling which DoC I would sign with that. I did not find that, and therefore did not sign it off. would like that to be added before
proxy protocol header is something you have to do more consciously, with this you can just look into the log and see the original client v4 address
|
That's fair. I'll add something in the doc with a link to https://www.kernel.org/doc/html/latest/process/submitting-patches.html?highlight=signed%20off#developer-s-certificate-of-origin-1-1
I was wondering if there was something else. This alone is nice though. |
Implement a nat46 mode similar to https://github.com/AGWA/snid/tree/main?tab=readme-ov-file#nat46-mode but using config instead of DNS records