Automatically manage firewall rules (iptables) for services #1

Merged
quentin merged 7 commits from add-firewall-rules into master 12 months ago
Collaborator

We chose to use the iptables crate rather than the nftables one (nftnl) because it is much higher level and seems to be more mature/popular.

This adds a new firewall (fw) actor that handles event and a fw module that provides high level functions (setup, add port, cleanup).

Still TODO / needs to be discussed :

  • Do we need to cleanup iptables rules when diplonat is stopped ? Do we tackle this with tokio / unix signal handlers / shutdown_hooks ?
  • More global question : are we handling "delete service/port" events ? it doesn't seem to be done in the other actors, or am I mistaken ? :)
We chose to use the iptables crate rather than the nftables one (nftnl) because it is much higher level and seems to be more mature/popular. This adds a new firewall (fw) actor that handles event and a fw module that provides high level functions (setup, add port, cleanup). Still TODO / needs to be discussed : * Do we need to cleanup iptables rules when diplonat is stopped ? Do we tackle this with tokio / unix signal handlers / shutdown_hooks ? * More global question : are we handling "delete service/port" events ? it doesn't seem to be done in the other actors, or am I mistaken ? :)
Owner

Should we cleanup? We should avoid state if possible

So in my review I propose a bit agressive solutions: flushing entries on each notification. Of course, we should be careful to not break the connection if we do that, it is maybe a bad idea. But we can cleanup at process start. Cleaning on stopping is a bit hard as process may crash, I think it will be lot of burden for close to no gain

IGD ports are removed by reaching a timeout due to not renewing them

Considering IGD, ports are registered with a timeout: if registration is not renewed, the entry is garbage collected automatically by the router.
On each notification, the full sets of ports to be opened is sent, so by not renewing ports that are not required, the NAT entry on the router will naturally expire.

## Should we cleanup? We should avoid state if possible So in my review I propose a bit agressive solutions: flushing entries on each notification. Of course, we should be careful to not break the connection if we do that, it is maybe a bad idea. But we can cleanup at process start. Cleaning on stopping is a bit hard as process may crash, I think it will be lot of burden for close to no gain ## IGD ports are removed by reaching a timeout due to not renewing them Considering IGD, ports are registered with a timeout: if registration is not renewed, the entry is garbage collected automatically by the router. On each notification, the full sets of ports to be opened is sent, so by not renewing ports that are not required, the NAT entry on the router will naturally expire.
quentin requested changes 12 months ago
Dismissed
quentin left a comment

Nice job, some minor comments before the merge to make your code more homogenous with our coding style ;)

[...]
options {
docker.privileged.enabled = "true"
Poster
Owner

Uuuuh nicely spot, I did not think to it but it makes sense :)
Thanks for testing the whole pipeline!

Uuuuh nicely spot, I did not think to it but it makes sense :) Thanks for testing the whole pipeline!
darkgallium marked this conversation as resolved
src/fw.rs Outdated
use regex::Regex;
use std::collections::HashSet;
use crate::messages;
Poster
Owner

Could you add the anyhow crate to handle errors please:

use anyhow::{Result,Context}
Could you add the anyhow crate to handle errors please: ```rust use anyhow::{Result,Context} ```
src/fw.rs Outdated
}
}
pub fn setup(ipt: &iptables::IPTables) -> Result<(), FirewallError> {
Poster
Owner

anyhow will override your Result<> object, taking only a return value, error will be generic then.
eg:

pub fn setup(ipt: &iptables::IPTables) -> Result<()> {

This tip applies for the whole document, and in any case build will fail as soon as you will have added the anyhow use statement.

anyhow will override your Result<> object, taking only a return value, error will be generic then. eg: ```rust pub fn setup(ipt: &iptables::IPTables) -> Result<()> { ``` This tip applies for the whole document, and in any case build will fail as soon as you will have added the anyhow use statement.
src/fw.rs Outdated
pub fn setup(ipt: &iptables::IPTables) -> Result<(), FirewallError> {
if !ipt.chain_exists("filter", "DIPLONAT")? {
Poster
Owner

It's very clever to put the rules in a separate chain, well done ;)

It's very clever to put the rules in a separate chain, well done ;)
src/fw.rs Outdated
Ok(())
}
pub fn open_ports(ipt: &iptables::IPTables, ports: messages::PublicExposedPorts) -> Result<(), FirewallError> {
Poster
Owner

Same as before, you can just use Result<()>

Same as before, you can just use `Result<()>`
src/fw.rs Outdated
Ok(())
}
pub fn get_opened_ports(ipt: &iptables::IPTables) -> Result<messages::PublicExposedPorts, FirewallError> {
Poster
Owner

Same as before, you can just use Result<messages::PublicExposedPorts>

Same as before, you can just use `Result<messages::PublicExposedPorts>`
src/fw.rs Outdated
};
let list = ipt.list("filter", "DIPLONAT")?;
let re = Regex::new(r"\-A.*? \-p (\w+).*\-\-dport (\d+).*?\-j ACCEPT").unwrap();
Poster
Owner

A convention we chose with LX (we should document it, mea culpa) is to use the anyhow crate and to handle errors without crashing whenever possible but forwarding it "higher" in the call stack.

The idea is the caller can choose to crash or to bypass the error and then your code can be used as a handful library. So, as your return type is already a Result, you can return Ok() or, thanks to anyhow, any kind of error. You can simply rewrite your statement as follow (as soon as you have added anyhow):

let re = Regex::new(r"\-A.*? \-p (\w+).*\-\-dport (\d+).*?\-j ACCEPT")?

To help debugging, you can add a context to your error:

let re = Regex::new(r"\-A.*? \-p (\w+).*\-\-dport (\d+).*?\-j ACCEPT").context("Regex matching open ports encountered an unexpected rule")?

As a more general rule, do not use:

  • expect
  • unwrap

Instead use:

  • ?
  • context
  • handle error in place if it's possible
A convention we chose with LX (we should document it, mea culpa) is to use the anyhow crate and to handle errors without crashing whenever possible but forwarding it "higher" in the call stack. The idea is the caller can choose to crash or to bypass the error and then your code can be used as a handful library. So, as your return type is already a `Result`, you can return `Ok()` or, thanks to anyhow, any kind of error. You can simply rewrite your statement as follow (as soon as you have added anyhow): ```rust let re = Regex::new(r"\-A.*? \-p (\w+).*\-\-dport (\d+).*?\-j ACCEPT")? ``` To help debugging, you can add a context to your error: ```rust let re = Regex::new(r"\-A.*? \-p (\w+).*\-\-dport (\d+).*?\-j ACCEPT").context("Regex matching open ports encountered an unexpected rule")? ``` As a more general rule, do not use: - `expect` - `unwrap` Instead use: - `?` - `context` - handle error in place if it's possible
src/fw.rs Outdated
let caps = re.captures(&i);
match caps {
Some(c) => {
let raw_proto = c.get(1).unwrap();
Poster
Owner

Lines 50, 51 and 54 you can replace unwrap by ?

Lines 50, 51 and 54 you can replace `unwrap` by `?`
src/fw.rs Outdated
Ok(ports)
}
pub fn cleanup(ipt: &iptables::IPTables) -> Result<(), FirewallError> {
Poster
Owner

Same as before, you can just use Result<()>


Moreover, I see that you never call this function.
What about calling it on the setup, so if the app reboots at least it will start with a clean state?
Even more agressive, could we not just simply regenerates the whole chain on every change?
So, no difference to compute or ports to close?

I believe that on each modification to iptables, you have to flush the whole set of rules,
so if we could do a single "commit" to the kernel with a brand new chain it could be great.

In practise:

  • If it's two lines to change, it's easy and takes 5 minutes, you can do the modification
  • Otherwise, let's keep ip for a next version
Same as before, you can just use `Result<()>` --- Moreover, I see that you never call this function. What about calling it on the setup, so if the app reboots at least it will start with a clean state? Even more agressive, could we not just simply regenerates the whole chain on every change? So, no difference to compute or ports to close? I believe that on each modification to iptables, you have to flush the whole set of rules, so if we could do a single "commit" to the kernel with a brand new chain it could be great. In practise: - If it's two lines to change, it's easy and takes 5 minutes, you can do the modification - Otherwise, let's keep ip for a next version
src/fw_actor.rs Outdated
impl FirewallActor {
pub async fn new(_refresh: Duration, rxp: &watch::Receiver<messages::PublicExposedPorts>) -> Result<Self> {
let ctx = Self {
ipt: iptables::new(false).unwrap(),
Poster
Owner

Replace with ?

Replace with `?`
src/fw_actor.rs Outdated
refresh: _refresh,
};
fw::setup(&ctx.ipt).expect("iptables setup failed");
Poster
Owner

Here also you can put a ? :)

Here also you can put a `?` :)
// 3. Update firewall rules
match self.do_fw_update().await {
Ok(()) => debug!("Successfully updated firewall rules"),
Err(e) => error!("An error occured while updating firewall rules. {}", e),
Poster
Owner

By putting ? everywhere, we can handle all errors here.
Currently, this line can't be called as there is no way that do_fw_update returns an Err as inside the function, you never returns an Err as unwrap makes the app panic ;)

By putting `?` everywhere, we can handle all errors here. Currently, this line can't be called as there is no way that `do_fw_update` returns an `Err` as inside the function, you never returns an `Err` as `unwrap` makes the app panic ;)
darkgallium marked this conversation as resolved
src/fw_actor.rs Outdated
}
pub async fn do_fw_update(&self) -> Result<()> {
let curr_opened_ports = fw::get_opened_ports(&self.ipt).unwrap();
Poster
Owner

You should replace unwrap ;)

You should replace `unwrap` ;)
pub async fn do_fw_update(&self) -> Result<()> {
let curr_opened_ports = fw::get_opened_ports(&self.ipt).unwrap();
let diff_tcp = self.last_ports.tcp_ports.difference(&curr_opened_ports.tcp_ports).copied().collect::<HashSet<u16>>();
Poster
Owner

Nice idea the set difference ;)

Nice idea the set difference ;)
Poster
Collaborator

Thanks a lot :)

Thanks a lot :)
quentin marked this conversation as resolved
tcp_ports: diff_tcp,
udp_ports: diff_udp
};
Poster
Owner

I see, maybe we could add later logic to close ports but I also like the idea to do the things incrementally.
So let's focus on stabilizing the current features and see how it runs in production :)

I see, maybe we could add later logic to close ports but I also like the idea to do the things incrementally. So let's focus on stabilizing the current features and see how it runs in production :)
quentin marked this conversation as resolved
src/fw_actor.rs Outdated
udp_ports: diff_udp
};
fw::open_ports(&self.ipt, ports_to_open).unwrap();
Poster
Owner

Here also ;)

Here also ;)
Poster
Collaborator

So in my review I propose a bit agressive solutions: flushing entries on each notification. Of course, we should be careful to not break the connection if we do that, it is maybe a bad idea.

I'm a little concerned about flushing entries on notification for the exact reason you are mentioning. I'd rather test it in another version as you are suggesting :)

But we can cleanup at process start. Cleaning on stopping is a bit hard as process may crash, I think it will be lot of burden for close to no gain

Agreed. Cleaning up on start is much more reliable than cleaning up on stop. I added that to the code.

> So in my review I propose a bit agressive solutions: flushing entries on each notification. Of course, we should be careful to not break the connection if we do that, it is maybe a bad idea. I'm a little concerned about flushing entries on notification for the exact reason you are mentioning. I'd rather test it in another version as you are suggesting :) > But we can cleanup at process start. Cleaning on stopping is a bit hard as process may crash, I think it will be lot of burden for close to no gain Agreed. Cleaning up on start is much more reliable than cleaning up on stop. I added that to the code.
quentin merged commit 7ec74a21d4 into master 12 months ago
quentin deleted branch add-firewall-rules 12 months ago
The pull request has been merged as 7ec74a21d4.
Sign in to join this conversation.
No reviewers
No Label
No Milestone
No Assignees
2 Participants
Notifications
Due Date

No due date set.

Dependencies

This pull request currently doesn't have any dependencies.

Loading…
There is no content yet.