t-eth-er 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- t_eth_er-1.2.0/.gitignore +5 -0
- t_eth_er-1.2.0/PKG-INFO +45 -0
- t_eth_er-1.2.0/README.md +36 -0
- t_eth_er-1.2.0/pyproject.toml +20 -0
- t_eth_er-1.2.0/release +47 -0
- t_eth_er-1.2.0/tETHer/__init__.py +0 -0
- t_eth_er-1.2.0/tETHer/main.py +432 -0
t_eth_er-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-eth-er
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Ethernet tethering for Linux - NAT a single device through an interface
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ethernet,iptables,nat,networking,tethering
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# tETHer - ethernet tethering
|
|
11
|
+
Tether one device to another over an ethernet interface on linux (rather than USB)
|
|
12
|
+
|
|
13
|
+
Tethering proxies one device through another devices internet connections. A common use case is to connect to a mobile phone over USB and then use its internet connection. tETHer does the same thing but via an ethernet connection such as an rj-45 cable.
|
|
14
|
+
|
|
15
|
+
This also allows forwarding
|
|
16
|
+
|
|
17
|
+
## Motivation
|
|
18
|
+
I had a camera which only supported wired connection. I did not want to run a long wire to a router and wireless bridges were moderately expensive so I decided to get this working with an old raspberry pi. I don't really like "infrastructure" because I forget how it works. I prefer tools which minimise the amount of infrastructure so I coded this.
|
|
19
|
+
|
|
20
|
+
## Alterantives and prior work
|
|
21
|
+
Use a wireless bridge. Hand code the networking yourself using wireless.
|
|
22
|
+
|
|
23
|
+
This is very similar to the idea of setting up an *access point*, but it has some additional features for port forwarding which are made easier if you are providing access or a single device.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
tETHer requires `nmap` `dnsmasq` and Linux. On a linux machine install them: `sudo apt install nmap dnsmasq` pipx.
|
|
27
|
+
|
|
28
|
+
You can then install with pipx: `pipx install tETHer`
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
Set up dhcp and forwarding on eth0 so that if you plug in a device which uses DHCP it will connect to you and then via you to the rest of the network - including the internet.
|
|
32
|
+
|
|
33
|
+
`eth-tether eth0`
|
|
34
|
+
|
|
35
|
+
If you want to forward ports you can capture all the ports that the tethering machine has
|
|
36
|
+
|
|
37
|
+
`eth-tether eth0 --scan ports`
|
|
38
|
+
|
|
39
|
+
Then on subsequent files you can use the generated ports file
|
|
40
|
+
|
|
41
|
+
`eth-tether eth0 --ports ports`
|
|
42
|
+
|
|
43
|
+
To avoid requiring too many permissions, tETHer uses sudo to run privileged commands. You can allow your use to only run these commands or you can run the entire commadn with sudo. To get a sudoers file alowing these commands run `eth-tether eth0 --sudoers`
|
|
44
|
+
|
|
45
|
+
|
t_eth_er-1.2.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# tETHer - ethernet tethering
|
|
2
|
+
Tether one device to another over an ethernet interface on linux (rather than USB)
|
|
3
|
+
|
|
4
|
+
Tethering proxies one device through another devices internet connections. A common use case is to connect to a mobile phone over USB and then use its internet connection. tETHer does the same thing but via an ethernet connection such as an rj-45 cable.
|
|
5
|
+
|
|
6
|
+
This also allows forwarding
|
|
7
|
+
|
|
8
|
+
## Motivation
|
|
9
|
+
I had a camera which only supported wired connection. I did not want to run a long wire to a router and wireless bridges were moderately expensive so I decided to get this working with an old raspberry pi. I don't really like "infrastructure" because I forget how it works. I prefer tools which minimise the amount of infrastructure so I coded this.
|
|
10
|
+
|
|
11
|
+
## Alterantives and prior work
|
|
12
|
+
Use a wireless bridge. Hand code the networking yourself using wireless.
|
|
13
|
+
|
|
14
|
+
This is very similar to the idea of setting up an *access point*, but it has some additional features for port forwarding which are made easier if you are providing access or a single device.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
tETHer requires `nmap` `dnsmasq` and Linux. On a linux machine install them: `sudo apt install nmap dnsmasq` pipx.
|
|
18
|
+
|
|
19
|
+
You can then install with pipx: `pipx install tETHer`
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
Set up dhcp and forwarding on eth0 so that if you plug in a device which uses DHCP it will connect to you and then via you to the rest of the network - including the internet.
|
|
23
|
+
|
|
24
|
+
`eth-tether eth0`
|
|
25
|
+
|
|
26
|
+
If you want to forward ports you can capture all the ports that the tethering machine has
|
|
27
|
+
|
|
28
|
+
`eth-tether eth0 --scan ports`
|
|
29
|
+
|
|
30
|
+
Then on subsequent files you can use the generated ports file
|
|
31
|
+
|
|
32
|
+
`eth-tether eth0 --ports ports`
|
|
33
|
+
|
|
34
|
+
To avoid requiring too many permissions, tETHer uses sudo to run privileged commands. You can allow your use to only run these commands or you can run the entire commadn with sudo. To get a sudoers file alowing these commands run `eth-tether eth0 --sudoers`
|
|
35
|
+
|
|
36
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [ "hatchling",]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "t-eth-er"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Ethernet tethering for Linux - NAT a single device through an interface"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
keywords = [ "networking", "nat", "tethering", "ethernet", "iptables",]
|
|
12
|
+
|
|
13
|
+
[project.license]
|
|
14
|
+
text = "MIT"
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
eth-tether = "tETHer.main:main"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = [ "tETHer",]
|
t_eth_er-1.2.0/release
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -o errexit
|
|
3
|
+
set -o nounset
|
|
4
|
+
set -o pipefail
|
|
5
|
+
|
|
6
|
+
# SSH ControlMaster setup
|
|
7
|
+
CONTROL_PATH="/tmp/ssh-release-%r@%h:%p"
|
|
8
|
+
|
|
9
|
+
echo "🔐 Establishing SSH connection to GitHub..."
|
|
10
|
+
ssh -o ControlMaster=yes -o ControlPath="$CONTROL_PATH" -o ControlPersist=300 -fN git@github.com
|
|
11
|
+
|
|
12
|
+
# Use ControlMaster for git
|
|
13
|
+
export GIT_SSH_COMMAND="ssh -o ControlPath=$CONTROL_PATH"
|
|
14
|
+
|
|
15
|
+
# Fail if there are any modified files
|
|
16
|
+
if ! git diff-index --quiet HEAD --; then
|
|
17
|
+
echo "Error: You have uncommitted changes to tracked files"
|
|
18
|
+
git status --short
|
|
19
|
+
ssh -o ControlPath="$CONTROL_PATH" -O exit git@github.com 2>/dev/null || true
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
git push
|
|
24
|
+
|
|
25
|
+
if [ -f pyproject.toml ]; then
|
|
26
|
+
VERSION=$(cat pyproject.toml | toml2json | jq -rc '.project.version')
|
|
27
|
+
else
|
|
28
|
+
VERSION=$(python3 setup.py --version)
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
git tag "$VERSION"
|
|
32
|
+
git push --tags
|
|
33
|
+
|
|
34
|
+
rm -rf dist *.egg-info build
|
|
35
|
+
|
|
36
|
+
if [ -f pyproject.toml ]; then
|
|
37
|
+
python3 -m build
|
|
38
|
+
else
|
|
39
|
+
python3 setup.py sdist
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
twine upload -p $(cat token) dist/*
|
|
43
|
+
|
|
44
|
+
# Close ControlMaster
|
|
45
|
+
ssh -o ControlPath="$CONTROL_PATH" -O exit git@github.com 2>/dev/null || true
|
|
46
|
+
|
|
47
|
+
echo "✅ Released version $VERSION"
|
|
File without changes
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
tETHer - Ethernet tethering for Linux.
|
|
4
|
+
|
|
5
|
+
NAT a single device through an interface with optional port forwarding.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
eth-tether <interface> --via <upstream> [--name <label>]
|
|
9
|
+
[--ports <file>] [--scan <file>] [--scan-delay <seconds>]
|
|
10
|
+
eth-tether --sudoers
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
eth-tether eth0 --via wlan0
|
|
14
|
+
eth-tether eth0 --via wlan0 --name camera --scan ~/camera-ports.txt
|
|
15
|
+
eth-tether eth0 --via wlan0 --name camera --ports ~/camera-ports.txt
|
|
16
|
+
eth-tether --sudoers | sudo tee /etc/sudoers.d/eth-tether
|
|
17
|
+
|
|
18
|
+
Port file format (one per line):
|
|
19
|
+
80:80
|
|
20
|
+
554:554
|
|
21
|
+
# comments are ignored
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import atexit
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
INTERNAL_IP = "10.42.0.1"
|
|
34
|
+
DHCP_RANGE_START = "10.42.0.100"
|
|
35
|
+
DHCP_RANGE_END = "10.42.0.200"
|
|
36
|
+
LEASE_FILE = "/tmp/tether-leases"
|
|
37
|
+
SCAN_DELAY_DEFAULT = 10
|
|
38
|
+
|
|
39
|
+
cleanup_actions = []
|
|
40
|
+
|
|
41
|
+
PRIVILEGED_COMMANDS = ["ip", "iptables", "sysctl", "dnsmasq", "pkill", "nmap", "resolvectl"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_default_route_iface():
|
|
45
|
+
"""Return the interface used by the default route, or None."""
|
|
46
|
+
result = run_capture("ip route show default")
|
|
47
|
+
for line in result.splitlines():
|
|
48
|
+
parts = line.split()
|
|
49
|
+
if 'dev' in parts:
|
|
50
|
+
return parts[parts.index('dev') + 1]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def print_sudoers(iface, via):
|
|
55
|
+
user = os.environ.get("USER", "YOUR_USER")
|
|
56
|
+
|
|
57
|
+
def which(cmd):
|
|
58
|
+
return run_capture(f"which {cmd}") or f"/usr/bin/{cmd}"
|
|
59
|
+
|
|
60
|
+
ip = which("ip")
|
|
61
|
+
ipt = which("iptables")
|
|
62
|
+
sysctl = which("sysctl")
|
|
63
|
+
dnsmasq = which("dnsmasq")
|
|
64
|
+
pkill = which("pkill")
|
|
65
|
+
nmap = which("nmap")
|
|
66
|
+
resolvectl = which("resolvectl")
|
|
67
|
+
|
|
68
|
+
cmds = [
|
|
69
|
+
f"{ip} addr flush dev {iface}",
|
|
70
|
+
f"{ip} addr add {INTERNAL_IP}/24 dev {iface}",
|
|
71
|
+
f"{ip} link set {iface} up",
|
|
72
|
+
f"{ip} link set {iface} down",
|
|
73
|
+
f"{sysctl} -w net.ipv4.ip_forward=1",
|
|
74
|
+
f"{ipt} -t nat -A POSTROUTING -o {via} -j MASQUERADE",
|
|
75
|
+
f"{ipt} -t nat -D POSTROUTING -o {via} -j MASQUERADE",
|
|
76
|
+
f"{ipt} -A FORWARD -i {iface} -o {via} -j ACCEPT",
|
|
77
|
+
f"{ipt} -D FORWARD -i {iface} -o {via} -j ACCEPT",
|
|
78
|
+
r"{ipt} -A FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
|
|
79
|
+
r"{ipt} -D FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
|
|
80
|
+
f"{pkill} -F /tmp/tether-dnsmasq-{iface}.pid",
|
|
81
|
+
f"{pkill} -HUP -F /tmp/tether-dnsmasq-{iface}.pid",
|
|
82
|
+
r"{dnsmasq} ^--conf-file=/tmp/[^ ]+\.conf$".format(dnsmasq=dnsmasq),
|
|
83
|
+
r"{ipt} ^-t nat -A PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
|
|
84
|
+
r"{ipt} ^-t nat -D PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
|
|
85
|
+
r"{ipt} ^-A FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
|
|
86
|
+
r"{ipt} ^-D FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
|
|
87
|
+
r"{nmap} ^-p- --open -T4 --host-timeout 60s -oG - 10\.42\.[0-9]+\.[0-9]+$".format(nmap=nmap),
|
|
88
|
+
f"{resolvectl} dns {iface} 10.42.0.1",
|
|
89
|
+
f"{resolvectl} domain {iface} tether",
|
|
90
|
+
f"{resolvectl} revert {iface}",
|
|
91
|
+
f"/bin/rm -f {LEASE_FILE}",
|
|
92
|
+
f"/bin/rm -f /tmp/tether-dnsmasq-{iface}.pid",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
print("# Generated by eth-tether --sudoers")
|
|
96
|
+
print(f"# Install with: eth-tether --sudoers {iface} {via} | sudo tee /etc/sudoers.d/eth-tether")
|
|
97
|
+
print()
|
|
98
|
+
for cmd in cmds:
|
|
99
|
+
print(f"{user} ALL=(ALL) NOPASSWD: {cmd}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
debug_mode = False
|
|
103
|
+
has_resolvectl = None
|
|
104
|
+
|
|
105
|
+
def check_resolvectl():
|
|
106
|
+
global has_resolvectl
|
|
107
|
+
if has_resolvectl is None:
|
|
108
|
+
has_resolvectl = bool(run_capture("which resolvectl"))
|
|
109
|
+
if not has_resolvectl:
|
|
110
|
+
print("[tETHer] resolvectl not found, skipping DNS registration")
|
|
111
|
+
return has_resolvectl
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run(cmd, check=True):
|
|
115
|
+
subprocess.run(cmd, shell=True, check=check)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_capture(cmd):
|
|
119
|
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
120
|
+
return result.stdout.strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sudo(cmd, check=True):
|
|
124
|
+
full = f"sudo {cmd}"
|
|
125
|
+
if debug_mode:
|
|
126
|
+
print(f"[tETHer] $ {full}")
|
|
127
|
+
subprocess.run(full, shell=True, check=check)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sudo_capture(cmd):
|
|
131
|
+
return run_capture(f"sudo {cmd}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cleanup():
|
|
135
|
+
print("\n[tETHer] Cleaning up...")
|
|
136
|
+
for action in reversed(cleanup_actions):
|
|
137
|
+
try:
|
|
138
|
+
action()
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"[tETHer] Warning during cleanup: {e}")
|
|
141
|
+
print("[tETHer] Done.")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def setup_interface(iface):
|
|
145
|
+
print(f"[tETHer] Bringing up {iface} with IP {INTERNAL_IP}...")
|
|
146
|
+
sudo(f"ip addr flush dev {iface}")
|
|
147
|
+
sudo(f"ip addr add {INTERNAL_IP}/24 dev {iface}")
|
|
148
|
+
sudo(f"ip link set {iface} up")
|
|
149
|
+
cleanup_actions.append(lambda: sudo(f"ip addr flush dev {iface}", check=False))
|
|
150
|
+
cleanup_actions.append(lambda: sudo(f"ip link set {iface} down", check=False))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def setup_nat(iface, via):
|
|
154
|
+
print(f"[tETHer] Setting up NAT from {iface} via {via}...")
|
|
155
|
+
sudo("sysctl -w net.ipv4.ip_forward=1")
|
|
156
|
+
sudo(f"iptables -t nat -A POSTROUTING -o {via} -j MASQUERADE")
|
|
157
|
+
sudo(f"iptables -A FORWARD -i {iface} -o {via} -j ACCEPT")
|
|
158
|
+
sudo(f"iptables -A FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT")
|
|
159
|
+
cleanup_actions.append(lambda: sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False))
|
|
160
|
+
cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False))
|
|
161
|
+
cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def kill_dnsmasq(iface):
|
|
165
|
+
"""Kill tETHer dnsmasq for this interface using pidfile."""
|
|
166
|
+
pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
|
|
167
|
+
if not os.path.exists(pidfile):
|
|
168
|
+
return
|
|
169
|
+
sudo(f"pkill -F {pidfile}", check=False)
|
|
170
|
+
# Wait for process to actually exit
|
|
171
|
+
for _ in range(20):
|
|
172
|
+
time.sleep(0.2)
|
|
173
|
+
result = subprocess.run(f"pkill -0 -F {pidfile}", shell=True, capture_output=True)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def start_dnsmasq(iface, conf_path=None):
|
|
179
|
+
print(f"[tETHer] Starting dnsmasq on {iface}...")
|
|
180
|
+
pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
|
|
181
|
+
|
|
182
|
+
if conf_path is None:
|
|
183
|
+
conf = tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False)
|
|
184
|
+
conf.write(f"""interface={iface}
|
|
185
|
+
bind-interfaces
|
|
186
|
+
dhcp-range={DHCP_RANGE_START},{DHCP_RANGE_END},12h
|
|
187
|
+
dhcp-leasefile={LEASE_FILE}
|
|
188
|
+
pid-file={pidfile}
|
|
189
|
+
log-dhcp
|
|
190
|
+
log-queries
|
|
191
|
+
filter-AAAA
|
|
192
|
+
""")
|
|
193
|
+
conf.flush()
|
|
194
|
+
conf.close()
|
|
195
|
+
conf_path = conf.name
|
|
196
|
+
cleanup_actions.append(lambda: os.unlink(conf_path) if os.path.exists(conf_path) else None)
|
|
197
|
+
cleanup_actions.append(lambda: os.unlink(LEASE_FILE) if os.path.exists(LEASE_FILE) else None)
|
|
198
|
+
|
|
199
|
+
if debug_mode:
|
|
200
|
+
print(f"[tETHer] dnsmasq conf ({conf_path}):")
|
|
201
|
+
with open(conf_path) as f:
|
|
202
|
+
for line in f:
|
|
203
|
+
print(f" {line}", end="")
|
|
204
|
+
|
|
205
|
+
kill_dnsmasq(iface)
|
|
206
|
+
sudo(f"dnsmasq --conf-file={conf_path}")
|
|
207
|
+
|
|
208
|
+
for _ in range(20):
|
|
209
|
+
if os.path.exists(pidfile):
|
|
210
|
+
break
|
|
211
|
+
time.sleep(0.2)
|
|
212
|
+
else:
|
|
213
|
+
print(f"[tETHer] Warning: dnsmasq pidfile not found after startup")
|
|
214
|
+
|
|
215
|
+
cleanup_actions.append(lambda: kill_dnsmasq(iface))
|
|
216
|
+
return conf_path
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_leases():
|
|
220
|
+
leases = []
|
|
221
|
+
if not os.path.exists(LEASE_FILE):
|
|
222
|
+
return leases
|
|
223
|
+
with open(LEASE_FILE) as f:
|
|
224
|
+
for line in f:
|
|
225
|
+
parts = line.strip().split()
|
|
226
|
+
if len(parts) >= 3:
|
|
227
|
+
leases.append({'mac': parts[1], 'ip': parts[2]})
|
|
228
|
+
return leases
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def wait_for_device(name):
|
|
232
|
+
print(f"[tETHer] Waiting for '{name}' to connect...")
|
|
233
|
+
while True:
|
|
234
|
+
leases = parse_leases()
|
|
235
|
+
if leases:
|
|
236
|
+
lease = leases[0]
|
|
237
|
+
print(f"[tETHer] '{name}' connected: {lease['ip']} ({lease['mac']})")
|
|
238
|
+
return lease['ip'], lease['mac']
|
|
239
|
+
time.sleep(2)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def watch_for_rogues(expected_mac, name):
|
|
243
|
+
leases = parse_leases()
|
|
244
|
+
for lease in leases:
|
|
245
|
+
if lease['mac'] != expected_mac:
|
|
246
|
+
print(f"[tETHer] WARNING: Unexpected device connected: {lease['mac']} (expected '{name}' {expected_mac})")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_ports_file(path):
|
|
250
|
+
"""Load port mappings from a file. Returns list of (ext_port, int_port) tuples."""
|
|
251
|
+
ports = []
|
|
252
|
+
with open(path) as f:
|
|
253
|
+
for line in f:
|
|
254
|
+
line = line.strip()
|
|
255
|
+
if not line or line.startswith('#'):
|
|
256
|
+
continue
|
|
257
|
+
if ':' not in line:
|
|
258
|
+
print(f"[tETHer] Warning: skipping invalid port line: {line!r}")
|
|
259
|
+
continue
|
|
260
|
+
ext, int_ = line.split(':', 1)
|
|
261
|
+
ports.append((ext.strip(), int_.strip()))
|
|
262
|
+
return ports
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def save_ports_file(path, ports):
|
|
266
|
+
"""Save port mappings to a file."""
|
|
267
|
+
with open(path, 'w') as f:
|
|
268
|
+
f.write("# tETHer port forwarding config\n")
|
|
269
|
+
f.write("# format: external_port:internal_port\n")
|
|
270
|
+
for ext, int_ in ports:
|
|
271
|
+
f.write(f"{ext}:{int_}\n")
|
|
272
|
+
print(f"[tETHer] Saved port config to {path}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def scan_ports(device_ip, delay):
|
|
276
|
+
"""Scan device for open TCP ports. Returns list of (port, port) tuples."""
|
|
277
|
+
if delay > 0:
|
|
278
|
+
print(f"[tETHer] Waiting {delay}s for services to come up...")
|
|
279
|
+
time.sleep(delay)
|
|
280
|
+
|
|
281
|
+
print(f"[tETHer] Scanning {device_ip} for open ports...")
|
|
282
|
+
result = sudo_capture(f"nmap -p- --open -T4 --host-timeout 60s -oG - {device_ip}")
|
|
283
|
+
|
|
284
|
+
ports = []
|
|
285
|
+
for line in result.splitlines():
|
|
286
|
+
if 'Ports:' in line:
|
|
287
|
+
parts = line.split('Ports:')[1].strip().split(',')
|
|
288
|
+
for part in parts:
|
|
289
|
+
part = part.strip()
|
|
290
|
+
if '/open/tcp' in part:
|
|
291
|
+
port = part.split('/')[0].strip()
|
|
292
|
+
ports.append((port, port))
|
|
293
|
+
|
|
294
|
+
if ports:
|
|
295
|
+
print(f"[tETHer] Found open ports: {', '.join(p[0] for p in ports)}")
|
|
296
|
+
else:
|
|
297
|
+
print(f"[tETHer] No open ports found on {device_ip}")
|
|
298
|
+
|
|
299
|
+
return ports
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def register_name(iface, name, device_ip, dnsmasq_conf):
|
|
303
|
+
"""Register a local DNS name for the device via dnsmasq + systemd-resolved."""
|
|
304
|
+
print(f"[tETHer] Registering '{name}' -> {device_ip}...")
|
|
305
|
+
with open(dnsmasq_conf, 'a') as f:
|
|
306
|
+
f.write(f"address=/{name}.tether/{device_ip}\n")
|
|
307
|
+
if debug_mode:
|
|
308
|
+
print(f"[tETHer] dnsmasq conf after name registration:")
|
|
309
|
+
with open(dnsmasq_conf) as f:
|
|
310
|
+
for line in f:
|
|
311
|
+
print(f" {line}", end="")
|
|
312
|
+
start_dnsmasq(iface, conf_path=dnsmasq_conf)
|
|
313
|
+
if check_resolvectl():
|
|
314
|
+
sudo(f"resolvectl dns {iface} 10.42.0.1", check=False)
|
|
315
|
+
sudo(f"resolvectl domain {iface} tether", check=False)
|
|
316
|
+
cleanup_actions.append(lambda: sudo(f"resolvectl revert {iface}", check=False))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def force_cleanup(iface, via):
|
|
320
|
+
"""Best-effort cleanup of all tETHer state for a given interface."""
|
|
321
|
+
print(f"[tETHer] Forcing cleanup of {iface}...")
|
|
322
|
+
kill_dnsmasq(iface)
|
|
323
|
+
sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False)
|
|
324
|
+
sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False)
|
|
325
|
+
sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False)
|
|
326
|
+
sudo(f"ip addr flush dev {iface}", check=False)
|
|
327
|
+
sudo(f"ip link set {iface} down", check=False)
|
|
328
|
+
if check_resolvectl():
|
|
329
|
+
sudo(f"resolvectl revert {iface}", check=False)
|
|
330
|
+
for tmp in [LEASE_FILE, f"/tmp/tether-dnsmasq-{iface}.pid"]:
|
|
331
|
+
sudo(f"/bin/rm -f {tmp}", check=False)
|
|
332
|
+
print(f"[tETHer] Cleanup done.")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def setup_port_forwards(via, device_ip, ports):
|
|
336
|
+
for ext_port, int_port in ports:
|
|
337
|
+
print(f"[tETHer] Port forward: {via}:{ext_port} -> {device_ip}:{int_port}")
|
|
338
|
+
sudo(f"iptables -t nat -A PREROUTING -i {via} -p tcp --dport {ext_port} -j DNAT --to-destination {device_ip}:{int_port}")
|
|
339
|
+
sudo(f"iptables -A FORWARD -p tcp -d {device_ip} --dport {int_port} -j ACCEPT")
|
|
340
|
+
cleanup_actions.append(lambda ep=ext_port, ip=device_ip, p=int_port: (
|
|
341
|
+
sudo(f"iptables -t nat -D PREROUTING -i {via} -p tcp --dport {ep} -j DNAT --to-destination {ip}:{p}", check=False),
|
|
342
|
+
sudo(f"iptables -D FORWARD -p tcp -d {ip} --dport {p} -j ACCEPT", check=False),
|
|
343
|
+
))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main():
|
|
347
|
+
parser = argparse.ArgumentParser(
|
|
348
|
+
description="NAT tether a single device through an ethernet interface.",
|
|
349
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
350
|
+
epilog=__doc__
|
|
351
|
+
)
|
|
352
|
+
parser.add_argument("interface", nargs="?", help="Internal interface (e.g. eth0)")
|
|
353
|
+
parser.add_argument("--via", help="Upstream interface (e.g. wlan0)")
|
|
354
|
+
parser.add_argument("--name", default=None, help="Label for the expected device (default: interface name)")
|
|
355
|
+
parser.add_argument("--ports", metavar="FILE", help="Load port forwards from file")
|
|
356
|
+
parser.add_argument("--scan", metavar="FILE",
|
|
357
|
+
help="Scan device for open ports and save to file (skips scan if file exists)")
|
|
358
|
+
parser.add_argument("--scan-delay", type=int, default=SCAN_DELAY_DEFAULT, metavar="SECONDS",
|
|
359
|
+
help=f"Seconds to wait before scanning (default: {SCAN_DELAY_DEFAULT})")
|
|
360
|
+
parser.add_argument("--sudoers", action="store_true", help="Print sudoers rules and exit")
|
|
361
|
+
parser.add_argument("--cleanup", action="store_true", help="Force cleanup of tETHer state and exit")
|
|
362
|
+
parser.add_argument("--debug", action="store_true", help="Print commands as they are run")
|
|
363
|
+
args = parser.parse_args()
|
|
364
|
+
|
|
365
|
+
global debug_mode
|
|
366
|
+
debug_mode = args.debug
|
|
367
|
+
|
|
368
|
+
if not args.interface:
|
|
369
|
+
parser.error("interface is required")
|
|
370
|
+
|
|
371
|
+
if not args.name:
|
|
372
|
+
args.name = args.interface
|
|
373
|
+
|
|
374
|
+
if not args.via:
|
|
375
|
+
args.via = get_default_route_iface()
|
|
376
|
+
if not args.via:
|
|
377
|
+
parser.error("could not detect default route interface, please specify --via")
|
|
378
|
+
if not args.sudoers:
|
|
379
|
+
print(f"[tETHer] Using default route interface: {args.via}")
|
|
380
|
+
|
|
381
|
+
if args.sudoers:
|
|
382
|
+
print_sudoers(args.interface, args.via)
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if args.cleanup:
|
|
386
|
+
force_cleanup(args.interface, args.via)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
if args.ports and args.scan:
|
|
390
|
+
parser.error("--ports and --scan are mutually exclusive")
|
|
391
|
+
|
|
392
|
+
atexit.register(cleanup)
|
|
393
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
394
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
395
|
+
|
|
396
|
+
setup_interface(args.interface)
|
|
397
|
+
setup_nat(args.interface, args.via)
|
|
398
|
+
dnsmasq_conf = start_dnsmasq(args.interface)
|
|
399
|
+
|
|
400
|
+
device_ip, device_mac = wait_for_device(args.name)
|
|
401
|
+
register_name(args.interface, args.name, device_ip, dnsmasq_conf)
|
|
402
|
+
|
|
403
|
+
ports = []
|
|
404
|
+
|
|
405
|
+
if args.ports:
|
|
406
|
+
print(f"[tETHer] Loading ports from {args.ports}...")
|
|
407
|
+
ports = load_ports_file(args.ports)
|
|
408
|
+
|
|
409
|
+
elif args.scan:
|
|
410
|
+
scan_file = os.path.expanduser(args.scan)
|
|
411
|
+
if os.path.exists(scan_file):
|
|
412
|
+
print(f"[tETHer] Loading existing scan from {scan_file}...")
|
|
413
|
+
ports = load_ports_file(scan_file)
|
|
414
|
+
else:
|
|
415
|
+
ports = scan_ports(device_ip, args.scan_delay)
|
|
416
|
+
if ports:
|
|
417
|
+
save_ports_file(scan_file, ports)
|
|
418
|
+
|
|
419
|
+
if ports:
|
|
420
|
+
setup_port_forwards(args.via, device_ip, ports)
|
|
421
|
+
print(f"[tETHer] Ready. '{args.name}' reachable on {args.via} via {len(ports)} port(s).")
|
|
422
|
+
else:
|
|
423
|
+
print(f"[tETHer] Ready. No port forwards configured.")
|
|
424
|
+
|
|
425
|
+
print("[tETHer] Running. Press Ctrl+C to stop and clean up.")
|
|
426
|
+
while True:
|
|
427
|
+
watch_for_rogues(device_mac, args.name)
|
|
428
|
+
time.sleep(5)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
if __name__ == "__main__":
|
|
432
|
+
main()
|