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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ jobs:
run: |
apt-get update
apt-get install -y --no-install-recommends iproute2 nftables openssh-server \
openssh-client curl python3 python3-venv util-linux procps ca-certificates
openssh-client curl python3 python3-venv util-linux procps ca-certificates \
iptables
python3 -m venv /opt/sshuttle
/opt/sshuttle/bin/pip install sshuttle==2.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Depends:
python3-gi,
sshuttle (>= 1.3.1),
Recommends:
iptables,
systemd-resolved,
Description: NetworkManager VPN plugin that tunnels with sshuttle
nm-sshuttle runs sshuttle as a NetworkManager VPN connection. The tunnel is
Expand Down
31 changes: 24 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ keys:
| `dns` | `split` | `none`, `split` or `all`. |
| `dns-servers` | `10.1.0.53` | Required for `split` and `all`. IPv4 only. |
| `dns-domains` | `corp.example,lab.example` | Routing domains for `split`. |
| `method` | `nft` | `nft` (default) or `nat`. `tproxy` comes later. |
| `method` | `nft` | `nft` (default), `nat`, or `tproxy`, which also carries UDP ([tproxy](later/tproxy.md)). |
| `gateway` | `203.0.113.7` | Override the first hop, IPv4 or IPv6. Needed with `ProxyCommand`. |
| `probe` | `10.1.0.10:22` | Optional reachability check through the tunnel, `ADDRESS:PORT` or `[IPV6]:PORT`. The service must send first, as an ssh server does (§4.4, "Health"). |
| `fail-closed` | `yes` | Install the guard table (default `yes`). |
Expand Down Expand Up @@ -1260,7 +1260,9 @@ Events and what the supervisor does:
state is not evidence (see "Activated is not proof"):
- the tunnel unit is active (an unreadable state is no failure);
- with `method=nft`, sshuttle's nft table exists and a listener holds its
port;
port; with `method=tproxy`, its mark chain in iptables' `mangle` table
does, for each tunnelled family, and the plugin's rule and route are
there (put back if not);
- the guard table exists (re-install it if something flushed the ruleset);
- optionally, the `probe` TCP connect through the tunnel, where two
failures in a row cause a restart. This covers sshuttle's "process
Expand Down Expand Up @@ -1469,7 +1471,10 @@ The IPv6 sets are always there, empty for an IPv4-only profile
([IPv6](later/ipv6.md)). The exclude sets hold every address of the first
hop and the profile's `exclude`. sshuttle's NAT redirect runs earlier, at
priority `dstnat`, so packets it redirected carry conntrack status `dnat`
when they reach the guard.
when they reach the guard. With `method=tproxy` there is no NAT; two rules
ahead of the `dnat` ones accept what sshuttle's mangle chain marked
(`ip daddr @subnets4 meta mark 0x4e530000 accept`, and the same for IPv6;
[tproxy](later/tproxy.md)).

**[lab T4]** In the lab, while the tunnel was down, connections failed in
about 10 ms instead of leaking to the default route. In the lab's container,
Expand Down Expand Up @@ -1680,11 +1685,18 @@ below used sshuttle 2.0.0 with the nft method in an Ubuntu 24.04 container
| T12 | The IPv6 guard sets: redirected IPv6 passes; tunnel down, IPv6 TCP is refused in milliseconds although a route would deliver it, and the excluded jump host stays reachable over IPv6 || PASS |
| T13 | A first hop with only an IPv6 address inside a tunnelled IPv6 subnet: ssh's own connection is not captured || PASS |
| T14 | IPv6 off on the host: the plugin's check sees it, sshuttle without `--disable-ipv6` stops, and with it and the IPv6 networks left out it carries IPv4 | PASS |
| T15 | `method=tproxy` behind the plugin's guard and policy routing: DNS pinned to the tunnel link with `IP_UNICAST_IF` is captured and answered via the jump host | PASS |
| T16 | `method=tproxy`: a UDP echo and TCP go through the tunnel, and nothing to the internal network leaves on the uplink | PASS |
| T17 | `method=tproxy`, tunnel down: UDP is refused locally, TCP in milliseconds, nothing leaves on the uplink | PASS |
| T18 | `method=tproxy`: SIGKILL leaves the mangle chains, which neither answer nor leak; the plugin's sweep removes them, and the rule and route stay | PASS |
| T19 | `method=tproxy`: a UDP flow silent for 40 s answers again | PASS |

T11 to T13 need IPv6 in the kernel, which the container this table was
first written in lacked; they passed in CI's lab job, as did all the
others. T1 to T10 and T14 passed in both, with the guard of §4.6, IPv6 sets
included.
included. T15 to T19 use the plugin's own guard, policy routing and sweep,
and give the host a route to the internal network through the jump host,
so that a leak would arrive.

The dummy link is replaced by a veth whose peer sits in an empty namespace,
because the lab kernel has no `dummy` module. For these tests the two
Expand Down Expand Up @@ -1817,8 +1829,9 @@ Later, also propose a hook for an externally provided firewall helper
- **The park candidate** (M2-26): NM stayed "activating" 4 s into a
parked gap with no flip, and a kill in a parked gap left no VPN DNS
entry, unlike M2-09. One run is not enough to turn it on by default.
The first CI run logged one SELinux denial that the second did not; its
record was not kept.
Some CI runs log one SELinux denial that others do not: Fedora's
`agetty` reading the console issue file NetworkManager's dispatcher
writes. It is not the plugin's, and M2-15 reports it as INFO.
**Third VM run** (CI, 2026-10-11; 482 checks, no failure, no SELinux
denial) added M2-29: with a ProxyJump host named inside `~corp.test`,
the reconnect after a kill found the name unresolvable and came back
Expand Down Expand Up @@ -1854,4 +1867,8 @@ Later, also propose a hook for an externally provided firewall helper
design in [`later/`](later/README.md). **IPv6 is in:** IPv6 subnets
and exclusions, the IPv6 guard sets, an IPv6 first hop and `gateway`,
and the IPv6 part of the network key, with unit tests, lab tests T11 to
T14 and VM scenarios V6-01 and V6-02.
T14 and VM scenarios V6-01 and V6-02. **So is `method=tproxy`:** UDP
through the tunnel, the guard's mark rules, policy routing the plugin
owns, the sweep of sshuttle's mangle chains and a per-method health
check, with unit tests, lab tests T15 to T19 and VM scenarios TP-01 to
TP-03.
2 changes: 1 addition & 1 deletion docs/later/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The [design](../design.md)'s roadmap (§7) ends with work left out of v1.
Each item has a design here. They build on the architecture of §4 and
change nothing in it unless they say so. Implemented: IPv6 and the GNOME editor.
change nothing in it unless they say so. Implemented: IPv6, the GNOME editor and tproxy.

| Design | What it adds | Size |
|---|---|---|
Expand Down
10 changes: 6 additions & 4 deletions docs/later/gnome-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ One column of rows, in the order a user fills them in:
| DNS domains | entry; sensitive for "split" | `dns-domains` |
| Advanced (expander): first hop | entry, empty means "from ssh -G" | `gateway` |
| Advanced: probe | entry, `ADDRESS:PORT` | `probe` |
| Advanced: method | drop-down: nft, nat, and tproxy once it exists | `method` |
| Advanced: method | drop-down: nft, nat, tproxy | `method` |
| Advanced: block traffic while down | switch, on by default | `fail-closed` |
| Local user | label | `local-user` |

Expand Down Expand Up @@ -274,6 +274,8 @@ NetworkManager or the plugin.
the plugin; DNS servers stay IPv4. The IPv6 tab has the same "Use this
connection only for resources on its network" switch, so the note names
both tabs.
- **Not done yet:** `method=tproxy` and the keys of several tunnels. Each is
a change to `nm-sshuttle-validate.c` and to the cases when the plugin gets
it; the shared cases fail on whichever side is behind.
- **`method=tproxy`** is in the drop-down and accepted, as in the plugin
([tproxy](tproxy.md)).
- **Not done yet:** the keys of several tunnels. That is a change to
`nm-sshuttle-validate.c` and to the cases when the plugin gets them; the
shared cases fail on whichever side is behind.
40 changes: 36 additions & 4 deletions docs/later/tproxy.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# UDP through `method=tproxy`

Status: design, not implemented. One of the "Later" items of the
Status: implemented. One of the "Later" items of the
[design](../design.md) (§7); the index is in [README.md](README.md).
"What was built" at the end lists the results and where the code differs
from this design.

v1 tunnels TCP only. sshuttle's `nft` and `nat` methods redirect TCP, plus
UDP to port 53 of the `--ns-hosts` servers; any other UDP to a tunnelled
Expand Down Expand Up @@ -232,12 +234,42 @@ About 120 lines of Python plus tests.
`nmcli general reload`; no SELinux denial (`iptables` runs from the
helper as `iptables_t`).

## What was built

The design as above, with these results and details:

- **DNS works (lab T15):** a query pinned to the tunnel link with
`IP_UNICAST_IF` is rerouted to `lo` by the mark and answered through
the jump host, so `dns=split` and `dns=all` stay allowed with `tproxy`.
- **Slot 0 only for now:** mark `0x4e530000`, table 20051, priority 100.
The functions take the slot, for the multiple tunnels design.
- **The routing is installed whether or not the profile fails closed**
(`fail-closed=no` drops only the guard), and only for the families the
tunnel carries. It is removed for both families.
- **The sweep** runs `iptables -w -t mangle -S` and `ip6tables`, deletes
the `OUTPUT` and `PREROUTING` jumps to stale chains, then flushes and
deletes the chains. Listing does not create a `mangle` table.
- **Health:** a family without a live `sshuttle-m-PORT` chain is a drop,
as a missing nft table is for `nft`; a missing rule or route is put
back. `nft flush ruleset` removes iptables-nft's chains and the guard,
not the rule or route. With iptables-legacy the chains are not in nft
and survive the flush, so only the guard comes back and the tunnel
stays up. Fedora's sshuttle package pulls in iptables-legacy, and in
the test VM `iptables` then uses it (VM TP-02).
- **Packaging:** `Recommends: iptables` (deb) and `iptables-nft` (RPM).
`nm-sshuttle check` fails a `tproxy` profile without them.
- **Tests:** lab T15 to T19 with the plugin's own guard, routing and
sweep, a route to the internal network through the jump host and a
counter on the uplink, so that a leak would show. T18 shows that stale
chains black-hole UDP rather than leak it. VM scenarios TP-01 (UDP,
TCP and split DNS, a gap), TP-02 (killed sshuttle, `nft flush
ruleset`) and TP-03 (`nmcli general reload` and a roam leave the rule
and route alone) pass on Fedora 44.

## Open questions

- DNS through `tproxy` with `IP_UNICAST_IF` (T15). The answer decides
whether `dns=split` works with this method at all.
- Whether `rp_filter` or a firewalld setting drops packets that come back
in on `lo` with an uplink source address. The loopback device is
normally exempt from reverse path filtering (inferred).
- Whether NetworkManager leaves the rule and the route on `lo` alone on
every target release (the VM test above).
every target release. It does on Fedora 44 (VM TP-03).
5 changes: 3 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The options of `add`:
| `--dns-domains` | The domains `split` sends there, subdomains included. `~corp.example` only routes lookups. A plain `corp.example` also completes short names, so `git` becomes `git.corp.example`. Quote a leading `~` in the shell. |
| `--gateway` | The address ssh connects to first, when the plugin cannot work it out (`ProxyCommand`). |
| `--probe` | `ADDRESS:PORT` (or `[IPV6]:PORT`) of a server on the far side that speaks first, such as an ssh server. The plugin connects to it every 10 s and restarts the tunnel after two failures in a row. Without it, a tunnel that is up but stuck is noticed only when sshuttle exits. |
| `--method` | `nft` (the default) or `nat`. |
| `--method` | `nft` (the default) or `nat`. `tproxy` also sends UDP through the tunnel, such as QUIC or a DNS server you query directly; it needs `iptables` installed. |
| `--fail-closed` | `yes` (the default) blocks traffic to the tunnelled networks while the tunnel is down. `no` lets it go out on the local network. |
| `--user` | Whose ssh configuration and agent to use. The default is you. |

Expand Down Expand Up @@ -210,7 +210,8 @@ Common causes:

- One tunnel at a time.
- TCP and DNS go through the tunnel; other UDP traffic, such as QUIC,
does not.
does so only with `--method tproxy`. A UDP flow that is silent for 30 s
starts afresh. `ping` never goes through: it is blocked.
- DNS servers on the far side must have IPv4 addresses. If a split domain
answers with IPv6 addresses, list those networks in `--subnets` too, or
connections to them go around the tunnel.
Expand Down
2 changes: 1 addition & 1 deletion gnome/nm-sshuttle-editor.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
#define RESOURCE "/io/github/dennisklein/nm-sshuttle/nm-sshuttle-editor.ui"

static const char *const DNS_MODES[] = {"none", "split", "all"};
static const char *const METHODS[] = {"nft", "nat"};
static const char *const METHODS[] = {"nft", "nat", "tproxy"};

/* The rows of the form, in the order of NmssEditor.rows. */
enum {
Expand Down
3 changes: 2 additions & 1 deletion gnome/nm-sshuttle-editor.ui
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@
<items>
<item>nft</item>
<item>nat</item>
<item>tproxy</item>
</items>
</object>
</property>
Expand All @@ -419,7 +420,7 @@
</child>
<child>
<object class="GtkLabel" id="hint_method">
<property name="label" translatable="yes">How traffic is captured. nft is the default</property>
<property name="label" translatable="yes">How traffic is captured. nft is the default; tproxy also carries UDP</property>
<property name="xalign">0</property>
<property name="wrap">True</property>
<property name="max-width-chars">64</property>
Expand Down
4 changes: 2 additions & 2 deletions gnome/nm-sshuttle-validate.c
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,8 @@ nmss_check_dns_domains(const char *dns, const char *value, GError **error)
gboolean
nmss_check_method(const char *value, GError **error)
{
if (value && !g_strv_contains((const char *const[]) {"nft", "nat", NULL}, value))
return fail(error, NMSS_FIELD_METHOD, _("Method: expected nft or nat, not “%s”"), value);
if (value && !g_strv_contains((const char *const[]) {"nft", "nat", "tproxy", NULL}, value))
return fail(error, NMSS_FIELD_METHOD, _("Method: expected nft, nat or tproxy, not “%s”"), value);
return TRUE;
}

Expand Down
4 changes: 2 additions & 2 deletions gnome/tests/test-editor.c
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ cli_connection(void)
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "remote", "corp");
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "local-user", g_get_user_name());
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "subnets", "10.0.0.0/8");
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "method", "tproxy");
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "method", "socks");
nm_setting_vpn_add_data_item(NM_SETTING_VPN(s_vpn), "from-a-newer-cli", "x");
nm_connection_add_setting(connection, s_vpn);
return connection;
Expand Down Expand Up @@ -234,7 +234,7 @@ test_existing_profile(Fixture *f, gconstpointer user_data)
g_assert_false(nm_vpn_editor_update_connection(f->editor, f->connection, &error));
g_assert_error(error, NMSS_VALIDATE_ERROR, NMSS_FIELD_METHOD);
g_clear_error(&error);
g_assert_cmpstr(nm_setting_vpn_get_data_item(s_vpn, "method"), ==, "tproxy");
g_assert_cmpstr(nm_setting_vpn_get_data_item(s_vpn, "method"), ==, "socks");

gtk_drop_down_set_selected(GTK_DROP_DOWN(method), 1); /* nat */
g_assert_true(nm_vpn_editor_update_connection(f->editor, f->connection, &error));
Expand Down
15 changes: 12 additions & 3 deletions lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ namespaces, veth links, nft tables and a local user (`nmss-lab`), and it
changes the address of its own veth link.

```console
# apt install iproute2 nftables openssh-server openssh-client curl python3
# apt install iproute2 nftables iptables openssh-server openssh-client curl python3
# pip install sshuttle # or the distro package
# SSHUTTLE=$(command -v sshuttle) ./lab/run.sh
```

It takes about two minutes, most of it in the roaming test (T10). It exits
It takes about three minutes, most of it in the roaming test (T10) and
the silent UDP flow (T19). It exits
non-zero if any check fails.

## What it checks
Expand All @@ -48,8 +49,16 @@ non-zero if any check fails.
| T12 | The guard's IPv6 sets let redirected traffic through. With the tunnel down they refuse IPv6 TCP at once, although the host's route would deliver it (shown without the guard), and the excluded jump host stays reachable over IPv6. |
| T13 | A first hop reached by its IPv6 address inside a tunnelled IPv6 subnet: with it excluded, ssh's own connection is not captured. |
| T14 | With IPv6 off on the host the plugin's check sees it, sshuttle stops without `--disable-ipv6`, and with it (and the IPv6 networks left out, as the plugin does) the tunnel carries IPv4. |
| T15 | `method=tproxy` behind the plugin's guard and policy routing: DNS pinned to the tunnel link with `IP_UNICAST_IF` is captured and answered through the jump host. |
| T16 | `method=tproxy`: a UDP echo and TCP go through the tunnel, and nothing to the internal network leaves on the uplink. |
| T17 | `method=tproxy`, tunnel down: UDP is refused locally and TCP at once, and nothing leaves on the uplink. |
| T18 | `method=tproxy`: SIGKILL leaves sshuttle's mangle chains, which neither answer nor leak; the plugin's sweep removes them and leaves its rule and route. |
| T19 | `method=tproxy`: a UDP flow silent for 40 s answers again. |

T11 to T13 print SKIP on a kernel without IPv6.
T11 to T13 print SKIP on a kernel without IPv6, and T15 to T19 without
`iptables` and `ip6tables`. T15 to T19 use the plugin's code from this
checkout, give the host a route to 10.99.0.0/24 through the jump host and
count what leaves on the uplink.

The per-tunnel dummy link is replaced by a veth whose peer sits in an empty
namespace, so the lab also runs on kernels built without the `dummy` module.
Expand Down
Loading