Skip to content

Implement nat46#74

Open
kloenk wants to merge 4 commits into
atenart:mainfrom
kloenk:nat46
Open

Implement nat46#74
kloenk wants to merge 4 commits into
atenart:mainfrom
kloenk:nat46

Conversation

@kloenk

@kloenk kloenk commented Sep 22, 2025

Copy link
Copy Markdown

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

kloenk and others added 3 commits September 14, 2025 10:54
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
@kloenk kloenk mentioned this pull request Sep 22, 2025

@atenart atenart left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.rs
Comment on lines +273 to +274
let mut ifname_c = [0u8; libc::IF_NAMESIZE];
(&mut ifname_c[..if_name.len()]).copy_from_slice(if_name.as_bytes());

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can use a CString and its to_ptr helper instead here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.rs
impl Nat46Address {
/// Convert ifname to scope_id
#[cfg(not(test))]
fn if_nametoindex(if_name: &str) -> std::io::Result<u32> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use anyhow::Result instead and e.g. the bail! macro for returning early. There are other examples.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.rs
#[derive(Debug)]
pub(crate) struct Nat46Address {
/// V6 address prefix
pub(crate) address: std::net::Ipv6Addr,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many examples of fully qualified names in the PR. Please avoid them (except for the libc:: ones).

Comment thread src/config.rs

/// IPv6 address prefix and optional scope ID for nat46 mapping.
#[derive(Debug)]
pub(crate) struct Nat46Address {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YMMV. Not saying the syntax is bad in general, but the cost/benefit of having two fields here is nice.

@atenart atenart Sep 24, 2025

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are kinda link. As it's part of the socketaddr to bind to a device for the backend used address

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it's very much non trivial to implement. If we don't do this baring either way to already set a source address

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure if this will work with fe80 addresses. But will have to try once the time for it (hopefully Monday)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Comment thread src/config.rs
Comment on lines +362 to +369
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"
)));
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why allowing a CIDR as it's not used? I guess it should be removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.rs
let mut scope_id = 0;
if let Some(scope_str) = scope_str {
if let Ok(scope) = scope_str.parse::<u32>() {
scope_id = scope;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/tcp/socket.rs
time::Instant,
};

pub(crate) struct Socket6(OwnedFd);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Comment thread src/tcp/tls.rs
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() };

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we get an IPv4-mapped IPv6 address here?

Comment thread src/config.rs
Comment thread src/config.rs
Comment on lines +285 to +292
#[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)),
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you instead not call the helper from the deserialization logic when testing and convert it there to a static known value ("0")?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean. also this is not only returning 0 but depending on the iface name?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
};

@kloenk

kloenk commented Sep 23, 2025

Copy link
Copy Markdown
Author

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.

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

* 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?

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

* 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.

@atenart

atenart commented Sep 24, 2025

Copy link
Copy Markdown
Owner

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

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

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

I was wondering if there was something else. This alone is nice though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants